feat: Implement Pomodoro plugin core logic, UI, settings, and daily note logging

This commit is contained in:
openhands 2025-06-22 05:43:01 +00:00
parent 6d09ce3e39
commit ccb2ec8439
8 changed files with 2987 additions and 140 deletions

View File

@ -15,7 +15,7 @@ const context = await esbuild.context({
banner: { banner: {
js: banner, js: banner,
}, },
entryPoints: ["main.ts"], entryPoints: ["src/main.ts"],
bundle: true, bundle: true,
external: [ external: [
"obsidian", "obsidian",

134
main.ts
View File

@ -1,134 +0,0 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
}
}

2451
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -20,5 +20,9 @@
"obsidian": "latest", "obsidian": "latest",
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "4.7.4" "typescript": "4.7.4"
},
"dependencies": {
"moment": "^2.30.1",
"obsidian-daily-notes-interface": "^0.9.4"
} }
} }

294
src/main.ts Normal file
View File

@ -0,0 +1,294 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, WorkspaceLeaf } from 'obsidian';
import { PomodoroView, POMODORO_VIEW_TYPE } from './pomodoroView';
import { PomodoroTimer } from './timer';
import { createDailyNote, getDailyNote, getAllDailyNotes } from 'obsidian-daily-notes-interface';
import moment from 'moment';
// Remember to rename these classes and interfaces!
interface PomodoroPluginSettings {
pomodoroDuration: number;
shortBreakDuration: number;
longBreakDuration: number;
cyclesBeforeLongBreak: number;
autoStartNextSession: boolean;
enableNotifications: boolean;
enableSound: boolean;
}
const DEFAULT_SETTINGS: PomodoroPluginSettings = {
pomodoroDuration: 25, // minutes
shortBreakDuration: 5, // minutes
longBreakDuration: 15, // minutes
cyclesBeforeLongBreak: 4,
autoStartNextSession: false,
enableNotifications: true,
enableSound: false,
}
export default class PomodoroPlugin extends Plugin {
settings: PomodoroPluginSettings;
timer: PomodoroTimer;
statusBarItemEl: HTMLElement;
async onload() {
await this.loadSettings();
this.timer = new PomodoroTimer(
this.settings.pomodoroDuration * 60,
this.settings.shortBreakDuration * 60,
this.settings.longBreakDuration * 60,
this.settings.cyclesBeforeLongBreak
);
this.statusBarItemEl = this.addStatusBarItem();
this.updateStatusBar();
this.addRibbonIcon('timer', 'Open Pomodoro Timer', () => {
this.activateView();
});
this.registerView(
POMODORO_VIEW_TYPE,
(leaf) => new PomodoroView(leaf, this)
);
this.addCommand({
id: 'pomodoro-start',
name: 'Start Pomodoro',
callback: () => this.startTimer(),
});
this.addCommand({
id: 'pomodoro-pause',
name: 'Pause Pomodoro',
callback: () => this.pauseTimer(),
});
this.addCommand({
id: 'pomodoro-reset',
name: 'Reset Pomodoro',
callback: () => this.resetTimer(),
});
this.addCommand({
id: 'pomodoro-skip',
name: 'Skip Session',
callback: () => this.skipTimer(),
});
this.addSettingTab(new PomodoroSettingTab(this.app, this));
}
onunload() {
this.timer.pause();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
async activateView() {
this.app.workspace.detachLeavesOfType(POMODORO_VIEW_TYPE);
await this.app.workspace.getRightLeaf(false).setViewState({
type: POMODORO_VIEW_TYPE,
active: true,
});
this.app.workspace.revealLeaf(
this.app.workspace.getLeavesOfType(POMODORO_VIEW_TYPE)[0]
);
}
updateStatusBar() {
const remainingTime = this.timer.getRemainingTime();
const minutes = Math.floor(remainingTime / 60);
const seconds = remainingTime % 60;
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
const statusText = `🍅 ${timeString}`;
this.statusBarItemEl.setText(statusText);
}
startTimer() {
this.timer.start(
(remainingTime, isPomodoro) => {
this.updateStatusBar();
// The PomodoroView will update itself via its registered interval
},
() => {
new Notice(this.timer.getIsPomodoro() ? 'Pomodoro Complete!' : 'Break Complete!');
this.logSession();
if (this.settings.autoStartNextSession) {
this.startTimer();
}
}
);
}
pauseTimer() {
this.timer.pause();
this.updateStatusBar();
}
resetTimer() {
this.timer.reset();
this.updateStatusBar();
}
skipTimer() {
this.timer.skip();
this.updateStatusBar();
if (this.settings.autoStartNextSession) {
this.startTimer();
}
}
async logSession() {
const now = moment();
const dailyNote = getDailyNote(now, getAllDailyNotes());
let fileContent = '';
if (dailyNote) {
fileContent = await this.app.vault.read(dailyNote);
} else {
const newDailyNote = await createDailyNote(now);
fileContent = await this.app.vault.read(newDailyNote);
}
const sessionType = this.timer.getIsPomodoro() ? 'Break' : 'Pomodoro'; // Log the *completed* session type
const duration = this.timer.getIsPomodoro() ? this.settings.pomodoroDuration : (this.timer.currentCycle % this.settings.cyclesBeforeLongBreak === 0 ? this.settings.longBreakDuration : this.settings.shortBreakDuration);
const activeNote = this.app.workspace.getActiveViewOfType(MarkdownView)?.file?.basename || 'No active note';
const logEntry = `- [x] ${now.format('HH:mm')} - ${sessionType} (${duration}分) - [[${activeNote}]]`;
if (dailyNote) {
await this.app.vault.append(dailyNote, `\n${logEntry}`);
} else {
const newDailyNote = await createDailyNote(now);
await this.app.vault.append(newDailyNote, `\n${logEntry}`);
}
}
}
class PomodoroSettingTab extends PluginSettingTab {
plugin: PomodoroPlugin;
constructor(app: App, plugin: PomodoroPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Pomodoro Duration')
.setDesc('Duration of a pomodoro session in minutes.')
.addText(text => text
.setPlaceholder('25')
.setValue(this.plugin.settings.pomodoroDuration.toString())
.onChange(async (value) => {
this.plugin.settings.pomodoroDuration = parseInt(value);
await this.plugin.saveSettings();
this.plugin.timer = new PomodoroTimer(
this.plugin.settings.pomodoroDuration * 60,
this.plugin.settings.shortBreakDuration * 60,
this.plugin.settings.longBreakDuration * 60,
this.plugin.settings.cyclesBeforeLongBreak
);
this.plugin.resetTimer();
}));
new Setting(containerEl)
.setName('Short Break Duration')
.setDesc('Duration of a short break in minutes.')
.addText(text => text
.setPlaceholder('5')
.setValue(this.plugin.settings.shortBreakDuration.toString())
.onChange(async (value) => {
this.plugin.settings.shortBreakDuration = parseInt(value);
await this.plugin.saveSettings();
this.plugin.timer = new PomodoroTimer(
this.plugin.settings.pomodoroDuration * 60,
this.plugin.settings.shortBreakDuration * 60,
this.plugin.settings.longBreakDuration * 60,
this.plugin.settings.cyclesBeforeLongBreak
);
this.plugin.resetTimer();
}));
new Setting(containerEl)
.setName('Long Break Duration')
.setDesc('Duration of a long break in minutes.')
.addText(text => text
.setPlaceholder('15')
.setValue(this.plugin.settings.longBreakDuration.toString())
.onChange(async (value) => {
this.plugin.settings.longBreakDuration = parseInt(value);
await this.plugin.saveSettings();
this.plugin.timer = new PomodoroTimer(
this.plugin.settings.pomodoroDuration * 60,
this.plugin.settings.shortBreakDuration * 60,
this.plugin.settings.longBreakDuration * 60,
this.plugin.settings.cyclesBeforeLongBreak
);
this.plugin.resetTimer();
}));
new Setting(containerEl)
.setName('Cycles Before Long Break')
.setDesc('Number of pomodoro sessions before a long break.')
.addText(text => text
.setPlaceholder('4')
.setValue(this.plugin.settings.cyclesBeforeLongBreak.toString())
.onChange(async (value) => {
this.plugin.settings.cyclesBeforeLongBreak = parseInt(value);
await this.plugin.saveSettings();
this.plugin.timer = new PomodoroTimer(
this.plugin.settings.pomodoroDuration * 60,
this.plugin.settings.shortBreakDuration * 60,
this.plugin.settings.longBreakDuration * 60,
this.plugin.settings.cyclesBeforeLongBreak
);
this.plugin.resetTimer();
}));
new Setting(containerEl)
.setName('Auto Start Next Session')
.setDesc('Automatically start the next pomodoro or break session after the current one ends.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoStartNextSession)
.onChange(async (value) => {
this.plugin.settings.autoStartNextSession = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Enable Notifications')
.setDesc('Display OS native notifications when a session ends.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableNotifications)
.onChange(async (value) => {
this.plugin.settings.enableNotifications = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Enable Sound')
.setDesc('Play a sound when a session ends.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableSound)
.onChange(async (value) => {
this.plugin.settings.enableSound = value;
await this.plugin.saveSettings();
}));
}
}

86
src/pomodoroView.ts Normal file
View File

@ -0,0 +1,86 @@
import { ItemView, WorkspaceLeaf, setIcon } from 'obsidian';
import { PomodoroTimer } from './timer';
import PomodoroPlugin from './main';
export const POMODORO_VIEW_TYPE = 'pomodoro-view';
export class PomodoroView extends ItemView {
plugin: PomodoroPlugin;
timer: PomodoroTimer;
private timerDisplay: HTMLElement;
private startPauseButton: HTMLElement;
private resetButton: HTMLElement;
private skipButton: HTMLElement;
constructor(leaf: WorkspaceLeaf, plugin: PomodoroPlugin) {
super(leaf);
this.plugin = plugin;
this.timer = plugin.timer;
}
getViewType(): string {
return POMODORO_VIEW_TYPE;
}
getDisplayText(): string {
return 'Pomodoro Timer';
}
getIcon(): string {
return 'timer';
}
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass('pomodoro-view-container');
// Timer Display
this.timerDisplay = container.createEl('div', { cls: 'pomodoro-timer-display' });
this.timerDisplay.setText('25:00'); // Initial display
// Control Buttons
const controls = container.createEl('div', { cls: 'pomodoro-controls' });
this.startPauseButton = controls.createEl('button', { cls: 'pomodoro-button' });
setIcon(this.startPauseButton, 'play');
this.startPauseButton.onclick = () => this.toggleTimer();
this.resetButton = controls.createEl('button', { cls: 'pomodoro-button' });
setIcon(this.resetButton, 'rotate-ccw');
this.resetButton.onclick = () => this.plugin.resetTimer();
this.skipButton = controls.createEl('button', { cls: 'pomodoro-button' });
setIcon(this.skipButton, 'skip-forward');
this.skipButton.onclick = () => this.plugin.skipTimer();
this.updateUI();
this.plugin.registerInterval(window.setInterval(() => this.updateUI(), 1000));
}
async onClose() {
// Nothing to clean up.
}
updateUI() {
const remainingTime = this.timer.getRemainingTime();
const minutes = Math.floor(remainingTime / 60);
const seconds = remainingTime % 60;
this.timerDisplay.setText(`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
if (this.timer.getIsRunning()) {
setIcon(this.startPauseButton, 'pause');
} else {
setIcon(this.startPauseButton, 'play');
}
}
toggleTimer() {
if (this.timer.getIsRunning()) {
this.plugin.pauseTimer();
} else {
this.plugin.startTimer();
}
}
}

98
src/timer.ts Normal file
View File

@ -0,0 +1,98 @@
export class PomodoroTimer {
private pomodoroDuration: number; // in seconds
private shortBreakDuration: number; // in seconds
private longBreakDuration: number; // in seconds
private cyclesBeforeLongBreak: number;
private timerId: NodeJS.Timeout | null = null;
private remainingTime: number; // in seconds
private isRunning: boolean = false;
private currentCycle: number = 0;
private isPomodoro: boolean = true; // true for pomodoro, false for break
constructor(
pomodoroDuration: number = 25 * 60,
shortBreakDuration: number = 5 * 60,
longBreakDuration: number = 15 * 60,
cyclesBeforeLongBreak: number = 4
) {
this.pomodoroDuration = pomodoroDuration;
this.shortBreakDuration = shortBreakDuration;
this.longBreakDuration = longBreakDuration;
this.cyclesBeforeLongBreak = cyclesBeforeLongBreak;
this.remainingTime = this.pomodoroDuration;
}
public start(onTick: (remainingTime: number, isPomodoro: boolean) => void, onComplete: () => void) {
if (this.isRunning) {
return;
}
this.isRunning = true;
this.timerId = setInterval(() => {
this.remainingTime--;
onTick(this.remainingTime, this.isPomodoro);
if (this.remainingTime <= 0) {
this.stop();
onComplete();
this.nextCycle();
}
}, 1000);
}
public pause() {
if (this.timerId) {
clearInterval(this.timerId);
this.timerId = null;
}
this.isRunning = false;
}
public reset() {
this.pause();
this.currentCycle = 0;
this.isPomodoro = true;
this.remainingTime = this.pomodoroDuration;
}
public skip() {
this.pause();
this.nextCycle();
}
private stop() {
if (this.timerId) {
clearInterval(this.timerId);
this.timerId = null;
}
this.isRunning = false;
}
private nextCycle() {
if (this.isPomodoro) {
this.currentCycle++;
if (this.currentCycle % this.cyclesBeforeLongBreak === 0) {
this.isPomodoro = false;
this.remainingTime = this.longBreakDuration;
} else {
this.isPomodoro = false;
this.remainingTime = this.shortBreakDuration;
}
} else {
this.isPomodoro = true;
this.remainingTime = this.pomodoroDuration;
}
}
public getRemainingTime(): number {
return this.remainingTime;
}
public getIsRunning(): boolean {
return this.isRunning;
}
public getIsPomodoro(): boolean {
return this.isPomodoro;
}
}

View File

@ -1,8 +1,56 @@
/*
This CSS file will be included with your plugin, and /* Pomodoro View Container */
available in the app when your plugin is enabled. .pomodoro-view-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 20px;
box-sizing: border-box;
background-color: var(--background-primary);
}
If your plugin does not need CSS, delete this file. /* Timer Display */
.pomodoro-timer-display {
font-size: 4em;
font-weight: bold;
color: var(--text-normal);
margin-bottom: 30px;
font-variant-numeric: tabular-nums; /* Ensures monospaced numbers */
}
*/ /* Control Buttons */
.pomodoro-controls {
display: flex;
gap: 15px;
}
.pomodoro-button {
background-color: var(--background-modifier-form-field);
border: 1px solid var(--background-modifier-border);
color: var(--text-normal);
border-radius: var(--button-radius);
padding: 10px 15px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
transition: background-color 0.2s ease;
}
.pomodoro-button:hover {
background-color: var(--background-modifier-hover);
}
.pomodoro-button svg {
width: 24px;
height: 24px;
stroke-width: 2;
}
/* Settings Page Styling (if needed, though Obsidian handles most of it) */
.setting-item-control input[type="number"] {
width: 80px; /* Adjust as needed for number inputs */
}