From cd902285d24ad792a552ea256958805426cfa691 Mon Sep 17 00:00:00 2001
From: frankthwang <461694678@qq.com>
Date: Sun, 13 Apr 2025 23:14:24 +0800
Subject: [PATCH] first commit
---
.gitignore | 1 +
LICENSE | 21 +++++
README.md | 31 +++++++
main.js | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++
manifest.json | 10 +++
styles.css | 26 ++++++
6 files changed, 320 insertions(+)
create mode 100644 .gitignore
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 main.js
create mode 100644 manifest.json
create mode 100644 styles.css
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c96066d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/.DS_Store
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..95defcc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 frankthwang
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9ea7684
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# 文本块计时器插件
+
+一个为Obsidian设计的文本块计时工具,可以在笔记中为任意文本行添加计时功能。
+
+## 功能特性
+
+- 为文本行添加计时器
+- ▶️/⏸️ 支持开始、暂停、继续计时,支持一个任务的多段计时累计
+- ⏳ 实时显示累计时间
+- 📊 计时数据持久化保存
+
+## 使用方法
+
+1. 在文本行上右键点击
+2. 选择"开始计时"、"暂停计时"或"继续计时"
+3. 计时器会以 `⏳00:00:00`格式显示在文本块的末尾
+
+## 安装方法
+
+1. 在Obsidian中打开"设置" → "社区插件"
+2. 点击"浏览"并搜索"文本块计时器"
+3. 安装并启用插件
+
+## 注意事项
+
+- 建议保持文本块所在文件状打开,否则会导致计时器的实时跳秒失效。但文件会保留计时器状态,下次打开文件时可以手动恢复计时。
+
+## 开发信息
+
+- 开发者: frankthwang
+- 版本: 1.0.0
diff --git a/main.js b/main.js
new file mode 100644
index 0000000..6aa2c9a
--- /dev/null
+++ b/main.js
@@ -0,0 +1,231 @@
+'use strict';
+
+var obsidian = require('obsidian');
+
+class TimerPlugin extends obsidian.Plugin {
+
+ async onload() {
+ // console.log('Loading Timer Plugin');
+
+ // 用于存储所有计时器的映射
+ this.timers = new Map();
+
+ this.registerEvent(
+ this.app.workspace.on('editor-menu', (menu, editor, view) => { // 监听编辑器菜单事件
+ const cursor = editor.getCursor();
+ const lineId = cursor.line;
+ const lineText = editor.getLine(lineId);
+
+ // 使用正则表达式匹配当前行的内容,内容格式为 `${timerData.statusicon} ⏳${formattedTime}`
+ const regex = /]+)>([^<]+)<\/span>/;
+
+ // const regex = /([^<]+)<\/span>/;
+ const timerMatch = lineText.match(regex);
+
+ // 提示解析结果
+ if (timerMatch) {
+ const timerData = {
+ timerId: timerMatch[1],
+ Status: timerMatch[2],
+ AccumulatedTime: timerMatch[3],
+ currentStartTimeStamp: timerMatch[4],
+ };
+ // new obsidian.Notice(`Timer Data: ID=${timerData.timerId}, Status=${timerData.Status}, Accumulated Time=${timerData.AccumulatedTime} seconds, Start Time=${timerData.currentStartTimeStamp}`);
+ } else {
+ // new obsidian.Notice('No timer data found in this line');
+ }
+
+ // 获取timerMatch的状态
+ const timerStatus = timerMatch ? timerMatch[2] : null;
+
+ // 如果timer在计时中,暂停计时按钮
+ if (timerStatus === 'Running') {
+ menu.addItem((item) => {
+ item
+ .setTitle('暂停计时')
+ .setIcon('pause')
+ .onClick(() => {
+ // new obsidian.Notice('暂停计时');
+ this.stopTimer(timerMatch[1], editor, cursor.line);
+ });
+ });
+ } // 如果timer在暂停中,继续计时按钮
+ else if (timerStatus === 'Paused') {
+ menu.addItem((item) => {
+ item
+ .setTitle('继续计时')
+ .setIcon('play')
+ .onClick(() => {
+ // new obsidian.Notice('继续计时');
+ this.startTimer(editor, cursor.line, timerMatch[1]);
+ });
+ });
+ } // 否则,开始/继续计时
+ else {
+ menu.addItem((item) => {
+ item
+ .setTitle('开始计时')
+ .setIcon('play')
+ .onClick(() => {
+ // new obsidian.Notice('开始计时');
+ this.initTimer(editor, lineId);
+ // this.startTimer(editor, lineId, Date.now().toString());
+ });
+ });
+ }
+
+ })
+ );
+ }
+
+ // 开始计时器
+ initTimer(editor, lineId) {
+
+ // 更新计时器状态
+ this.updateTimerInfo(editor, 'init', lineId);
+
+ // // 更新计时器状态
+ // this.updateTimerInfo(editor, 'continue', lineId);
+
+ // // 创建一个定时器
+ // const intervalId = setInterval(() => {
+ // this.updateTimerInfo(editor, 'update', lineId, timerId);
+ // }, 1000);
+
+ // // 将定时器存储到映射中
+ // this.timers.set(timerId, intervalId);
+ }
+
+ // 开始计时器
+ startTimer(editor, lineId, timerId) {
+ // 更新计时器状态
+ this.updateTimerInfo(editor, 'continue', lineId);
+
+ // 创建一个定时器
+ const intervalId = setInterval(() => {
+ this.updateTimerInfo(editor, 'update', lineId, timerId);
+ }, 1000);
+
+ // 将定时器存储到映射中
+ this.timers.set(timerId, intervalId);
+ }
+
+ // 停止计时器
+ stopTimer(timerId, editor, lineId) {
+ if (this.timers.has(timerId)) {
+ // 获取定时器ID并清除定时器
+ const intervalId = this.timers.get(timerId);
+
+ clearInterval(intervalId);
+
+ // 从映射中移除定时器
+ this.timers.delete(timerId);
+ }
+
+ // 更新计时器状态
+ this.updateTimerInfo(editor, 'pause', lineId);
+
+
+ }
+
+ // 更新计时器信息
+ updateTimerInfo(editor, action, lineId, timerId = null) {
+ const cursor = editor.getCursor();
+ let regex = /([^<]+)<\/span>/;
+ let lineText = editor.getLine(lineId);
+ let timerMatch = lineText.match(regex);
+ let timerInfoStart = timerMatch ? timerMatch.index : lineText.length;
+ let timerInfoEnd = timerInfoStart + (timerMatch ? timerMatch[0].length : 0);
+
+ // 将timerData定义在if语句外部
+ let timerData;
+ if (action === 'init') {
+ timerData = {
+ timerId: Date.now().toString(),
+ lineId: cursor.line,
+ Status: 'Paused',
+ AccumulatedTime: 0,
+ currentStartTimeStamp: null,
+ statusicon: '▶️',
+ }
+ } else if (action === 'continue') {
+ timerData = {
+ // 如果没有timerId,则使用当前时间戳作为ID
+ timerId: timerMatch ? timerMatch[1] : Date.now().toString(),
+ lineId: cursor.line,
+ Status: 'Running',
+ // 如果没有,就用0作为AccumulatedTime
+ AccumulatedTime: timerMatch ? parseInt(timerMatch[3], 10) : 0,
+ currentStartTimeStamp: Math.floor(Date.now() / 1000),
+ statusicon: '⏸️',
+ };
+ } else if (action === 'pause') {
+ timerData = {
+ timerId: timerMatch[1],
+ lineId: cursor.line,
+ Status: 'Paused',
+ AccumulatedTime: parseInt(timerMatch[3], 10) + (Math.floor(Date.now() / 1000) - parseInt(timerMatch[4], 10)),
+ currentStartTimeStamp: null,
+ statusicon: '▶️',
+ };
+ } else if (action === 'update') {
+
+ // 用LineText.includes检查匹配到的文本中是否有timerId=timerId
+ if (!lineText.includes(`timerId="${timerId}"`)) {
+ // console.log(`!lineText.includes(timerId=${timerId})`);
+ // 如果没有匹配到,则在全文本中查找timerId,返回找到timerId=timerId的第一行
+ // console.log('start to find timerId in all lines,looking for timerId:', timerId);
+ // console.log('editor:', editor);
+ let allLines = editor.getValue().split('\n');
+ // console.log('allLines:', allLines)
+ let lineIndex = allLines.findIndex(line => line.includes(`timerId="${timerId}"`));
+ // console.log('filtered lineIndex:', lineIndex);
+ if (lineIndex !== -1) {
+ lineId = lineIndex;
+ lineText = allLines[lineIndex];
+ } else {
+ // 销毁定时器
+ if (this.timers.has(timerId)) {
+ const intervalId = this.timers.get(timerId);
+ clearInterval(intervalId);
+ this.timers.delete(timerId);
+ // console.log('Timer with ID', timerId, 'destroyed');
+ }
+ }
+ }
+ // console.log('lineId:', lineId);
+ // console.log('lineText:', lineText);
+ timerMatch = lineText.match(regex);
+ timerInfoStart = timerMatch ? timerMatch.index : lineText.length;
+ timerInfoEnd = timerInfoStart + (timerMatch ? timerMatch[0].length : 0);
+
+ timerData = {
+ timerId: timerMatch[1],
+ lineId: lineId,
+ Status: 'Running',
+ AccumulatedTime: parseInt(timerMatch[3], 10) + (Math.floor(Date.now() / 1000) - parseInt(timerMatch[4], 10)),
+ currentStartTimeStamp: Math.floor(Date.now() / 1000),
+ statusicon: '⏸️',
+ };
+ } else {
+ throw new Error('Invalid action');
+ }
+
+ const formattedTime = new Date(timerData.AccumulatedTime * 1000).toISOString().substr(11, 8);
+ const colorStyle = timerData.Status === 'Running' ? 'style="color: #10b981;"' : '';
+ const updatedTimerInfo = `${timerData.statusicon} ⏳${formattedTime}`;
+
+ editor.replaceRange(updatedTimerInfo, { line: lineId, ch: timerInfoStart }, { line: lineId, ch: timerInfoEnd });
+ }
+
+ onunload() {
+ // console.log('Unloading Timer Plugin');
+
+ // 清除所有计时器
+ for (const intervalId of this.timers.values()) {
+ clearInterval(intervalId);
+ }
+ }
+}
+
+module.exports = TimerPlugin;
\ No newline at end of file
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..9fadb6e
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,10 @@
+{
+ "id": "text-block-timer",
+ "version": "1.0.0",
+ "minAppVersion": "1.8.9",
+ "name": "Text Block Timer",
+ "author": "frankthwang",
+ "description": "Add a timer to text block to track task time consumption.",
+ "repo": "wth461694678/text-block-timer",
+ "isDesktopOnly": true
+}
\ No newline at end of file
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..aa384b4
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,26 @@
+.timer-container {
+ display: inline-flex;
+ align-items: center;
+ margin-left: 10px;
+}
+
+.timer-button {
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 8px 0 8px 12px;
+ border-color: transparent transparent transparent var(--text-normal);
+ cursor: pointer;
+ margin-right: 5px;
+}
+
+.timer-button.paused {
+ border-style: double;
+ border-width: 0 0 0 12px;
+ border-color: transparent transparent transparent var(--text-normal);
+}
+
+.timer-display {
+ font-size: 0.9em;
+ color: var(--text-muted);
+}
\ No newline at end of file