Initial commit
This commit is contained in:
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
main.js
|
||||||
|
main.js.map
|
||||||
|
|
||||||
|
# Package folder
|
||||||
|
taskweaver-package/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Data files (plugin saves)
|
||||||
|
data.json
|
||||||
|
|
||||||
|
# NPM
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude
|
||||||
|
|
||||||
32
build-package.bat
Normal file
32
build-package.bat
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
@echo off
|
||||||
|
echo Building TaskWeaver plugin...
|
||||||
|
|
||||||
|
REM Run the build
|
||||||
|
call npm run build
|
||||||
|
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo Build failed!
|
||||||
|
exit /b %errorlevel%
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Build successful!
|
||||||
|
echo Packaging plugin files...
|
||||||
|
|
||||||
|
REM Create package directory
|
||||||
|
if exist "taskweaver-package" rmdir /s /q "taskweaver-package"
|
||||||
|
mkdir "taskweaver-package"
|
||||||
|
|
||||||
|
REM Copy required files
|
||||||
|
copy "main.js" "taskweaver-package\"
|
||||||
|
copy "manifest.json" "taskweaver-package\"
|
||||||
|
copy "styles.css" "taskweaver-package\"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Package created successfully in 'taskweaver-package' folder!
|
||||||
|
echo.
|
||||||
|
echo Files included:
|
||||||
|
echo - main.js
|
||||||
|
echo - manifest.json
|
||||||
|
echo - styles.css
|
||||||
|
echo.
|
||||||
|
echo To install: Copy the contents of 'taskweaver-package' to your vault's .obsidian/plugins/taskweaver/ folder
|
||||||
48
esbuild.config.mjs
Normal file
48
esbuild.config.mjs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import esbuild from "esbuild";
|
||||||
|
import process from "process";
|
||||||
|
import builtins from "builtin-modules";
|
||||||
|
|
||||||
|
const banner =
|
||||||
|
`/*
|
||||||
|
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||||
|
if you want to view the source, please visit the github repository of this plugin
|
||||||
|
*/
|
||||||
|
`;
|
||||||
|
|
||||||
|
const prod = (process.argv[2] === "production");
|
||||||
|
|
||||||
|
const context = await esbuild.context({
|
||||||
|
banner: {
|
||||||
|
js: banner,
|
||||||
|
},
|
||||||
|
entryPoints: ["main.ts"],
|
||||||
|
bundle: true,
|
||||||
|
external: [
|
||||||
|
"obsidian",
|
||||||
|
"electron",
|
||||||
|
"@codemirror/autocomplete",
|
||||||
|
"@codemirror/collab",
|
||||||
|
"@codemirror/commands",
|
||||||
|
"@codemirror/language",
|
||||||
|
"@codemirror/lint",
|
||||||
|
"@codemirror/search",
|
||||||
|
"@codemirror/state",
|
||||||
|
"@codemirror/view",
|
||||||
|
"@lezer/common",
|
||||||
|
"@lezer/highlight",
|
||||||
|
"@lezer/lr",
|
||||||
|
...builtins],
|
||||||
|
format: "cjs",
|
||||||
|
target: "es2018",
|
||||||
|
logLevel: "info",
|
||||||
|
sourcemap: prod ? false : "inline",
|
||||||
|
treeShaking: true,
|
||||||
|
outfile: "main.js",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prod) {
|
||||||
|
await context.rebuild();
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
await context.watch();
|
||||||
|
}
|
||||||
173
main.ts
Normal file
173
main.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { Plugin } from "obsidian";
|
||||||
|
import { TaskWeaverView, VIEW_TYPE_TASKWEAVER } from "./view";
|
||||||
|
import { TaskWeaverSettingTab } from "./settings";
|
||||||
|
import { TaskWeaverSettings, DEFAULT_SETTINGS, Task, Category } from "./types";
|
||||||
|
import { generateUniqueId, logTaskToDailyNote } from "./utils";
|
||||||
|
|
||||||
|
export default class TaskWeaverPlugin extends Plugin {
|
||||||
|
settings: TaskWeaverSettings;
|
||||||
|
|
||||||
|
async onload(): Promise<void> {
|
||||||
|
await this.loadSettings();
|
||||||
|
|
||||||
|
this.registerView(
|
||||||
|
VIEW_TYPE_TASKWEAVER,
|
||||||
|
(leaf) => new TaskWeaverView(leaf, this)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addRibbonIcon("clock", "Open TaskWeaver", () => {
|
||||||
|
this.activateView();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: "open-taskweaver",
|
||||||
|
name: "Open TaskWeaver",
|
||||||
|
callback: () => {
|
||||||
|
this.activateView();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addSettingTab(new TaskWeaverSettingTab(this.app, this));
|
||||||
|
|
||||||
|
this.app.workspace.onLayoutReady(() => {
|
||||||
|
this.activateView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onunload(): Promise<void> {
|
||||||
|
this.app.workspace.detachLeavesOfType(VIEW_TYPE_TASKWEAVER);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateView(): Promise<void> {
|
||||||
|
const { workspace } = this.app;
|
||||||
|
|
||||||
|
let leaf = workspace.getLeavesOfType(VIEW_TYPE_TASKWEAVER)[0];
|
||||||
|
|
||||||
|
if (!leaf) {
|
||||||
|
const rightLeaf = workspace.getRightLeaf(false);
|
||||||
|
if (rightLeaf) {
|
||||||
|
await rightLeaf.setViewState({
|
||||||
|
type: VIEW_TYPE_TASKWEAVER,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
leaf = rightLeaf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaf) {
|
||||||
|
workspace.revealLeaf(leaf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings(): Promise<void> {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings(): Promise<void> {
|
||||||
|
await this.saveData(this.settings);
|
||||||
|
this.refreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshView(): void {
|
||||||
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_TASKWEAVER);
|
||||||
|
leaves.forEach(leaf => {
|
||||||
|
if (leaf.view instanceof TaskWeaverView) {
|
||||||
|
leaf.view.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCategory(name: string, color: string, emoji?: string): Promise<void> {
|
||||||
|
const category: Category = {
|
||||||
|
id: generateUniqueId(),
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
emoji
|
||||||
|
};
|
||||||
|
this.settings.categories.push(category);
|
||||||
|
await this.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCategory(categoryId: string): Promise<void> {
|
||||||
|
const hasTasks = this.settings.tasks.some(t => t.categoryId === categoryId);
|
||||||
|
if (hasTasks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings.categories = this.settings.categories.filter(c => c.id !== categoryId);
|
||||||
|
await this.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTask(title: string, categoryId: string): Promise<void> {
|
||||||
|
const task: Task = {
|
||||||
|
id: generateUniqueId(),
|
||||||
|
title,
|
||||||
|
categoryId,
|
||||||
|
startTime: null,
|
||||||
|
totalElapsed: 0,
|
||||||
|
completed: false
|
||||||
|
};
|
||||||
|
this.settings.tasks.push(task);
|
||||||
|
await this.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTask(taskId: string): Promise<void> {
|
||||||
|
this.settings.tasks = this.settings.tasks.filter(t => t.id !== taskId);
|
||||||
|
await this.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleTaskTimer(taskId: string): Promise<void> {
|
||||||
|
const task = this.settings.tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
if (task.startTime) {
|
||||||
|
const elapsed = Date.now() - task.startTime;
|
||||||
|
task.totalElapsed += elapsed;
|
||||||
|
task.startTime = null;
|
||||||
|
} else {
|
||||||
|
task.startTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleTaskComplete(taskId: string): Promise<void> {
|
||||||
|
const task = this.settings.tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
if (!task.completed) {
|
||||||
|
if (task.startTime) {
|
||||||
|
const elapsed = Date.now() - task.startTime;
|
||||||
|
task.totalElapsed += elapsed;
|
||||||
|
task.startTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
task.completed = true;
|
||||||
|
task.completedAt = Date.now();
|
||||||
|
|
||||||
|
if (this.settings.enableDailyNoteLogging) {
|
||||||
|
const category = this.settings.categories.find(c => c.id === task.categoryId);
|
||||||
|
await logTaskToDailyNote(
|
||||||
|
this.app.vault,
|
||||||
|
task,
|
||||||
|
category,
|
||||||
|
this.settings.dailyNoteFormat,
|
||||||
|
this.settings.dailyNotePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
task.completed = false;
|
||||||
|
delete task.completedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTaskElapsed(task: Task): number {
|
||||||
|
let elapsed = task.totalElapsed;
|
||||||
|
if (task.startTime) {
|
||||||
|
elapsed += Date.now() - task.startTime;
|
||||||
|
}
|
||||||
|
return elapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
manifest.json
Normal file
10
manifest.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "taskweaver",
|
||||||
|
"name": "TaskWeaver",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minAppVersion": "0.15.0",
|
||||||
|
"description": "A simple task timer with category support and daily note logging",
|
||||||
|
"author": "",
|
||||||
|
"authorUrl": "",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "obsidian-taskweaver",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A simple task timer plugin for Obsidian with category support",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node esbuild.config.mjs",
|
||||||
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
|
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^16.11.6",
|
||||||
|
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||||
|
"@typescript-eslint/parser": "5.29.0",
|
||||||
|
"builtin-modules": "3.3.0",
|
||||||
|
"esbuild": "0.17.3",
|
||||||
|
"obsidian": "latest",
|
||||||
|
"tslib": "2.4.0",
|
||||||
|
"typescript": "4.7.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
settings.ts
Normal file
51
settings.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||||
|
import TaskWeaverPlugin from "./main";
|
||||||
|
|
||||||
|
export class TaskWeaverSettingTab extends PluginSettingTab {
|
||||||
|
plugin: TaskWeaverPlugin;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: TaskWeaverPlugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
display(): void {
|
||||||
|
const { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Enable daily note logging")
|
||||||
|
.setDesc("Log completed tasks to your daily note")
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(this.plugin.settings.enableDailyNoteLogging)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.enableDailyNoteLogging = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
this.display();
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.plugin.settings.enableDailyNoteLogging) {
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Daily note path")
|
||||||
|
.setDesc("Path to your daily notes folder (e.g., 'Daily Notes' or leave empty for root)")
|
||||||
|
.addText(text => text
|
||||||
|
.setPlaceholder("Daily Notes")
|
||||||
|
.setValue(this.plugin.settings.dailyNotePath)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.dailyNotePath = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Daily note format")
|
||||||
|
.setDesc("Format for logging tasks. Available placeholders: {{title}}, {{duration}}, {{emoji}}, {{category}}")
|
||||||
|
.addTextArea(text => text
|
||||||
|
.setPlaceholder("- [x] {{title}} ({{duration}}) {{emoji}}")
|
||||||
|
.setValue(this.plugin.settings.dailyNoteFormat)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.dailyNoteFormat = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
197
styles.css
Normal file
197
styles.css
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
.taskweaver-container {
|
||||||
|
padding: var(--size-4-3);
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--size-4-3);
|
||||||
|
padding-bottom: var(--size-4-2);
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-4-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-btn {
|
||||||
|
padding: var(--size-4-1) var(--size-4-2);
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-btn:hover {
|
||||||
|
background-color: var(--interactive-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-btn-small {
|
||||||
|
padding: var(--size-2-1) var(--size-4-1);
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-ui-smaller);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-btn-small:hover {
|
||||||
|
background-color: var(--background-modifier-error-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-category {
|
||||||
|
margin-bottom: var(--size-4-3);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
padding: var(--size-4-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-category-header {
|
||||||
|
padding: var(--size-4-2);
|
||||||
|
margin-bottom: var(--size-4-2);
|
||||||
|
border-left: 3px solid var(--interactive-accent);
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-category-name {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: var(--font-ui-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-emoji {
|
||||||
|
font-size: var(--font-ui-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-task {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--size-4-2);
|
||||||
|
margin-bottom: var(--size-2-2);
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-task:hover {
|
||||||
|
background-color: var(--background-primary-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-task-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4-2);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-checkbox {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-task-title {
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-task-completed {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-task-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-timer {
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-ui-smaller);
|
||||||
|
min-width: 70px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-timer-btn,
|
||||||
|
.taskweaver-delete-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
padding: var(--size-2-1) var(--size-2-3);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
color: var(--text-normal);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-timer-btn:hover {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-delete-btn:hover {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: var(--background-modifier-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-warning {
|
||||||
|
color: var(--text-error);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
margin: var(--size-4-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-categories-list {
|
||||||
|
margin-bottom: var(--size-4-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-category-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4-2);
|
||||||
|
padding: var(--size-4-2);
|
||||||
|
margin-bottom: var(--size-2-2);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-color-preview {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskweaver-category-info {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .taskweaver-warning {
|
||||||
|
padding: var(--size-4-2);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
border-left: 3px solid var(--text-error);
|
||||||
|
}
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"inlineSourceMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ES6",
|
||||||
|
"allowJs": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"ES5",
|
||||||
|
"ES6",
|
||||||
|
"ES7"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
32
types.ts
Normal file
32
types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
emoji?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
categoryId: string;
|
||||||
|
startTime: number | null;
|
||||||
|
totalElapsed: number;
|
||||||
|
completed: boolean;
|
||||||
|
completedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskWeaverSettings {
|
||||||
|
categories: Category[];
|
||||||
|
tasks: Task[];
|
||||||
|
enableDailyNoteLogging: boolean;
|
||||||
|
dailyNoteFormat: string;
|
||||||
|
dailyNotePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: TaskWeaverSettings = {
|
||||||
|
categories: [],
|
||||||
|
tasks: [],
|
||||||
|
enableDailyNoteLogging: false,
|
||||||
|
dailyNoteFormat: "- [x] {{title}} ({{duration}}) {{emoji}}",
|
||||||
|
dailyNotePath: ""
|
||||||
|
};
|
||||||
62
utils.ts
Normal file
62
utils.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { TFile, Vault, moment } from "obsidian";
|
||||||
|
import { Task, Category } from "./types";
|
||||||
|
|
||||||
|
export function formatDuration(milliseconds: number): string {
|
||||||
|
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
} else {
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateUniqueId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logTaskToDailyNote(
|
||||||
|
vault: Vault,
|
||||||
|
task: Task,
|
||||||
|
category: Category | undefined,
|
||||||
|
format: string,
|
||||||
|
dailyNotePath: string
|
||||||
|
): Promise<void> {
|
||||||
|
const today = moment().format("YYYY-MM-DD");
|
||||||
|
const dailyNoteFileName = `${today}.md`;
|
||||||
|
|
||||||
|
let dailyNoteFolderPath = dailyNotePath.trim();
|
||||||
|
if (dailyNoteFolderPath && !dailyNoteFolderPath.endsWith("/")) {
|
||||||
|
dailyNoteFolderPath += "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = `${dailyNoteFolderPath}${dailyNoteFileName}`;
|
||||||
|
|
||||||
|
let file = vault.getAbstractFileByPath(fullPath);
|
||||||
|
|
||||||
|
if (!file || !(file instanceof TFile)) {
|
||||||
|
const folder = dailyNoteFolderPath.slice(0, -1);
|
||||||
|
if (folder && !vault.getAbstractFileByPath(folder)) {
|
||||||
|
await vault.createFolder(folder);
|
||||||
|
}
|
||||||
|
file = await vault.create(fullPath, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
const duration = formatDuration(task.totalElapsed);
|
||||||
|
const logEntry = format
|
||||||
|
.replace("{{title}}", task.title)
|
||||||
|
.replace("{{duration}}", duration)
|
||||||
|
.replace("{{emoji}}", category?.emoji || "")
|
||||||
|
.replace("{{category}}", category?.name || "");
|
||||||
|
|
||||||
|
const content = await vault.read(file);
|
||||||
|
const newContent = content ? `${content}\n${logEntry}` : logEntry;
|
||||||
|
await vault.modify(file, newContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
version-bump.mjs
Normal file
12
version-bump.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const targetVersion = process.env.npm_package_version;
|
||||||
|
|
||||||
|
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||||
|
const { minAppVersion } = manifest;
|
||||||
|
manifest.version = targetVersion;
|
||||||
|
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||||
|
|
||||||
|
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||||
|
versions[targetVersion] = minAppVersion;
|
||||||
|
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||||
3
versions.json
Normal file
3
versions.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"1.0.0": "0.15.0"
|
||||||
|
}
|
||||||
347
view.ts
Normal file
347
view.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { ItemView, WorkspaceLeaf, Modal, App, Setting } from "obsidian";
|
||||||
|
import TaskWeaverPlugin from "./main";
|
||||||
|
import { Task, Category } from "./types";
|
||||||
|
import { formatDuration, generateUniqueId } from "./utils";
|
||||||
|
|
||||||
|
export const VIEW_TYPE_TASKWEAVER = "taskweaver-view";
|
||||||
|
|
||||||
|
export class TaskWeaverView extends ItemView {
|
||||||
|
plugin: TaskWeaverPlugin;
|
||||||
|
private updateInterval: number | null = null;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf, plugin: TaskWeaverPlugin) {
|
||||||
|
super(leaf);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType(): string {
|
||||||
|
return VIEW_TYPE_TASKWEAVER;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText(): string {
|
||||||
|
return "TaskWeaver";
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon(): string {
|
||||||
|
return "clock";
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen(): Promise<void> {
|
||||||
|
this.render();
|
||||||
|
this.updateInterval = window.setInterval(() => {
|
||||||
|
this.updateTimers();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClose(): Promise<void> {
|
||||||
|
if (this.updateInterval !== null) {
|
||||||
|
window.clearInterval(this.updateInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): void {
|
||||||
|
const container = this.containerEl.children[1] as HTMLElement;
|
||||||
|
container.empty();
|
||||||
|
container.addClass("taskweaver-container");
|
||||||
|
|
||||||
|
const headerDiv = container.createDiv({ cls: "taskweaver-header" });
|
||||||
|
headerDiv.createEl("h4", { text: "TaskWeaver" });
|
||||||
|
|
||||||
|
const buttonContainer = headerDiv.createDiv({ cls: "taskweaver-button-group" });
|
||||||
|
|
||||||
|
const addTaskBtn = buttonContainer.createEl("button", {
|
||||||
|
text: "Add Task",
|
||||||
|
cls: "taskweaver-btn"
|
||||||
|
});
|
||||||
|
addTaskBtn.onclick = () => this.openAddTaskModal();
|
||||||
|
|
||||||
|
const manageCategoriesBtn = buttonContainer.createEl("button", {
|
||||||
|
text: "Categories",
|
||||||
|
cls: "taskweaver-btn"
|
||||||
|
});
|
||||||
|
manageCategoriesBtn.onclick = () => this.openManageCategoriesModal();
|
||||||
|
|
||||||
|
const categoriesMap = new Map(this.plugin.settings.categories.map(c => [c.id, c]));
|
||||||
|
|
||||||
|
this.plugin.settings.categories.forEach(category => {
|
||||||
|
const categoryTasks = this.plugin.settings.tasks.filter(t => t.categoryId === category.id && !t.completed);
|
||||||
|
|
||||||
|
if (categoryTasks.length > 0) {
|
||||||
|
this.renderCategory(container, category, categoryTasks);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedTasks = this.plugin.settings.tasks.filter(t => t.completed);
|
||||||
|
if (completedTasks.length > 0) {
|
||||||
|
const completedSection = container.createDiv({ cls: "taskweaver-category" });
|
||||||
|
const completedHeader = completedSection.createDiv({ cls: "taskweaver-category-header" });
|
||||||
|
completedHeader.createEl("span", { text: "✓ Completed", cls: "taskweaver-category-name" });
|
||||||
|
|
||||||
|
completedTasks.forEach(task => {
|
||||||
|
const category = categoriesMap.get(task.categoryId);
|
||||||
|
this.renderTask(completedSection, task, category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCategory(container: HTMLElement, category: Category, tasks: Task[]): void {
|
||||||
|
const categoryDiv = container.createDiv({ cls: "taskweaver-category" });
|
||||||
|
|
||||||
|
const categoryHeader = categoryDiv.createDiv({ cls: "taskweaver-category-header" });
|
||||||
|
categoryHeader.style.borderLeftColor = category.color;
|
||||||
|
|
||||||
|
const categoryName = categoryHeader.createSpan({ cls: "taskweaver-category-name" });
|
||||||
|
if (category.emoji) {
|
||||||
|
categoryName.createSpan({ text: `${category.emoji} `, cls: "taskweaver-emoji" });
|
||||||
|
}
|
||||||
|
categoryName.createSpan({ text: category.name });
|
||||||
|
|
||||||
|
tasks.forEach(task => {
|
||||||
|
this.renderTask(categoryDiv, task, category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTask(container: HTMLElement, task: Task, category?: Category): void {
|
||||||
|
const taskDiv = container.createDiv({ cls: "taskweaver-task" });
|
||||||
|
|
||||||
|
const taskInfo = taskDiv.createDiv({ cls: "taskweaver-task-info" });
|
||||||
|
|
||||||
|
const checkbox = taskInfo.createEl("input", { type: "checkbox" });
|
||||||
|
checkbox.checked = task.completed;
|
||||||
|
checkbox.addClass("taskweaver-checkbox");
|
||||||
|
checkbox.onclick = async () => {
|
||||||
|
await this.plugin.toggleTaskComplete(task.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskTitle = taskInfo.createSpan({
|
||||||
|
text: task.title,
|
||||||
|
cls: task.completed ? "taskweaver-task-title taskweaver-task-completed" : "taskweaver-task-title"
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskControls = taskDiv.createDiv({ cls: "taskweaver-task-controls" });
|
||||||
|
|
||||||
|
const timerSpan = taskControls.createSpan({
|
||||||
|
cls: "taskweaver-timer",
|
||||||
|
attr: { "data-task-id": task.id }
|
||||||
|
});
|
||||||
|
timerSpan.setText(formatDuration(this.plugin.getTaskElapsed(task)));
|
||||||
|
|
||||||
|
if (!task.completed) {
|
||||||
|
const timerBtn = taskControls.createEl("button", {
|
||||||
|
text: task.startTime ? "⏸" : "▶",
|
||||||
|
cls: "taskweaver-timer-btn"
|
||||||
|
});
|
||||||
|
timerBtn.onclick = async () => {
|
||||||
|
await this.plugin.toggleTaskTimer(task.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBtn = taskControls.createEl("button", {
|
||||||
|
text: "🗑",
|
||||||
|
cls: "taskweaver-delete-btn"
|
||||||
|
});
|
||||||
|
deleteBtn.onclick = async () => {
|
||||||
|
await this.plugin.deleteTask(task.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimers(): void {
|
||||||
|
const container = this.containerEl.children[1] as HTMLElement;
|
||||||
|
const timerElements = container.querySelectorAll(".taskweaver-timer");
|
||||||
|
|
||||||
|
timerElements.forEach((element) => {
|
||||||
|
const taskId = element.getAttribute("data-task-id");
|
||||||
|
if (taskId) {
|
||||||
|
const task = this.plugin.settings.tasks.find(t => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
element.setText(formatDuration(this.plugin.getTaskElapsed(task)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openAddTaskModal(): void {
|
||||||
|
new AddTaskModal(this.app, this.plugin, (title, categoryId) => {
|
||||||
|
this.plugin.addTask(title, categoryId);
|
||||||
|
}).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
openManageCategoriesModal(): void {
|
||||||
|
new ManageCategoriesModal(this.app, this.plugin).open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddTaskModal extends Modal {
|
||||||
|
plugin: TaskWeaverPlugin;
|
||||||
|
onSubmit: (title: string, categoryId: string) => void;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: TaskWeaverPlugin, onSubmit: (title: string, categoryId: string) => void) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.onSubmit = onSubmit;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
|
||||||
|
contentEl.createEl("h3", { text: "Add New Task" });
|
||||||
|
|
||||||
|
let taskTitle = "";
|
||||||
|
let selectedCategoryId = this.plugin.settings.categories[0]?.id || "";
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName("Task title")
|
||||||
|
.addText(text => text
|
||||||
|
.setPlaceholder("Enter task title")
|
||||||
|
.onChange(value => {
|
||||||
|
taskTitle = value;
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.plugin.settings.categories.length === 0) {
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Please create a category first",
|
||||||
|
cls: "taskweaver-warning"
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(btn => btn
|
||||||
|
.setButtonText("Create Category")
|
||||||
|
.setCta()
|
||||||
|
.onClick(() => {
|
||||||
|
this.close();
|
||||||
|
new ManageCategoriesModal(this.app, this.plugin).open();
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName("Category")
|
||||||
|
.addDropdown(dropdown => {
|
||||||
|
this.plugin.settings.categories.forEach(cat => {
|
||||||
|
const label = cat.emoji ? `${cat.emoji} ${cat.name}` : cat.name;
|
||||||
|
dropdown.addOption(cat.id, label);
|
||||||
|
});
|
||||||
|
dropdown.setValue(selectedCategoryId);
|
||||||
|
dropdown.onChange(value => {
|
||||||
|
selectedCategoryId = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(btn => btn
|
||||||
|
.setButtonText("Cancel")
|
||||||
|
.onClick(() => {
|
||||||
|
this.close();
|
||||||
|
}))
|
||||||
|
.addButton(btn => btn
|
||||||
|
.setButtonText("Add")
|
||||||
|
.setCta()
|
||||||
|
.onClick(() => {
|
||||||
|
if (taskTitle && selectedCategoryId) {
|
||||||
|
this.onSubmit(taskTitle, selectedCategoryId);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ManageCategoriesModal extends Modal {
|
||||||
|
plugin: TaskWeaverPlugin;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: TaskWeaverPlugin) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
|
||||||
|
contentEl.createEl("h3", { text: "Manage Categories" });
|
||||||
|
|
||||||
|
const categoriesList = contentEl.createDiv({ cls: "taskweaver-categories-list" });
|
||||||
|
|
||||||
|
this.plugin.settings.categories.forEach(category => {
|
||||||
|
const categoryItem = categoriesList.createDiv({ cls: "taskweaver-category-item" });
|
||||||
|
|
||||||
|
const colorPreview = categoryItem.createDiv({ cls: "taskweaver-color-preview" });
|
||||||
|
colorPreview.style.backgroundColor = category.color;
|
||||||
|
|
||||||
|
const categoryInfo = categoryItem.createDiv({ cls: "taskweaver-category-info" });
|
||||||
|
if (category.emoji) {
|
||||||
|
categoryInfo.createSpan({ text: `${category.emoji} `, cls: "taskweaver-emoji" });
|
||||||
|
}
|
||||||
|
categoryInfo.createSpan({ text: category.name });
|
||||||
|
|
||||||
|
const deleteBtn = categoryItem.createEl("button", {
|
||||||
|
text: "Delete",
|
||||||
|
cls: "taskweaver-btn-small"
|
||||||
|
});
|
||||||
|
deleteBtn.onclick = async () => {
|
||||||
|
await this.plugin.deleteCategory(category.id);
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
contentEl.createEl("h4", { text: "Add New Category" });
|
||||||
|
|
||||||
|
let categoryName = "";
|
||||||
|
let categoryColor = "#4a9eff";
|
||||||
|
let categoryEmoji = "";
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName("Category name")
|
||||||
|
.addText(text => text
|
||||||
|
.setPlaceholder("e.g., Work, Personal")
|
||||||
|
.onChange(value => {
|
||||||
|
categoryName = value;
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName("Color")
|
||||||
|
.addText(text => text
|
||||||
|
.setValue(categoryColor)
|
||||||
|
.setPlaceholder("#4a9eff")
|
||||||
|
.onChange(value => {
|
||||||
|
categoryColor = value;
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName("Emoji (optional)")
|
||||||
|
.addText(text => text
|
||||||
|
.setPlaceholder("💼")
|
||||||
|
.onChange(value => {
|
||||||
|
categoryEmoji = value;
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton(btn => btn
|
||||||
|
.setButtonText("Close")
|
||||||
|
.onClick(() => {
|
||||||
|
this.close();
|
||||||
|
}))
|
||||||
|
.addButton(btn => btn
|
||||||
|
.setButtonText("Add Category")
|
||||||
|
.setCta()
|
||||||
|
.onClick(async () => {
|
||||||
|
if (categoryName) {
|
||||||
|
await this.plugin.addCategory(categoryName, categoryColor, categoryEmoji);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user