Refactor plugin architecture and implement best practices

- Create modular folder structure
- Move classes to dedicated files
- Extract utility functions
- Improve error handling
- Apply Obsidian plugin guidelines
- Use setHeading for section headings
- Use Vault API instead of adapter where possible
- Simplify codebase with fewer console logs
- Update frontmatter processing to use FileManager.processFrontMatter
This commit is contained in:
Callum Alpass 2025-05-18 11:50:16 +10:00
commit b1c755ddfa
25 changed files with 5523 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
obsidian-developer-docs/

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

5
LICENSE Normal file
View file

@ -0,0 +1,5 @@
Copyright (C) 2020-2025 by Dynalist Inc.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

111
README.md Normal file
View file

@ -0,0 +1,111 @@
# ChronoSync for Obsidian
ChronoSync is a comprehensive diary, task, note, and time management plugin for Obsidian. It integrates calendar views, task management, daily notes, and timeblocking into a seamless workflow.
## Features
- **Enhanced Daily Notes**
- Automatic creation and navigation to daily notes
- YAML frontmatter for daily metadata (pomodoros, workout, meditate, tags, important)
- Integrated timeblock section within daily notes
- **Advanced Task Management**
- Tasks as individual Markdown notes with rich YAML frontmatter
- Task properties: title, due date, priority, status, contexts, tags, recurrence, details
- Dedicated task views and filtering
- **Calendar Views**
- Month, Week, and Year views integrated into the Obsidian workspace
- Visual cues on calendar dates for entries, due tasks, and metadata
- Easy navigation to daily notes
- **Timeblocking**
- Structured timeblock tables in daily notes
- Easy creation and editing of timeblocks
- Configurable time intervals and ranges
- **Flexible Layouts**
- Side-by-side view (Calendar/List on one side, File Preview/Editor on the other)
- Tabbed interface for switching between tasks, notes, and timeblocks
## Installation
### From Obsidian Community Plugins
1. Open Obsidian Settings
2. Go to Community Plugins and turn off Safe Mode
3. Click Browse and search for "ChronoSync"
4. Install the plugin and enable it
### Manual Installation
1. Download the latest release from the GitHub releases page
2. Extract the files to your Obsidian vault's plugins folder: `<vault>/.obsidian/plugins/chronosync/`
3. Reload Obsidian
4. Go to Settings > Community Plugins and enable "ChronoSync"
## Usage
### Getting Started
1. After installation, click the calendar icon in the ribbon to open the ChronoSync dashboard
2. Configure the plugin settings in Settings > ChronoSync
3. Create your first task or navigate to today's daily note
### Commands
ChronoSync adds several commands to Obsidian's command palette:
- **Open Dashboard/Calendar View**: Opens the main ChronoSync dashboard
- **Create New Task**: Opens a modal to create a new task
- **Go to Today's Note**: Navigates to or creates today's daily note
- **Open Home Note**: Navigates to or creates your home note
- **Increment Daily Pomodoros**: Adds 1 to the pomodoro count in today's note
- **Toggle Daily Workout**: Toggles the workout flag in today's note
- **Toggle Daily Meditation**: Toggles the meditation flag in today's note
- **Toggle Daily Important Flag**: Toggles the important flag in today's note
### Folder Structure
ChronoSync uses the following folder structure by default (configurable in settings):
- `ChronoSync/Daily/`: Daily notes (YYYY-MM-DD.md)
- `ChronoSync/Tasks/`: Task files
- `ChronoSync/Notes/`: General notes
- `ChronoSync/Home.md`: Home note
## Configuration
In the plugin settings, you can configure:
- Folder paths for daily notes, tasks, and general notes
- Default task properties (priority, status)
- Timeblock settings (start/end times, interval)
- Indexing options
## Development
### Prerequisites
- [NodeJS](https://nodejs.org/) v16 or later
- [npm](https://www.npmjs.com/)
### Setup
1. Clone this repository
2. Run `npm install` to install dependencies
3. Run `npm run dev` to start the development build process
### Building
- `npm run dev`: Builds the plugin and watches for changes
- `npm run build`: Builds the plugin for production
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Acknowledgments
- Inspired by `diary-tui` by Callum Alpass
- Built with the [Obsidian API](https://github.com/obsidianmd/obsidian-api)

219
SPEC.md Normal file
View file

@ -0,0 +1,219 @@
## Obsidian Plugin Specification: "Chronosync" (or "Zenith Planner")
**Plugin Name Ideas:** Chronosync, Zenith Planner, Obsidian Task & Time Weaver, Daily Flow
**Author:** Callum Alpass
**Version:** 0.1.0 (Initial Release)
**Minimum Obsidian Version:** (To be determined based on API usage, likely 1.0.0+)
**Inspired By:** `diary-tui` by Callum Alpass
**1. Overview & Goal:**
Chronosync aims to integrate comprehensive diary, task, note, and time management directly within Obsidian, leveraging Obsidian's strengths in linking and Markdown. It will provide calendar views, robust task management with YAML frontmatter, daily timeblocking, and organized note-taking, mirroring the key functionalities of the `diary-tui` command-line application. The plugin will focus on enhancing daily planning, task tracking, and reflective journaling.
**2. Core Features:**
* **Enhanced Daily Notes:**
* Automatic creation/navigation to daily notes.
* YAML frontmatter for daily metadata (e.g., `pomodoros`, `workout`, `meditate`, `tags`, `important`).
* Integrated Timeblock section within daily notes.
* **Advanced Task Management:**
* Tasks as individual Markdown notes with rich YAML frontmatter.
* Task properties: title, due date, priority, status, contexts, tags, recurrence, details.
* Dedicated Task views and filtering.
* **Note Organization:**
* View non-task notes associated with specific dates (based on `dateCreated` or a custom field).
* Easy internal linking and navigation.
* **Calendar Views:**
* Month, Week, and Year views integrated into the Obsidian workspace.
* Visual cues on calendar dates for entries, due tasks, and metadata.
* **Flexible Layouts:**
* Side-by-side view (Calendar/List on one side, File Preview/Editor on the other).
* Full-pane views for focused work.
* **Data Integrity & Performance:**
* Efficient indexing and caching of task/note metadata.
* Robust YAML parsing and updating.
**3. User Interface (UI) & Interaction:**
* **Ribbon Icon:**
* An icon to open a primary Chronosync view (e.g., a dashboard showing calendar and upcoming tasks).
* **Command Palette Commands:**
* `Chronosync: Open Dashboard/Calendar View`
* `Chronosync: Create New Task`
* `Chronosync: Go to Today's Note`
* `Chronosync: Open Home Note`
* `Chronosync: Search Diary/Notes`
* `Chronosync: Filter Tasks by Context`
* `Chronosync: Toggle Daily Metadata (Meditate, Workout, Important)`
* `Chronosync: Increment Daily Pomodoros`
* `Chronosync: Show Weekly/Monthly Stats`
* `Chronosync: Re-index Tasks & Notes`
* **Custom Views/Panes (as new Obsidian View Types):**
* **Calendar View:**
* Switchable between Month, Week, Year.
* Clicking a date navigates to the daily note or shows a summary.
* Visual indicators on dates (dots/icons) for:
* Existing daily note.
* Tasks due.
* Specific metadata (e.g., 'important' tag).
* **Task List View:**
* Displays all tasks, filterable by status (open, in-progress, done, all, archive), context, due date, priority.
* Sortable by due date, priority, title.
* Clicking a task opens it in the editor or a preview pane.
* Context menu/buttons for actions: toggle status, change priority, edit, delete, archive.
* **Notes View:**
* Displays non-task notes, filterable by date created (matching selected calendar date) or tags.
* Clicking a note opens it.
* **File Preview Pane:**
* When a task or note is selected in a list view, its content is previewed (similar to Obsidian's linked pane).
* **Modals:**
* **Task Creation Modal:**
* Fields: Title (required), Details (Markdown), Due Date (date picker), Priority (dropdown: low, normal, high), Context Tags (comma-separated text input), Extra Tags (comma-separated text input), Recurrence Frequency (dropdown: none, daily, weekly, monthly, yearly), Day of Month (text input, visible if monthly/yearly), Days of Week (checkboxes, visible if weekly).
* Input validation for dates and required fields.
* Confirmation step before creating the task file.
* **Quick Add Note Modal (for daily notes):**
* Simple text input to append a timestamped note to the current daily note.
* **Timeblock Entry Modal:**
* Input for activity for a selected time slot in the daily note's timeblock.
* **Search/Filter Modals:**
* Input fields for search queries, tag filters, context filters.
* **Confirmation Dialogs:** For deletions, etc.
* **Settings Tab:**
* Configure folder paths (Daily Notes, Tasks, General Notes, Home Note).
* Default task properties.
* Editor preferences (though Obsidian's internal editor will be primary).
* Timeblock template settings.
* Indexing options (e.g., auto-reindex frequency).
* Appearance settings for calendar indicators.
**4. Data Management & Storage:**
* **Folders:**
* `Daily Notes Folder`: (User-configurable, e.g., `Chronosync/Daily/`) - Files named `YYYY-MM-DD.md`.
* `Tasks Folder`: (User-configurable, e.g., `Chronosync/Tasks/`) - Task files.
* `Notes Folder`: (User-configurable, e.g., `Chronosync/Notes/` or root) - For general notes.
* `Home Note Path`: (User-configurable, e.g., `Chronosync/Home.md`).
* **File Naming for Tasks:**
* `YYYYMMDD<random_suffix>.md` (e.g., `250323abc.md`) to ensure uniqueness and sortability by creation.
* **YAML Frontmatter (Daily Notes):**
* `date: YYYY-MM-DD`
* `pomodoros: number`
* `workout: boolean`
* `meditate: boolean`
* `tags: [tag1, tag2]`
* `important: boolean` (or could be a tag)
* (Other user-defined metadata)
* **YAML Frontmatter (Task Notes):**
* `title: "Task Title"`
* `zettelid: "YYYYMMDD<random_suffix>"` (matches filename base)
* `dateCreated: "YYYY-MM-DDTHH:MM:SS"`
* `dateModified: "YYYY-MM-DDTHH:MM:SS"`
* `status: "open" | "in-progress" | "done"`
* `due: "YYYY-MM-DD"` (optional)
* `tags: ["task", "extra_tag1", "archive"]` (always includes "task")
* `priority: "low" | "normal" | "high"`
* `contexts: ["context1", "context2"]` (optional)
* `recurrence:` (optional object)
* `frequency: "daily" | "weekly" | "monthly" | "yearly"`
* `days_of_week: ["mon", "tue"]` (if weekly)
* `day_of_month: number` (if monthly/yearly)
* `complete_instances: ["YYYY-MM-DD", "YYYY-MM-DD"]` (for recurring tasks)
* **YAML Frontmatter (General Notes):**
* `title: "Note Title"`
* `dateCreated: "YYYY-MM-DDTHH:MM:SS"`
* `dateModified: "YYYY-MM-DDTHH:MM:SS"`
* `tags: [tag1, tag2]`
* **Timeblocks:**
* Stored as a Markdown table within the body of daily notes (e.g., under a `## Timeblock` heading).
* Structure: `| Time | Activity |`
* Default template will be configurable.
* **Indexing:**
* An `index_state.json` file (similar to `diary-tui`) in the plugin's data directory (`.obsidian/plugins/chronosync/data/`) to store file modification times/hashes for efficient re-indexing.
* Background process for indexing tasks and notes.
**5. Key Functionalities (Detailed):**
* **Daily Note Enhancement:**
* On plugin load or daily, check/create today's note.
* Commands to toggle boolean metadata (`workout`, `meditate`, `important`) and increment `pomodoros` in the current daily note's frontmatter.
* Command to add a default timeblock table to the daily note if not present.
* Ability to edit timeblock entries directly in the Markdown table or via a modal.
* **Task Creation & Management:**
* **Creation:** Via Modal. File saved to Tasks Folder.
* **Status Toggle:** `open` -> `in-progress` -> `done` -> `open`. For recurring tasks, marks the current instance as complete in `complete_instances` if due today.
* **Priority Cycle:** `low` -> `normal` -> `high` -> `low`.
* **Recurrence:**
* When a recurring task instance is marked "done" for the `current_date`, add `YYYY-MM-DD` to `complete_instances`.
* Task list will show recurring tasks as "open" if they are due for the `current_date` and not in `complete_instances`.
* **Editing:** Opens the task note in Obsidian editor.
* **Deletion:** Deletes the task file (with confirmation).
* **Archiving:** Adds/removes an "archive" tag in the task's frontmatter. Archived tasks are hidden by default but viewable with a filter.
* **Note Listing (Non-Task):**
* In Notes View, list all notes from the `Notes Folder` (or entire vault, configurable) where `dateCreated` matches the selected calendar date. Exclude notes tagged "task".
* **Calendar Views:**
* Render calendar. Highlight today's date. Highlight selected date.
* Mark dates with existing daily notes.
* Mark dates with tasks due (color-coded by highest priority if multiple tasks).
* Mark dates if the daily note has `important: true` or a specific "important" tag.
* **Search & Filtering:**
* **Global Search:** Input query, search content of all diary and note files. Results update calendar highlighting and can be navigated.
* **Tag Filter (Diary):** Input tag, filter daily notes. Updates calendar.
* **Context Filter (Tasks):** Input context, filter task list.
* **Stats:**
* Commands to show a modal with weekly/monthly stats (pomodoros, workouts, meditation days) aggregated from daily note frontmatter.
**6. Configuration Options (Settings Tab):**
* **General:**
* `Daily Notes Folder Path` (text input, default: `Chronosync/Daily`)
* `Tasks Folder Path` (text input, default: `Chronosync/Tasks`)
* `General Notes Folder Path` (text input, default: `/` for vault root, or e.g., `Chronosync/Notes`)
* `Home Note Path` (text input, default: `Chronosync/Home.md`)
* **Tasks:**
* `Default Task Priority` (dropdown: low, normal, high)
* `Default Task Status` (dropdown: open)
* **Timeblocks:**
* `Default Timeblock Start Time` (e.g., "05:00")
* `Default Timeblock End Time` (e.g., "23:30")
* `Timeblock Interval` (dropdown: 30 minutes, 1 hour)
* `Automatically add Timeblock to new Daily Notes` (toggle)
* **Appearance:**
* `Calendar: Daily Note Indicator` (e.g., dot color, icon)
* `Calendar: Task Due Indicator` (e.g., dot color, icon - potentially different for priorities)
* `Calendar: Important Day Indicator` (e.g., dot color, icon)
* **Indexing:**
* `Enable Background Indexing` (toggle, default: true)
* `Re-index on Startup` (toggle, default: true)
**7. Dependencies & Potential Integrations:**
* **Dataview Plugin (Optional but Recommended):** Could be leveraged for advanced querying and dynamic list generation, potentially simplifying some custom view logic if the user has it installed. The plugin should aim to function without it for core features.
* **Obsidian Tasks Plugin (Potential Integration):** For task rendering and potentially some actions, explore compatibility or an option to use its format/queries if available. This would be a more advanced integration.
* **Calendar Plugin (Potential Integration):** Check if existing calendar plugins offer APIs that could be used or if this plugin's calendar should remain standalone.
* **Moment.js (Bundled with Obsidian):** For date/time manipulations.
**8. Future Enhancements (Post v0.1.0):**
* More sophisticated recurrence options (e.g., "every 2nd Tuesday").
* Kanban board view for tasks.
* Gantt chart view for tasks with durations.
* Directly link tasks to timeblock slots.
* Notifications/reminders for due tasks (if Obsidian API allows).
* Habit tracking features integrated with daily notes.
* Customizable dashboard view.
* Mobile compatibility improvements.
* Templating support for new tasks/notes beyond frontmatter.
**9. Technical Considerations:**
* **Performance:** Efficient file reading and YAML parsing is critical. Caching metadata and diff-based indexing (checking mod times/hashes) will be essential.
* **API Usage:** Rely on stable Obsidian API features.
* **Error Handling:** Graceful handling of missing files, malformed YAML, and other potential issues.
* **State Management:** Carefully manage the state of selected dates, filters, scroll positions, etc.
This spec should give you a solid blueprint, Callum. Given you wrote the original TUI, you'll have a great head start on the logic! Good luck with the development if you decide to pursue it!

49
esbuild.config.mjs Normal file
View file

@ -0,0 +1,49 @@
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",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

2
main.ts Normal file
View file

@ -0,0 +1,2 @@
import ChronoSyncPlugin from './src/main';
export default ChronoSyncPlugin;

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "chronosync",
"name": "ChronoSync",
"version": "0.1.0",
"minAppVersion": "1.0.0",
"description": "Comprehensive diary, task, note, and time management for Obsidian.",
"author": "Callum Alpass",
"authorUrl": "https://github.com/callumA",
"isDesktopOnly": false
}

2457
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "chronosync",
"version": "0.1.0",
"description": "Comprehensive diary, task, note, and time management for Obsidian",
"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/date-fns": "^2.5.3",
"@types/node": "^16.11.6",
"@types/yaml": "^1.9.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"
},
"dependencies": {
"date-fns": "^2.30.0",
"yaml": "^2.3.1"
}
}

304
src/main.ts Normal file
View file

@ -0,0 +1,304 @@
import { Notice, Plugin, TFile, WorkspaceLeaf, normalizePath } from 'obsidian';
import { format } from 'date-fns';
import * as YAML from 'yaml';
import {
ChronoSyncSettings,
DEFAULT_SETTINGS,
ChronoSyncSettingTab
} from './settings/settings';
import {
CALENDAR_VIEW_TYPE,
NOTES_VIEW_TYPE,
TASK_LIST_VIEW_TYPE,
TimeInfo
} from './types';
import { CalendarView } from './views/CalendarView';
import { TaskListView } from './views/TaskListView';
import { NotesView } from './views/NotesView';
import { TaskCreationModal } from './modals/TaskCreationModal';
import {
ensureFolderExists,
generateDailyNoteTemplate,
parseTime,
updateYamlFrontmatter
} from './utils/helpers';
export default class ChronoSyncPlugin extends Plugin {
settings: ChronoSyncSettings;
async onload() {
await this.loadSettings();
// Register view types
this.registerView(
CALENDAR_VIEW_TYPE,
(leaf) => new CalendarView(leaf, this)
);
this.registerView(
TASK_LIST_VIEW_TYPE,
(leaf) => new TaskListView(leaf, this)
);
this.registerView(
NOTES_VIEW_TYPE,
(leaf) => new NotesView(leaf, this)
);
// Add ribbon icon
this.addRibbonIcon('calendar-days', 'ChronoSync', async () => {
await this.activateCalendarView();
});
// Add commands
this.addCommands();
// Add settings tab
this.addSettingTab(new ChronoSyncSettingTab(this.app, this));
}
onunload() {
// Views cleanup happens automatically
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
addCommands() {
// Dashboard/Calendar commands
this.addCommand({
id: 'open-dashboard',
name: 'Open dashboard/calendar view',
callback: async () => {
await this.activateCalendarView();
}
});
// Task commands
this.addCommand({
id: 'create-new-task',
name: 'Create new task',
callback: () => {
this.openTaskCreationModal();
}
});
// Note commands
this.addCommand({
id: 'go-to-today',
name: 'Go to today\'s note',
callback: async () => {
await this.navigateToCurrentDailyNote();
}
});
this.addCommand({
id: 'go-to-home',
name: 'Open home note',
callback: async () => {
await this.navigateToHomeNote();
}
});
// Daily note metadata commands
this.addCommand({
id: 'increment-pomodoros',
name: 'Increment daily pomodoros',
callback: async () => {
await this.incrementPomodoros();
}
});
this.addCommand({
id: 'toggle-workout',
name: 'Toggle daily workout',
callback: async () => {
await this.toggleDailyMetadata('workout');
}
});
this.addCommand({
id: 'toggle-meditate',
name: 'Toggle daily meditation',
callback: async () => {
await this.toggleDailyMetadata('meditate');
}
});
this.addCommand({
id: 'toggle-important',
name: 'Toggle daily important flag',
callback: async () => {
await this.toggleDailyMetadata('important');
}
});
}
async activateCalendarView() {
const { workspace } = this.app;
// Close existing calendar view if it exists
let leaf = this.getCalendarLeaf();
if (!leaf) {
// Create new leaf for calendar view
leaf = workspace.getLeaf('split', 'vertical');
await leaf.setViewState({
type: CALENDAR_VIEW_TYPE,
active: true,
});
}
// Reveal the leaf in case it's in a collapsed state
workspace.revealLeaf(leaf);
}
getCalendarLeaf(): WorkspaceLeaf | null {
const { workspace } = this.app;
const leaves = workspace.getLeavesOfType(CALENDAR_VIEW_TYPE);
return leaves.length > 0 ? leaves[0] : null;
}
async navigateToCurrentDailyNote() {
const date = new Date();
await this.navigateToDailyNote(date);
}
async navigateToDailyNote(date: Date) {
const dailyNoteFileName = format(date, 'yyyy-MM-dd') + '.md';
const dailyNotePath = normalizePath(`${this.settings.dailyNotesFolder}/${dailyNoteFileName}`);
// Check if the daily note exists, if not create it
const fileExists = await this.app.vault.adapter.exists(dailyNotePath);
if (!fileExists) {
// Create the daily notes folder if it doesn't exist
await ensureFolderExists(this.app.vault, this.settings.dailyNotesFolder);
// Create daily note with default content
const content = this.generateDailyNoteTemplate(date);
await this.app.vault.create(dailyNotePath, content);
}
// Open the daily note
const file = this.app.vault.getAbstractFileByPath(dailyNotePath);
if (file instanceof TFile) {
await this.app.workspace.getLeaf(false).openFile(file);
}
}
async navigateToHomeNote() {
const homeNotePath = this.settings.homeNotePath;
// Check if the home note exists, if not create it
const fileExists = await this.app.vault.adapter.exists(homeNotePath);
if (!fileExists) {
// Create the parent folder if it doesn't exist
const folderPath = homeNotePath.substring(0, homeNotePath.lastIndexOf('/'));
await ensureFolderExists(this.app.vault, folderPath);
// Create home note with default content
const content = '# ChronoSync Home\n\nWelcome to your ChronoSync Home note!\n';
await this.app.vault.create(homeNotePath, content);
}
// Open the home note
const file = this.app.vault.getAbstractFileByPath(homeNotePath);
if (file instanceof TFile) {
await this.app.workspace.getLeaf(false).openFile(file);
}
}
async incrementPomodoros() {
await this.updateDailyNoteMetadata('pomodoros', (val) => {
const current = typeof val === 'number' ? val : 0;
return current + 1;
});
}
async toggleDailyMetadata(key: 'workout' | 'meditate' | 'important') {
await this.updateDailyNoteMetadata(key, (val) => {
return typeof val === 'boolean' ? !val : true;
});
}
async updateDailyNoteMetadata(key: string, updateFn: (val: any) => any) {
// Get the current daily note file
const date = new Date();
const dailyNoteFileName = format(date, 'yyyy-MM-dd') + '.md';
const dailyNotePath = normalizePath(`${this.settings.dailyNotesFolder}/${dailyNoteFileName}`);
// Check if the daily note exists, if not create it
const fileExists = await this.app.vault.adapter.exists(dailyNotePath);
if (!fileExists) {
await this.navigateToCurrentDailyNote();
}
// Get the file and update its metadata
const file = this.app.vault.getAbstractFileByPath(dailyNotePath);
if (file instanceof TFile) {
try {
// Process the frontmatter using FileManager.processFrontMatter for safer modification
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter[key] = updateFn(frontmatter[key]);
});
// Show notice
new Notice(`Updated ${key} in daily note`);
} catch (error) {
console.error('Error updating daily note metadata:', error);
new Notice('Error updating daily note metadata');
}
}
}
generateDailyNoteTemplate(date: Date): string {
const startTime = parseTime(this.settings.timeblockStartTime);
const endTime = parseTime(this.settings.timeblockEndTime);
const intervalMinutes = parseInt(this.settings.timeblockInterval);
if (!startTime || !endTime) {
return 'Error: Invalid timeblock settings';
}
return generateDailyNoteTemplate(
date,
startTime,
endTime,
intervalMinutes,
this.settings.autoAddTimeblock
);
}
generateTimeblockTable(): string {
// Create a timeblock table based on settings
const startTime = parseTime(this.settings.timeblockStartTime);
const endTime = parseTime(this.settings.timeblockEndTime);
const intervalMinutes = parseInt(this.settings.timeblockInterval);
if (!startTime || !endTime) return '';
let table = '| Time | Activity |\n| ---- | -------- |\n';
const startMinutes = startTime.hours * 60 + startTime.minutes;
const endMinutes = endTime.hours * 60 + endTime.minutes;
for (let minutes = startMinutes; minutes <= endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const timeStr = `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
table += `| ${timeStr} | |\n`;
}
return table;
}
openTaskCreationModal() {
new TaskCreationModal(this.app, this).open();
}
}

View file

@ -0,0 +1,293 @@
import { App, Modal, Notice, TFile } from 'obsidian';
import { format } from 'date-fns';
import * as YAML from 'yaml';
import ChronoSyncPlugin from '../main';
import { ensureFolderExists } from '../utils/helpers';
import { CALENDAR_VIEW_TYPE } from '../types';
import { CalendarView } from '../views/CalendarView';
export class TaskCreationModal extends Modal {
plugin: ChronoSyncPlugin;
title: string = '';
details: string = '';
dueDate: string = '';
priority: 'low' | 'normal' | 'high' = 'normal';
contexts: string = '';
tags: string = '';
recurrence: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' = 'none';
daysOfWeek: string[] = [];
dayOfMonth: string = '';
constructor(app: App, plugin: ChronoSyncPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('task-creation-modal');
contentEl.createEl('h2', { text: 'Create new task' });
// Title
this.createFormGroup(contentEl, 'Title', (container) => {
const input = container.createEl('input', { type: 'text' });
input.addEventListener('input', (e) => {
this.title = (e.target as HTMLInputElement).value;
});
});
// Details
this.createFormGroup(contentEl, 'Details', (container) => {
const textarea = container.createEl('textarea');
textarea.rows = 3;
textarea.addEventListener('input', (e) => {
this.details = (e.target as HTMLTextAreaElement).value;
});
});
// Due Date
this.createFormGroup(contentEl, 'Due date', (container) => {
const input = container.createEl('input', { type: 'date' });
input.addEventListener('change', (e) => {
this.dueDate = (e.target as HTMLInputElement).value;
});
});
// Priority
this.createFormGroup(contentEl, 'Priority', (container) => {
const select = container.createEl('select');
const options = [
{ value: 'low', text: 'Low' },
{ value: 'normal', text: 'Normal' },
{ value: 'high', text: 'High' }
];
options.forEach(option => {
const optEl = select.createEl('option', { value: option.value, text: option.text });
if (option.value === this.plugin.settings.defaultTaskPriority) {
optEl.selected = true;
this.priority = option.value as 'low' | 'normal' | 'high';
}
});
select.addEventListener('change', (e) => {
this.priority = (e.target as HTMLSelectElement).value as 'low' | 'normal' | 'high';
});
});
// Contexts
this.createFormGroup(contentEl, 'Contexts (comma-separated)', (container) => {
const input = container.createEl('input', { type: 'text' });
input.addEventListener('input', (e) => {
this.contexts = (e.target as HTMLInputElement).value;
});
});
// Tags
this.createFormGroup(contentEl, 'Tags (comma-separated)', (container) => {
const input = container.createEl('input', { type: 'text' });
input.addEventListener('input', (e) => {
this.tags = (e.target as HTMLInputElement).value;
});
});
// Recurrence
this.createFormGroup(contentEl, 'Recurrence', (container) => {
const select = container.createEl('select');
const options = [
{ value: 'none', text: 'None' },
{ value: 'daily', text: 'Daily' },
{ value: 'weekly', text: 'Weekly' },
{ value: 'monthly', text: 'Monthly' },
{ value: 'yearly', text: 'Yearly' }
];
options.forEach(option => {
select.createEl('option', { value: option.value, text: option.text });
});
select.addEventListener('change', (e) => {
this.recurrence = (e.target as HTMLSelectElement).value as any;
this.updateRecurrenceOptions(contentEl);
});
});
// The recurrence options container (will be populated based on recurrence selection)
const recurrenceOptions = contentEl.createDiv({ cls: 'recurrence-options' });
(recurrenceOptions as HTMLElement).style.display = 'none';
// Buttons
const buttonContainer = contentEl.createDiv({ cls: 'button-container' });
const cancelButton = buttonContainer.createEl('button', { text: 'Cancel' });
cancelButton.addEventListener('click', () => {
this.close();
});
const createButton = buttonContainer.createEl('button', { text: 'Create task', cls: 'create-button' });
createButton.addEventListener('click', () => {
this.createTask();
});
}
createFormGroup(container: HTMLElement, label: string, inputCallback: (container: HTMLElement) => void) {
const group = container.createDiv({ cls: 'form-group' });
group.createEl('label', { text: label });
inputCallback(group);
return group;
}
updateRecurrenceOptions(container: HTMLElement) {
const optionsContainer = container.querySelector('.recurrence-options');
if (!optionsContainer) return;
optionsContainer.empty();
(optionsContainer as HTMLElement).style.display = 'block';
if (this.recurrence === 'weekly') {
this.createDaysOfWeekSelector(optionsContainer as HTMLElement);
} else if (this.recurrence === 'monthly' || this.recurrence === 'yearly') {
this.createDayOfMonthSelector(optionsContainer as HTMLElement);
} else {
(optionsContainer as HTMLElement).style.display = 'none';
}
}
createDaysOfWeekSelector(container: HTMLElement) {
container.createEl('label', { text: 'Days of week' });
const checkboxContainer = container.createDiv({ cls: 'checkbox-container' });
const daysOfWeek = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const shortDays = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
daysOfWeek.forEach((day, index) => {
const label = checkboxContainer.createEl('label', { cls: 'checkbox-label' });
const checkbox = label.createEl('input', { type: 'checkbox' });
checkbox.dataset.day = shortDays[index];
checkbox.addEventListener('change', (e) => {
const isChecked = (e.target as HTMLInputElement).checked;
const day = (e.target as HTMLInputElement).dataset.day;
if (isChecked && day) {
this.daysOfWeek.push(day);
} else if (day) {
this.daysOfWeek = this.daysOfWeek.filter(d => d !== day);
}
});
label.appendChild(document.createTextNode(day));
});
}
createDayOfMonthSelector(container: HTMLElement) {
this.createFormGroup(container, 'Day of month', (group) => {
const input = group.createEl('input', { type: 'number' });
input.min = '1';
input.max = '31';
input.addEventListener('input', (e) => {
this.dayOfMonth = (e.target as HTMLInputElement).value;
});
});
}
async createTask() {
if (!this.title) {
new Notice('Title is required');
return;
}
try {
// Generate unique ID for the task
const now = new Date();
const datePart = format(now, 'yyyyMMdd');
const randomPart = Math.random().toString(36).substring(2, 5);
const taskId = `${datePart}${randomPart}`;
// Ensure the tasks folder exists
await ensureFolderExists(this.app.vault, this.plugin.settings.tasksFolder);
// Create the task file
const taskFilePath = `${this.plugin.settings.tasksFolder}/${taskId}.md`;
// Prepare tags (always include "task")
let tagsArray = ['task'];
if (this.tags) {
tagsArray = tagsArray.concat(this.tags.split(',').map(tag => tag.trim()));
}
// Prepare contexts
let contextsArray: string[] = [];
if (this.contexts) {
contextsArray = this.contexts.split(',').map(context => context.trim());
}
// Create the YAML frontmatter
const yaml: any = {
title: this.title,
zettelid: taskId,
dateCreated: format(now, "yyyy-MM-dd'T'HH:mm:ss"),
dateModified: format(now, "yyyy-MM-dd'T'HH:mm:ss"),
status: this.plugin.settings.defaultTaskStatus,
tags: tagsArray,
priority: this.priority,
};
// Add optional fields
if (this.dueDate) {
yaml.due = this.dueDate;
}
if (contextsArray.length > 0) {
yaml.contexts = contextsArray;
}
// Add recurrence info if specified
if (this.recurrence !== 'none') {
yaml.recurrence = {
frequency: this.recurrence
};
if (this.recurrence === 'weekly' && this.daysOfWeek.length > 0) {
yaml.recurrence.days_of_week = this.daysOfWeek;
}
if ((this.recurrence === 'monthly' || this.recurrence === 'yearly') && this.dayOfMonth) {
yaml.recurrence.day_of_month = parseInt(this.dayOfMonth);
}
yaml.complete_instances = [];
}
// Prepare the file content
const content = `---\n${YAML.stringify(yaml)}---\n\n# ${this.title}\n\n${this.details}`;
// Create the file
await this.app.vault.create(taskFilePath, content);
// Show success notice and close modal
new Notice('Task created successfully');
this.close();
// Find calendar view and refresh it if open
const calendarLeaf = this.plugin.getCalendarLeaf();
if (calendarLeaf) {
const view = calendarLeaf.view as CalendarView;
if (view.activeTab === 'tasks') {
// Refresh the view
view.refreshView();
}
}
} catch (error) {
console.error('Error creating task:', error);
new Notice('Error creating task. Check the console for details.');
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

163
src/settings/settings.ts Normal file
View file

@ -0,0 +1,163 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import ChronoSyncPlugin from '../main';
export interface ChronoSyncSettings {
dailyNotesFolder: string;
tasksFolder: string;
notesFolder: string;
homeNotePath: string;
defaultTaskPriority: 'low' | 'normal' | 'high';
defaultTaskStatus: 'open' | 'in-progress' | 'done';
timeblockStartTime: string;
timeblockEndTime: string;
timeblockInterval: '30' | '60';
autoAddTimeblock: boolean;
}
export const DEFAULT_SETTINGS: ChronoSyncSettings = {
dailyNotesFolder: 'ChronoSync/Daily',
tasksFolder: 'ChronoSync/Tasks',
notesFolder: 'ChronoSync/Notes',
homeNotePath: 'ChronoSync/Home.md',
defaultTaskPriority: 'normal',
defaultTaskStatus: 'open',
timeblockStartTime: '05:00',
timeblockEndTime: '23:30',
timeblockInterval: '30',
autoAddTimeblock: true
};
export class ChronoSyncSettingTab extends PluginSettingTab {
plugin: ChronoSyncPlugin;
constructor(app: App, plugin: ChronoSyncPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// General Settings
new Setting(containerEl)
.setName('Daily notes folder')
.setDesc('Folder where daily notes will be stored')
.addText(text => text
.setPlaceholder('ChronoSync/Daily')
.setValue(this.plugin.settings.dailyNotesFolder)
.onChange(async (value) => {
this.plugin.settings.dailyNotesFolder = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Tasks folder')
.setDesc('Folder where tasks will be stored')
.addText(text => text
.setPlaceholder('ChronoSync/Tasks')
.setValue(this.plugin.settings.tasksFolder)
.onChange(async (value) => {
this.plugin.settings.tasksFolder = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Notes folder')
.setDesc('Folder where general notes will be stored (use "/" for vault root)')
.addText(text => text
.setPlaceholder('ChronoSync/Notes')
.setValue(this.plugin.settings.notesFolder)
.onChange(async (value) => {
this.plugin.settings.notesFolder = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Home note path')
.setDesc('Path to the home note file')
.addText(text => text
.setPlaceholder('ChronoSync/Home.md')
.setValue(this.plugin.settings.homeNotePath)
.onChange(async (value) => {
this.plugin.settings.homeNotePath = value;
await this.plugin.saveSettings();
}));
// Task Settings
new Setting(containerEl).setName('Tasks').setHeading();
new Setting(containerEl)
.setName('Default task priority')
.setDesc('Default priority for new tasks')
.addDropdown(dropdown => dropdown
.addOption('low', 'Low')
.addOption('normal', 'Normal')
.addOption('high', 'High')
.setValue(this.plugin.settings.defaultTaskPriority)
.onChange(async (value: any) => {
this.plugin.settings.defaultTaskPriority = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Default task status')
.setDesc('Default status for new tasks')
.addDropdown(dropdown => dropdown
.addOption('open', 'Open')
.addOption('in-progress', 'In Progress')
.addOption('done', 'Done')
.setValue(this.plugin.settings.defaultTaskStatus)
.onChange(async (value: any) => {
this.plugin.settings.defaultTaskStatus = value;
await this.plugin.saveSettings();
}));
// Timeblock Settings
new Setting(containerEl).setName('Timeblocks').setHeading();
new Setting(containerEl)
.setName('Default timeblock start time')
.setDesc('Start time for timeblock table (HH:MM format)')
.addText(text => text
.setPlaceholder('05:00')
.setValue(this.plugin.settings.timeblockStartTime)
.onChange(async (value) => {
this.plugin.settings.timeblockStartTime = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Default timeblock end time')
.setDesc('End time for timeblock table (HH:MM format)')
.addText(text => text
.setPlaceholder('23:30')
.setValue(this.plugin.settings.timeblockEndTime)
.onChange(async (value) => {
this.plugin.settings.timeblockEndTime = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Timeblock interval')
.setDesc('Interval between timeblock entries')
.addDropdown(dropdown => dropdown
.addOption('30', '30 minutes')
.addOption('60', '1 hour')
.setValue(this.plugin.settings.timeblockInterval)
.onChange(async (value: any) => {
this.plugin.settings.timeblockInterval = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Auto-add timeblock')
.setDesc('Automatically add timeblock table to new daily notes')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoAddTimeblock)
.onChange(async (value) => {
this.plugin.settings.autoAddTimeblock = value;
await this.plugin.saveSettings();
}));
}
}

27
src/types.ts Normal file
View file

@ -0,0 +1,27 @@
// View types
export const CALENDAR_VIEW_TYPE = 'chronosync-calendar-view';
export const TASK_LIST_VIEW_TYPE = 'chronosync-task-list-view';
export const NOTES_VIEW_TYPE = 'chronosync-notes-view';
// Time and date related types
export interface TimeInfo {
hours: number;
minutes: number;
}
// Task types
export interface TaskInfo {
title: string;
status: string;
priority: string;
due?: string;
path: string;
}
// Note types
export interface NoteInfo {
title: string;
tags: string[];
path: string;
createdDate?: string;
}

251
src/utils/helpers.ts Normal file
View file

@ -0,0 +1,251 @@
import { normalizePath, TFile, Vault } from 'obsidian';
import { format } from 'date-fns';
import { TimeInfo } from '../types';
import * as YAML from 'yaml';
/**
* Ensures a folder and its parent folders exist
*/
export async function ensureFolderExists(vault: Vault, folderPath: string): Promise<void> {
const folders = folderPath.split('/').filter(folder => folder.length > 0);
let currentPath = '';
for (const folder of folders) {
currentPath = currentPath ? `${currentPath}/${folder}` : folder;
const exists = await vault.adapter.exists(currentPath);
if (!exists) {
await vault.createFolder(currentPath);
}
}
}
/**
* Parses a time string in the format HH:MM and returns hours and minutes
*/
export function parseTime(timeStr: string): TimeInfo | null {
const match = timeStr.match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return null;
}
return { hours, minutes };
}
/**
* Updates a YAML frontmatter value in a Markdown file
*/
export function updateYamlFrontmatter(content: string, key: string, updateFn: (val: any) => any): string {
// Check if the content has YAML frontmatter
if (!content.startsWith('---')) {
// If not, add it with the updated key
const yamlObj: any = {};
yamlObj[key] = updateFn(undefined);
const yamlStr = YAML.stringify(yamlObj);
return `---\n${yamlStr}---\n\n${content}`;
}
// Find the end of the frontmatter
const endOfFrontmatter = content.indexOf('---', 3);
if (endOfFrontmatter === -1) return content;
// Extract the frontmatter
const frontmatter = content.substring(3, endOfFrontmatter);
const restOfContent = content.substring(endOfFrontmatter);
// Parse the frontmatter
let yamlObj: any = {};
try {
yamlObj = YAML.parse(frontmatter) || {};
} catch (e) {
console.error('Error parsing YAML frontmatter:', e);
return content;
}
// Update the key
yamlObj[key] = updateFn(yamlObj[key]);
// Stringify the frontmatter
const updatedFrontmatter = YAML.stringify(yamlObj);
// Return the updated content
return `---\n${updatedFrontmatter}---${restOfContent}`;
}
/**
* Generates a timeblock table based on start/end times and interval
*/
export function generateTimeblockTable(startTime: TimeInfo, endTime: TimeInfo, intervalMinutes: number): string {
if (!startTime || !endTime) return '';
let table = '| Time | Activity |\n| ---- | -------- |\n';
const startMinutes = startTime.hours * 60 + startTime.minutes;
const endMinutes = endTime.hours * 60 + endTime.minutes;
for (let minutes = startMinutes; minutes <= endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const timeStr = `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
table += `| ${timeStr} | |\n`;
}
return table;
}
/**
* Generates a daily note template
*/
export function generateDailyNoteTemplate(date: Date, timeblockStartTime: TimeInfo, timeblockEndTime: TimeInfo, intervalMinutes: number, addTimeblock: boolean): string {
const dateStr = format(date, 'yyyy-MM-dd');
// Create the YAML frontmatter
const yaml = {
date: dateStr,
pomodoros: 0,
workout: false,
meditate: false,
important: false,
tags: ['daily']
};
let content = `---\n${YAML.stringify(yaml)}---\n\n# ${format(date, 'eeee, MMMM do, yyyy')}\n\n## Notes\n\n`;
// Add timeblock table if configured
if (addTimeblock) {
content += `\n## Timeblock\n\n${generateTimeblockTable(timeblockStartTime, timeblockEndTime, parseInt(intervalMinutes.toString()))}`;
}
return content;
}
/**
* Checks if two dates are the same day
*/
export function isSameDay(date1: Date, date2: Date): boolean {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
}
/**
* Extracts task information from a task file's content
*/
export function extractTaskInfo(content: string, path: string): { title: string, status: string, priority: string, due?: string, path: string } | null {
// Try to extract task info from frontmatter
if (content.startsWith('---')) {
const endOfFrontmatter = content.indexOf('---', 3);
if (endOfFrontmatter !== -1) {
const frontmatter = content.substring(3, endOfFrontmatter);
try {
const yaml = YAML.parse(frontmatter);
if (yaml) {
return {
title: yaml.title || 'Untitled Task',
status: yaml.status || 'open',
priority: yaml.priority || 'normal',
due: yaml.due,
path
};
}
} catch (e) {
console.error('Error parsing YAML frontmatter:', e);
}
}
}
// Fallback to basic info from filename
const filename = path.split('/').pop()?.replace('.md', '') || 'Untitled';
return {
title: filename,
status: 'open',
priority: 'normal',
path
};
}
/**
* Checks if a task is overdue
*/
export function isTaskOverdue(task: {due?: string}): boolean {
if (!task.due) return false;
const dueDate = new Date(task.due);
const today = new Date();
today.setHours(0, 0, 0, 0);
return dueDate < today;
}
/**
* Extracts note information from a note file's content
*/
export function extractNoteInfo(content: string, path: string, file?: TFile): {title: string, tags: string[], path: string, createdDate?: string} | null {
let title = path.split('/').pop()?.replace('.md', '') || 'Untitled';
let tags: string[] = [];
let createdDate: string | undefined = undefined;
// Try to extract note info from frontmatter
if (content.startsWith('---')) {
const endOfFrontmatter = content.indexOf('---', 3);
if (endOfFrontmatter !== -1) {
const frontmatter = content.substring(3, endOfFrontmatter);
try {
const yaml = YAML.parse(frontmatter);
if (yaml) {
if (yaml.title) {
title = yaml.title;
}
if (yaml.tags && Array.isArray(yaml.tags)) {
tags = yaml.tags;
}
// Extract creation date from dateCreated or date field
if (yaml.dateCreated) {
createdDate = yaml.dateCreated;
} else if (yaml.date) {
createdDate = yaml.date;
}
}
} catch (e) {
console.error('Error parsing YAML frontmatter:', e);
}
}
}
// Look for first heading in the content as a fallback title
if (title === 'Untitled') {
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch && headingMatch[1]) {
title = headingMatch[1].trim();
}
}
// If no creation date in frontmatter, use file creation time
if (!createdDate && file) {
createdDate = format(new Date(file.stat.ctime), "yyyy-MM-dd'T'HH:mm:ss");
}
return { title, tags, path, createdDate };
}
/**
* Extracts timeblock content from a daily note
*/
export function extractTimeblockContent(content: string): string | null {
// Check if the content has a timeblock section
const timeblockMatch = content.match(/## Timeblock\s*\n([^#]*)/);
if (timeblockMatch && timeblockMatch[1]) {
return timeblockMatch[1].trim();
}
return null;
}

895
src/views/CalendarView.ts Normal file
View file

@ -0,0 +1,895 @@
import { Notice, TFile, View, WorkspaceLeaf, normalizePath } from 'obsidian';
import { format } from 'date-fns';
import ChronoSyncPlugin from '../main';
import { CALENDAR_VIEW_TYPE, TaskInfo, NoteInfo, TimeInfo } from '../types';
import {
extractNoteInfo,
extractTaskInfo,
extractTimeblockContent,
isTaskOverdue,
isSameDay,
parseTime
} from '../utils/helpers';
export class CalendarView extends View {
plugin: ChronoSyncPlugin;
currentDate: Date;
viewType: 'month' | 'week' = 'month';
activeTab: 'tasks' | 'notes' | 'timeblock' = 'tasks';
constructor(leaf: WorkspaceLeaf, plugin: ChronoSyncPlugin) {
super(leaf);
this.plugin = plugin;
this.currentDate = new Date();
}
getViewType(): string {
return CALENDAR_VIEW_TYPE;
}
getDisplayText(): string {
return 'Calendar';
}
getIcon(): string {
return 'calendar-days';
}
async onOpen() {
// Clear and prepare the content element
const contentEl = this.containerEl;
contentEl.empty();
// Add a container for our view content
const container = contentEl.createDiv({ cls: 'chronosync-container' });
// Create and add UI elements
container.createEl('h2', { text: 'ChronoSync Calendar' });
// Create the calendar UI
this.renderView(container);
}
renderView(container: HTMLElement) {
// Clear existing content except the header
const header = container.querySelector('h2');
container.empty();
if (header) container.appendChild(header);
// Create the calendar UI
this.createCalendarControls(container);
if (this.viewType === 'month') {
this.createCalendarGrid(container);
} else {
this.createWeekView(container);
}
this.createSidePanel(container);
}
navigateToPreviousPeriod() {
if (this.viewType === 'month') {
// Go to previous month
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1, 1);
} else {
// Go to previous week
this.currentDate = new Date(this.currentDate.getTime() - 7 * 24 * 60 * 60 * 1000);
}
this.refreshView();
}
navigateToNextPeriod() {
if (this.viewType === 'month') {
// Go to next month
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 1);
} else {
// Go to next week
this.currentDate = new Date(this.currentDate.getTime() + 7 * 24 * 60 * 60 * 1000);
}
this.refreshView();
}
navigateToToday() {
this.currentDate = new Date();
this.refreshView();
}
switchView(viewType: 'month' | 'week') {
if (this.viewType === viewType) return;
this.viewType = viewType;
this.refreshView();
}
async onClose() {
// Clean up when the view is closed
this.containerEl.empty();
}
// Helper method to refresh the view
refreshView() {
// Store the current active tab
const activeTabBefore = this.activeTab;
const container = this.containerEl.querySelector('.chronosync-container') as HTMLElement;
if (container) {
this.renderView(container);
// Restore the active tab
if (activeTabBefore) {
// Find the tab and click it
const tabSelector = `.chronosync-tab[data-tab="${activeTabBefore}"]`;
const tabButton = container.querySelector(tabSelector);
if (tabButton) {
(tabButton as HTMLElement).click();
}
}
}
}
createCalendarControls(container: HTMLElement) {
const controlsContainer = container.createDiv({ cls: 'calendar-controls' });
// Add month navigation
const navContainer = controlsContainer.createDiv({ cls: 'calendar-nav' });
const prevButton = navContainer.createEl('button', { text: '←' });
prevButton.addEventListener('click', () => {
this.navigateToPreviousPeriod();
});
const currentMonth = navContainer.createEl('span', {
text: format(this.currentDate, 'MMMM yyyy'),
cls: 'current-month'
});
const todayButton = navContainer.createEl('button', { text: 'Today' });
todayButton.addEventListener('click', () => {
this.navigateToToday();
});
const nextButton = navContainer.createEl('button', { text: '→' });
nextButton.addEventListener('click', () => {
this.navigateToNextPeriod();
});
// Add view type switcher
const viewContainer = controlsContainer.createDiv({ cls: 'view-switcher' });
const monthButton = viewContainer.createEl('button', { text: 'Month' });
if (this.viewType === 'month') monthButton.classList.add('active');
monthButton.addEventListener('click', () => {
this.switchView('month');
});
const weekButton = viewContainer.createEl('button', { text: 'Week' });
if (this.viewType === 'week') weekButton.classList.add('active');
weekButton.addEventListener('click', () => {
this.switchView('week');
});
}
createCalendarGrid(container: HTMLElement) {
const calendarContainer = container.createDiv({ cls: 'calendar-grid-container' });
const grid = calendarContainer.createDiv({ cls: 'calendar-grid' });
// Create header with day names
const header = grid.createDiv({ cls: 'calendar-header' });
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
for (const day of dayNames) {
header.createDiv({ cls: 'calendar-day-header', text: day });
}
// Get current year and month from the currentDate
const currentYear = this.currentDate.getFullYear();
const currentMonth = this.currentDate.getMonth();
// First day of the month
const firstDay = new Date(currentYear, currentMonth, 1);
// Last day of the month
const lastDay = new Date(currentYear, currentMonth + 1, 0);
// Get the day of the week for the first day (0 = Sunday, 6 = Saturday)
const firstDayOfWeek = firstDay.getDay();
// Calculate the total number of cells needed (max 6 rows of 7 days)
const totalDays = lastDay.getDate();
const totalCells = Math.ceil((totalDays + firstDayOfWeek) / 7) * 7;
// Create the grid cells
for (let i = 0; i < totalCells; i++) {
const dayCell = grid.createDiv({ cls: 'calendar-day' });
// Calculate the day number
const dayNumber = i - firstDayOfWeek + 1;
if (dayNumber > 0 && dayNumber <= totalDays) {
// Regular day in current month
dayCell.textContent = dayNumber.toString();
const cellDate = new Date(currentYear, currentMonth, dayNumber);
// Highlight today
const today = new Date();
if (today.getFullYear() === cellDate.getFullYear() &&
today.getMonth() === cellDate.getMonth() &&
today.getDate() === cellDate.getDate()) {
dayCell.classList.add('today');
}
// Highlight selected date
if (isSameDay(cellDate, this.currentDate)) {
dayCell.classList.add('selected');
}
// Add click event handler - just select the date, don't open the note
dayCell.addEventListener('click', () => {
// Update current date and refresh the view
this.currentDate = new Date(cellDate);
this.refreshView();
});
// Add double-click handler to open the daily note
dayCell.addEventListener('dblclick', () => {
// Navigate to daily note for this day
this.navigateToDailyNote(cellDate);
});
// TODO: Add indicators for tasks, daily notes, etc.
} else {
// Day outside current month
dayCell.classList.add('outside-month');
// Calculate the actual date for days outside current month
let date: Date;
if (dayNumber <= 0) {
// Days from previous month
const prevMonth = new Date(currentYear, currentMonth - 1, 1);
const daysInPrevMonth = new Date(currentYear, currentMonth, 0).getDate();
date = new Date(prevMonth.getFullYear(), prevMonth.getMonth(), daysInPrevMonth + dayNumber);
dayCell.textContent = date.getDate().toString();
} else {
// Days from next month
const nextMonth = new Date(currentYear, currentMonth + 1, 1);
date = new Date(nextMonth.getFullYear(), nextMonth.getMonth(), dayNumber - totalDays);
dayCell.textContent = date.getDate().toString();
}
// Highlight selected date
if (isSameDay(date, this.currentDate)) {
dayCell.classList.add('selected');
}
// Add click event handler for days outside current month - just select the date
dayCell.addEventListener('click', () => {
// Update current date and refresh the view
this.currentDate = new Date(date);
this.refreshView();
});
// Add double-click handler to open the daily note
dayCell.addEventListener('dblclick', () => {
// Navigate to daily note for this day
this.navigateToDailyNote(date);
});
}
}
}
createWeekView(container: HTMLElement) {
const weekContainer = container.createDiv({ cls: 'week-view-container' });
const grid = weekContainer.createDiv({ cls: 'week-grid' });
// Determine the start of the week (Sunday of the week containing currentDate)
const currentDate = new Date(this.currentDate);
const dayOfWeek = currentDate.getDay();
const startOfWeek = new Date(currentDate);
startOfWeek.setDate(currentDate.getDate() - dayOfWeek);
// Create header with day names and dates
const header = grid.createDiv({ cls: 'week-header' });
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
for (let i = 0; i < 7; i++) {
const date = new Date(startOfWeek);
date.setDate(startOfWeek.getDate() + i);
const dayHeader = header.createDiv({ cls: 'week-day-header' });
dayHeader.createDiv({ cls: 'week-day-name', text: dayNames[i] });
dayHeader.createDiv({ cls: 'week-day-date', text: format(date, 'MMM d') });
// Highlight today
const today = new Date();
if (today.getFullYear() === date.getFullYear() &&
today.getMonth() === date.getMonth() &&
today.getDate() === date.getDate()) {
dayHeader.classList.add('today');
}
// Highlight selected date
if (isSameDay(date, this.currentDate)) {
dayHeader.classList.add('selected');
}
// Add click event to select the day
dayHeader.addEventListener('click', () => {
this.currentDate = new Date(date);
this.refreshView();
});
// Add double-click event to navigate to the daily note
dayHeader.addEventListener('dblclick', () => {
this.navigateToDailyNote(date);
});
}
// Create time slots for the week view
const timeContainer = grid.createDiv({ cls: 'week-time-container' });
// Start and end times based on settings
const startTime = parseTime(this.plugin.settings.timeblockStartTime);
const endTime = parseTime(this.plugin.settings.timeblockEndTime);
const intervalMinutes = parseInt(this.plugin.settings.timeblockInterval);
if (!startTime || !endTime) return;
const startMinutes = startTime.hours * 60 + startTime.minutes;
const endMinutes = endTime.hours * 60 + endTime.minutes;
for (let minutes = startMinutes; minutes <= endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const timeStr = `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
const timeRow = timeContainer.createDiv({ cls: 'week-time-row' });
timeRow.createDiv({ cls: 'week-time-label', text: timeStr });
const timeGrid = timeRow.createDiv({ cls: 'week-time-grid' });
// Create a cell for each day in the week
for (let i = 0; i < 7; i++) {
const date = new Date(startOfWeek);
date.setDate(startOfWeek.getDate() + i);
const cell = timeGrid.createDiv({ cls: 'week-time-cell' });
// Add highlight for current time if it matches
const now = new Date();
if (now.getFullYear() === date.getFullYear() &&
now.getMonth() === date.getMonth() &&
now.getDate() === date.getDate() &&
now.getHours() === hours &&
Math.floor(now.getMinutes() / intervalMinutes) * intervalMinutes === mins) {
cell.classList.add('current-time');
}
// Add click handler to edit the timeblock
cell.addEventListener('click', () => {
// TODO: Implement timeblock editing
new Notice(`Editing timeblock for ${format(date, 'yyyy-MM-dd')} at ${timeStr}`);
});
}
}
}
createSidePanel(container: HTMLElement) {
const mainContent = container.createDiv({ cls: 'chronosync-main-content' });
// Create tabs for different views
const tabsContainer = mainContent.createDiv({ cls: 'chronosync-tabs' });
const tasksTab = tabsContainer.createEl('button', {
text: 'Tasks',
cls: 'chronosync-tab active',
attr: { 'data-tab': 'tasks' }
});
const notesTab = tabsContainer.createEl('button', {
text: 'Notes',
cls: 'chronosync-tab',
attr: { 'data-tab': 'notes' }
});
const timeblockTab = tabsContainer.createEl('button', {
text: 'Timeblock',
cls: 'chronosync-tab',
attr: { 'data-tab': 'timeblock' }
});
// Create content area
const contentArea = mainContent.createDiv({ cls: 'chronosync-content-area' });
// Initial content - Tasks view
this.createTasksView(contentArea);
// Store active tab reference
this.activeTab = 'tasks';
// Set up tab switching
tasksTab.addEventListener('click', () => {
this.setActiveTab(tabsContainer, tasksTab);
contentArea.empty();
this.activeTab = 'tasks';
this.createTasksView(contentArea);
});
notesTab.addEventListener('click', () => {
this.setActiveTab(tabsContainer, notesTab);
contentArea.empty();
this.activeTab = 'notes';
this.createNotesView(contentArea);
});
timeblockTab.addEventListener('click', () => {
this.setActiveTab(tabsContainer, timeblockTab);
contentArea.empty();
this.activeTab = 'timeblock';
this.createTimeblockView(contentArea);
});
}
setActiveTab(container: HTMLElement, activeTab: HTMLElement) {
const tabs = container.querySelectorAll('.chronosync-tab');
tabs.forEach(tab => tab.classList.remove('active'));
activeTab.classList.add('active');
}
async createTasksView(container: HTMLElement) {
container.createEl('h3', { text: 'Tasks' });
// Create filters
const filtersContainer = container.createDiv({ cls: 'task-filters' });
// Status filter
const statusFilter = filtersContainer.createDiv({ cls: 'filter-group' });
statusFilter.createEl('span', { text: 'Status: ' });
const statusSelect = statusFilter.createEl('select');
const statuses = ['All', 'Open', 'In Progress', 'Done'];
statuses.forEach(status => {
const option = statusSelect.createEl('option', { value: status.toLowerCase(), text: status });
});
// Add task button
const addButton = filtersContainer.createEl('button', { text: 'New Task', cls: 'add-task-button' });
addButton.addEventListener('click', () => {
// Use the TaskCreationModal from the plugin
this.plugin.openTaskCreationModal();
});
// Task list
const taskList = container.createDiv({ cls: 'task-list' });
// Get tasks for the current view
const tasks = await this.getTasksForView();
if (tasks.length === 0) {
// Placeholder for empty task list
taskList.createEl('p', { text: 'No tasks found for the selected date and filters.' });
} else {
// Create task items
tasks.forEach(task => {
const taskItem = taskList.createDiv({ cls: 'task-item' });
const taskInfo = taskItem.createDiv({ cls: 'task-info' });
taskInfo.createDiv({
cls: `task-item-title task-priority-${task.priority}`,
text: task.title
});
if (task.due) {
taskInfo.createDiv({
cls: `task-item-due ${isTaskOverdue(task) ? 'task-overdue' : ''}`,
text: `Due: ${task.due}`
});
}
const taskMeta = taskItem.createDiv({ cls: 'task-item-metadata' });
taskMeta.createDiv({
cls: `task-status task-status-${task.status.replace(/\s+/g, '-').toLowerCase()}`,
text: task.status
});
// Add click handler to open task
taskItem.addEventListener('click', () => {
this.openTask(task.path);
});
});
}
}
async createNotesView(container: HTMLElement) {
// Get the selected date as a string for display
const selectedDate = this.getSingleSelectedDate();
const dateText = selectedDate ? `Notes for ${format(selectedDate, 'MMM d, yyyy')}` : 'All Notes';
container.createEl('h3', { text: dateText });
// Notes list
const notesList = container.createDiv({ cls: 'notes-list' });
// Get notes for the current view
const notes = await this.getNotesForView();
if (notes.length === 0) {
// Placeholder for empty notes list
notesList.createEl('p', { text: 'No notes found for the selected date.' });
} else {
// Create note items
notes.forEach(note => {
const noteItem = notesList.createDiv({ cls: 'note-item' });
noteItem.createDiv({ cls: 'note-item-title', text: note.title });
// Add created date if available
if (note.createdDate) {
const dateStr = note.createdDate.indexOf('T') > 0
? format(new Date(note.createdDate), 'MMM d, yyyy h:mm a')
: note.createdDate;
noteItem.createDiv({ cls: 'note-item-date', text: `Created: ${dateStr}` });
}
if (note.tags && note.tags.length > 0) {
const tagContainer = noteItem.createDiv({ cls: 'note-item-tags' });
note.tags.forEach(tag => {
tagContainer.createSpan({ cls: 'note-tag', text: tag });
});
}
// Add click handler to open note
noteItem.addEventListener('click', () => {
this.openNote(note.path);
});
});
}
}
async createTimeblockView(container: HTMLElement) {
container.createEl('h3', { text: 'Timeblock' });
// Get the daily note for the specific date
const date = this.getSingleSelectedDate();
if (!date) {
// No specific date selected in current view
container.createEl('p', { text: 'Select a specific date to view timeblocks.' });
return;
}
const dateStr = format(date, 'yyyy-MM-dd');
const dailyNotePath = normalizePath(`${this.plugin.settings.dailyNotesFolder}/${dateStr}.md`);
// Check if the daily note exists
const fileExists = await this.app.vault.adapter.exists(dailyNotePath);
if (!fileExists) {
container.createEl('p', { text: `No daily note exists for ${format(date, 'MMMM d, yyyy')}. Click a date to create one.` });
const createButton = container.createEl('button', { text: 'Create daily note', cls: 'create-note-button' });
createButton.addEventListener('click', () => {
this.navigateToDailyNote(date);
});
return;
}
// Timeblock editor
const timeblockEditor = container.createDiv({ cls: 'timeblock-editor' });
// Get the timeblock content from the daily note
const file = this.app.vault.getAbstractFileByPath(dailyNotePath);
if (file instanceof TFile) {
const content = await this.app.vault.read(file);
const timeblockContent = extractTimeblockContent(content);
if (timeblockContent) {
// Create a timeblock table
const table = timeblockEditor.createEl('table', { cls: 'timeblock-table' });
// Parse the timeblock content and create rows
const rows = timeblockContent.trim().split('\n').slice(1); // Skip header row
rows.forEach(row => {
const [time, activity] = row.split('|').map(cell => cell.trim()).filter(cell => cell);
if (time && time !== '----') {
const tr = table.createEl('tr');
tr.createEl('td', { cls: 'timeblock-time', text: time });
const activityTd = tr.createEl('td', { cls: 'timeblock-activity' });
if (activity) {
activityTd.textContent = activity;
}
// Make the activity cell editable
activityTd.addEventListener('click', () => {
this.editTimeblockActivity(date, time, activityTd, dailyNotePath);
});
}
});
} else {
// No timeblock found
timeblockEditor.createEl('p', { text: 'No timeblock found in the daily note.' });
const generateButton = timeblockEditor.createEl('button', {
text: 'Generate timeblock',
cls: 'generate-timeblock-button'
});
generateButton.addEventListener('click', async () => {
// Generate a timeblock table and add it to the daily note
await this.addTimeblockToNote(file);
// Refresh the view
this.createTimeblockView(container);
});
}
}
}
async getTasksForView(): Promise<TaskInfo[]> {
const tasksFolder = this.plugin.settings.tasksFolder;
const result: TaskInfo[] = [];
// Ensure tasks folder exists
const folderExists = await this.app.vault.adapter.exists(tasksFolder);
if (!folderExists) return result;
// Get all files in the tasks folder
const files = this.app.vault.getFiles().filter(file =>
file.path.startsWith(tasksFolder) && file.extension === 'md'
);
// Extract task information from each file
for (const file of files) {
try {
const content = await this.app.vault.read(file);
const taskInfo = extractTaskInfo(content, file.path);
if (taskInfo) {
// Filter based on current view date if it's a specific date
if (this.viewType === 'month' || this.viewType === 'week') {
// If we're in a specific date view, filter only tasks due in the current month/week
if (taskInfo.due) {
const dueDate = new Date(taskInfo.due);
const startDate = this.getViewStartDate();
const endDate = this.getViewEndDate();
// Only include tasks due in the current view range
if (dueDate >= startDate && dueDate <= endDate) {
result.push(taskInfo);
}
} else {
// Include tasks without due dates
result.push(taskInfo);
}
} else {
// For all other views, include all tasks
result.push(taskInfo);
}
}
} catch (e) {
console.error(`Error processing task file ${file.path}:`, e);
}
}
// Sort tasks by due date, then priority
return result.sort((a, b) => {
// Sort by due date
if (a.due && b.due) {
return new Date(a.due).getTime() - new Date(b.due).getTime();
}
// Tasks with due dates come before tasks without
if (a.due && !b.due) return -1;
if (!a.due && b.due) return 1;
// Then sort by priority
const priorityOrder = { high: 0, normal: 1, low: 2 };
return priorityOrder[a.priority as keyof typeof priorityOrder] - priorityOrder[b.priority as keyof typeof priorityOrder];
});
}
async getNotesForView(): Promise<NoteInfo[]> {
const notesFolder = this.plugin.settings.notesFolder;
const result: NoteInfo[] = [];
// Ensure notes folder exists
const folderExists = await this.app.vault.adapter.exists(notesFolder);
if (!folderExists) return result;
// Get all markdown files in the notes folder
const files = this.app.vault.getFiles().filter(file =>
file.path.startsWith(notesFolder) && file.extension === 'md'
);
// Get the selected date
const selectedDate = this.getSingleSelectedDate();
const selectedDateStr = selectedDate ? format(selectedDate, 'yyyy-MM-dd') : null;
// Extract note information from each file
for (const file of files) {
try {
const content = await this.app.vault.read(file);
const noteInfo = extractNoteInfo(content, file.path, file);
if (noteInfo) {
// Only include notes that match the selected date's creation date
if (!selectedDateStr || (noteInfo.createdDate && noteInfo.createdDate.startsWith(selectedDateStr))) {
result.push(noteInfo);
}
}
} catch (e) {
console.error(`Error processing note file ${file.path}:`, e);
}
}
// Sort notes by title
return result.sort((a, b) => a.title.localeCompare(b.title));
}
async addTimeblockToNote(file: TFile) {
try {
// Read the content
const content = await this.app.vault.read(file);
// Check if it already has a timeblock section
if (extractTimeblockContent(content)) {
new Notice('Note already has a timeblock section');
return;
}
// Parse the settings for timeblock generation
const startTime = parseTime(this.plugin.settings.timeblockStartTime);
const endTime = parseTime(this.plugin.settings.timeblockEndTime);
const interval = parseInt(this.plugin.settings.timeblockInterval);
if (!startTime || !endTime) {
new Notice('Invalid timeblock settings');
return;
}
// Generate the timeblock table
const timeblockTable = this.plugin.generateTimeblockTable();
// Append the timeblock to the content
const updatedContent = `${content.trim()}\n\n## Timeblock\n\n${timeblockTable}\n`;
// Update the file
await this.app.vault.modify(file, updatedContent);
new Notice('Timeblock added to daily note');
} catch (e) {
console.error('Error adding timeblock to note:', e);
new Notice('Error adding timeblock to note');
}
}
async editTimeblockActivity(date: Date, time: string, cell: HTMLElement, notePath: string) {
// Create an input element
const input = document.createElement('input');
input.type = 'text';
input.value = cell.textContent || '';
input.className = 'timeblock-edit-input';
input.style.width = '100%';
// Replace the cell content with the input
cell.empty();
cell.appendChild(input);
input.focus();
// Handle saving on enter or blur
const saveChange = async () => {
const newValue = input.value;
cell.textContent = newValue;
// Update the note file
await this.updateTimeblockInNote(time, newValue, notePath);
};
input.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
await saveChange();
} else if (e.key === 'Escape') {
// Restore original content
cell.textContent = input.value;
}
});
input.addEventListener('blur', async () => {
await saveChange();
});
}
async updateTimeblockInNote(time: string, activity: string, notePath: string) {
try {
const file = this.app.vault.getAbstractFileByPath(notePath);
if (!(file instanceof TFile)) return;
const content = await this.app.vault.read(file);
const timeblockMatch = content.match(/## Timeblock\s*\n([^#]*)/);
if (!timeblockMatch) return;
const timeblockContent = timeblockMatch[1].trim();
const lines = timeblockContent.split('\n');
// Find the line with the matching time
let updatedTimeblock = lines.map(line => {
if (line.includes(`| ${time} |`)) {
return `| ${time} | ${activity} |`;
}
return line;
}).join('\n');
// Replace the timeblock in the content
const updatedContent = content.replace(
/## Timeblock\s*\n([^#]*)/,
`## Timeblock\n\n${updatedTimeblock}\n`
);
// Update the file
await this.app.vault.modify(file, updatedContent);
} catch (e) {
console.error('Error updating timeblock in note:', e);
new Notice('Error updating timeblock');
}
}
getSingleSelectedDate(): Date | null {
if (this.viewType === 'week') {
// For week view, return the current date
return this.currentDate;
} else {
// For month view, if the user has selected a specific day, return that
return this.currentDate;
}
}
getViewStartDate(): Date {
if (this.viewType === 'month') {
// First day of the month
return new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
} else if (this.viewType === 'week') {
// Start of the week (Sunday)
const date = new Date(this.currentDate);
const dayOfWeek = date.getDay();
date.setDate(date.getDate() - dayOfWeek);
return date;
}
return this.currentDate;
}
getViewEndDate(): Date {
if (this.viewType === 'month') {
// Last day of the month
return new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0);
} else if (this.viewType === 'week') {
// End of the week (Saturday)
const date = new Date(this.currentDate);
const dayOfWeek = date.getDay();
date.setDate(date.getDate() + (6 - dayOfWeek));
return date;
}
return this.currentDate;
}
openTask(path: string) {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
this.app.workspace.getLeaf(false).openFile(file);
}
}
openNote(path: string) {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
this.app.workspace.getLeaf(false).openFile(file);
}
}
async navigateToDailyNote(date: Date) {
await this.plugin.navigateToDailyNote(date);
}
}

43
src/views/NotesView.ts Normal file
View file

@ -0,0 +1,43 @@
import { View, WorkspaceLeaf } from 'obsidian';
import ChronoSyncPlugin from '../main';
import { NOTES_VIEW_TYPE } from '../types';
export class NotesView extends View {
plugin: ChronoSyncPlugin;
constructor(leaf: WorkspaceLeaf, plugin: ChronoSyncPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return NOTES_VIEW_TYPE;
}
getDisplayText(): string {
return 'Notes';
}
getIcon(): string {
return 'file-text';
}
async onOpen() {
// Clear and prepare the content element
const contentEl = this.containerEl;
contentEl.empty();
// Add a container for our view content
const container = contentEl.createDiv({ cls: 'chronosync-container' });
// Create and add UI elements
container.createEl('h2', { text: 'Notes' });
// This will be implemented in more detail later
}
async onClose() {
// Clean up when the view is closed
this.containerEl.empty();
}
}

43
src/views/TaskListView.ts Normal file
View file

@ -0,0 +1,43 @@
import { View, WorkspaceLeaf } from 'obsidian';
import ChronoSyncPlugin from '../main';
import { TASK_LIST_VIEW_TYPE } from '../types';
export class TaskListView extends View {
plugin: ChronoSyncPlugin;
constructor(leaf: WorkspaceLeaf, plugin: ChronoSyncPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return TASK_LIST_VIEW_TYPE;
}
getDisplayText(): string {
return 'Tasks';
}
getIcon(): string {
return 'check-square';
}
async onOpen() {
// Clear and prepare the content element
const contentEl = this.containerEl;
contentEl.empty();
// Add a container for our view content
const container = contentEl.createDiv({ cls: 'chronosync-container' });
// Create and add UI elements
container.createEl('h2', { text: 'Tasks' });
// This will be implemented in more detail later
}
async onClose() {
// Clean up when the view is closed
this.containerEl.empty();
}
}

520
styles.css Normal file
View file

@ -0,0 +1,520 @@
/* ChronoSync Plugin Styles */
/* Main container for all views */
.chronosync-container {
padding: 1rem;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
/* Calendar Month View */
.calendar-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding: 0.5rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.calendar-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.current-month {
font-weight: bold;
min-width: 8rem;
text-align: center;
}
.view-switcher {
display: flex;
gap: 0.5rem;
}
.view-switcher button {
border-radius: 4px;
padding: 0.25rem 0.5rem;
}
.view-switcher button.active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.calendar-grid-container {
margin-bottom: 1rem;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
margin-bottom: 1rem;
}
.calendar-header {
display: contents;
}
.calendar-day-header {
text-align: center;
font-weight: bold;
padding: 0.5rem;
background-color: var(--background-secondary);
border-radius: 4px;
}
.calendar-day {
position: relative;
min-height: 2.5rem;
padding: 0.5rem;
text-align: center;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
.calendar-day:hover {
background-color: var(--background-modifier-hover);
}
.calendar-day.today {
background-color: var(--interactive-accent-hover);
font-weight: bold;
}
.calendar-day.selected,
.week-day-header.selected {
border: 2px solid var(--interactive-accent);
font-weight: bold;
}
/* Style for dates that are selected but not today */
.calendar-day.selected:not(.today),
.week-day-header.selected:not(.today) {
background-color: var(--background-modifier-form-field);
}
/* Style for dates that are both today and selected */
.calendar-day.today.selected,
.week-day-header.today.selected {
box-shadow: 0 0 0 2px var(--interactive-accent);
}
.calendar-day.outside-month {
color: var(--text-faint);
background-color: var(--background-primary-alt);
}
.day-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
margin: 2px;
}
.indicator-container {
display: flex;
justify-content: center;
gap: 2px;
margin-top: 2px;
}
.has-note {
background-color: var(--interactive-accent);
}
.has-task {
background-color: var(--text-error);
}
.is-important {
background-color: var(--text-warning);
}
/* Main Content Area with Tabs */
.chronosync-main-content {
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
overflow: hidden;
}
.chronosync-tabs {
display: flex;
background-color: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
}
.chronosync-tab {
padding: 0.5rem 1rem;
cursor: pointer;
background: none;
border: none;
font-size: 0.9rem;
color: var(--text-normal);
}
.chronosync-tab:hover {
background-color: var(--background-modifier-hover);
}
.chronosync-tab.active {
background-color: var(--background-primary);
border-bottom: 2px solid var(--interactive-accent);
font-weight: bold;
}
.chronosync-content-area {
padding: 1rem;
background-color: var(--background-primary);
min-height: 300px;
max-height: 500px;
overflow-y: auto;
}
/* Task Filters */
.task-filters {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group select {
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.add-task-button {
padding: 0.25rem 0.5rem;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-radius: 4px;
cursor: pointer;
}
/* Task List */
.task-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.task-item {
padding: 0.5rem;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.task-item:hover {
background-color: var(--background-modifier-hover);
}
.task-item-title {
font-weight: bold;
}
.task-item-metadata {
display: flex;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.8rem;
}
.task-priority-high {
color: var(--text-error);
}
.task-priority-normal {
color: var(--text-normal);
}
.task-priority-low {
color: var(--text-muted);
}
.task-status-open {
color: var(--text-error);
}
.task-status-in-progress {
color: var(--text-warning);
}
.task-status-done {
color: var(--text-success);
text-decoration: line-through;
}
.task-overdue {
color: var(--text-error);
font-weight: bold;
}
/* Task Creation Modal */
.task-creation-modal {
min-width: 500px;
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.form-group label {
margin-bottom: 0.25rem;
font-weight: bold;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 0.5rem;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
}
.checkbox-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.25rem;
}
.button-container {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.create-button {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
/* Notes List */
.notes-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.note-item {
padding: 0.5rem;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
cursor: pointer;
}
.note-item:hover {
background-color: var(--background-modifier-hover);
}
.note-item-title {
font-weight: bold;
}
.note-item-date {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.note-item-tags {
display: flex;
gap: 0.25rem;
margin-top: 0.25rem;
}
.note-tag {
font-size: 0.8rem;
padding: 0.1rem 0.3rem;
background-color: var(--background-secondary);
border-radius: 4px;
}
/* Timeblock Editor */
.timeblock-edit-input {
border: none;
background: transparent;
padding: 0.25rem;
font-family: inherit;
font-size: inherit;
color: inherit;
border-bottom: 1px dashed var(--interactive-accent);
}
.timeblock-editor {
width: 100%;
}
.timeblock-table {
width: 100%;
border-collapse: collapse;
}
.timeblock-table td,
.timeblock-table th {
border: 1px solid var(--background-modifier-border);
padding: 0.5rem;
}
.timeblock-time {
font-weight: bold;
width: 100px;
}
.timeblock-activity {
cursor: text;
}
.timeblock-activity:hover {
background-color: var(--background-modifier-hover);
}
.generate-timeblock-button, .create-note-button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-radius: 4px;
cursor: pointer;
}
/* Week View */
.week-view-container {
margin-bottom: 1rem;
overflow-y: auto;
max-height: 400px;
}
.week-grid {
display: flex;
flex-direction: column;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
}
.week-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
background-color: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
}
.week-day-header {
padding: 0.5rem;
text-align: center;
cursor: pointer;
border-right: 1px solid var(--background-modifier-border);
position: relative;
}
.week-day-header:last-child {
border-right: none;
}
.week-day-header.today {
background-color: var(--interactive-accent-hover);
}
.week-day-name {
font-weight: bold;
}
.week-day-date {
font-size: 0.8rem;
color: var(--text-muted);
}
.week-time-container {
display: flex;
flex-direction: column;
}
.week-time-row {
display: flex;
border-bottom: 1px solid var(--background-modifier-border);
}
.week-time-row:last-child {
border-bottom: none;
}
.week-time-label {
padding: 0.25rem 0.5rem;
width: 60px;
font-size: 0.8rem;
color: var(--text-muted);
font-weight: bold;
text-align: right;
border-right: 1px solid var(--background-modifier-border);
}
.week-time-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
flex-grow: 1;
}
.week-time-cell {
padding: 0.25rem;
border-right: 1px solid var(--background-modifier-border);
min-height: 2rem;
cursor: pointer;
}
.week-time-cell:last-child {
border-right: none;
}
.week-time-cell:hover {
background-color: var(--background-modifier-hover);
}
.week-time-cell.current-time {
background-color: rgba(var(--interactive-accent-rgb), 0.1);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.calendar-grid {
gap: 0.1rem;
}
.calendar-day {
min-height: 1.5rem;
padding: 0.25rem;
font-size: 0.8rem;
}
.task-creation-modal {
min-width: 300px;
}
}

24
tsconfig.json Normal file
View 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"
]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}