diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
index 9f7e358..356f384 100644
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -13,8 +13,8 @@ permissions:
pull-requests: write
env:
- PACKAGE_NAME: custom-fonts
- PACKAGE_VERSION: 0.1.2
+ PACKAGE_NAME: wakatime
+ PACKAGE_VERSION: 0.2.0
jobs:
release-please:
diff --git a/package.json b/package.json
index 19d098b..a74648c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "wakatime",
"private": true,
- "version": "0.1.2",
+ "version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -17,7 +17,7 @@
"svelte": "^4.2.0",
"svelte-check": "^3.5.0",
"svelte-preprocess-less": "^0.4.0",
- "tslib": "^2.6.1",
+ "tslib": "^2.6.2",
"typescript": "^5.1.6",
"vite": "^4.4.9"
},
diff --git a/public/README.md b/public/README.md
index 63f0d26..c2530f5 100644
--- a/public/README.md
+++ b/public/README.md
@@ -37,6 +37,14 @@ A plugin for [SiYuan Note](https://github.com/siyuan-note/siyuan) that can use [
* `Wakapi/WakaTime > Service Settings > Hide Notebook Name`
* `Wakapi/WakaTime > Service Settings > Hide Document Title`
+ * Customize the inclusion list to set a whitelist
+
+ * `Wakapi/WakaTime > Service Settings > ID Inclusion List`
+ * `Wakapi/WakaTime > Service Settings > Inclusion List`
+ * Customize the exclusion list to set a blacklist
+
+ * `Wakapi/WakaTime > Service Settings > ID Exclusion List`
+ * `Wakapi/WakaTime > Service Settings > Exclusion List`
## INTRODUCTION
@@ -56,6 +64,13 @@ A plugin for [SiYuan Note](https://github.com/siyuan-note/siyuan) that can use [
* After clicking this button, a confirmation dialog will appear.
* After clicking the confirmation button in the dialog, all options for this plugin will be reset to their default values, and the current interface will be automatically refreshed.
+ * `Clean offline cache`
+
+ * Delete all offline cache files
+ * Cache file directory: `workspace/temp/.wakatime/cache`
+ * After clicking this button, a confirmation dialog box will appear
+
+ * After clicking the confirm button on the dialog box, the cache file directory will be deleted and the current interface will be automatically refreshed.
* `Wakapi/WakaTime`: Configure the functionality and service of `Wakapi` or `WakaTime`
* `General`
@@ -158,7 +173,7 @@ A plugin for [SiYuan Note](https://github.com/siyuan-note/siyuan) that can use [
* Unit: seconds
* `Hide Notebook Name`
- * Whether to hide the notebook name in the submitted activity information.
+ * Whether to hide the notebook name in the submitted action information.
* Corresponds to the `hide_branch_names` configuration option in the `.wakatime.cfg` configuration file of `Wakapi/WakaTime`.
* If enabled, all Notebook Name in the submitted information will be replaced with Notebook ID.
* `Hide Document Title`
@@ -166,6 +181,15 @@ A plugin for [SiYuan Note](https://github.com/siyuan-note/siyuan) that can use [
* Whether to hide the document title in the submitted activity information.
* Corresponds to the `hide_file_names` configuration option in the `.wakatime.cfg` configuration file of `Wakapi/WakaTime`.
* If enabled, all Document Title in the submitted information will be replaced with Block ID.
+ * `Offline caching`
+
+ * Whether to cache action information in local files
+ * Conditions for triggering offline caching
+
+ * Disable `Wakapi/WakaTime > General > Heartbeat Connections`
+ * Or unable to access `Wakapi/WakaTime` service
+ * Cache file directory: `workspace/temp/.wakatime/cache`
+ * Corresponds to the `offline` configuration option in the `.wakatime.cfg` configuration file of `Wakapi/WakaTime`.
* `ID Include List`
* Only submit documents whose ID path includes the list field.
diff --git a/public/README_zh_CN.md b/public/README_zh_CN.md
index ab81ef3..40825b1 100644
--- a/public/README_zh_CN.md
+++ b/public/README_zh_CN.md
@@ -37,6 +37,14 @@
* `Wakapi/WakaTime > 服务设置 > 隐藏笔记本名称`
* `Wakapi/WakaTime > 服务设置 > 隐藏文档标题`
+ * 自定义包含列表以设置白名单
+
+ * `Wakapi/WakaTime > 服务设置 > ID 包含列表`
+ * `Wakapi/WakaTime > 服务设置 > 包含列表`
+ * 自定义排除列表以设置黑名单
+
+ * `Wakapi/WakaTime > 服务设置 > ID 排除列表`
+ * `Wakapi/WakaTime > 服务设置 > 排除列表`
## 介绍
@@ -56,6 +64,13 @@
* 点击该按钮后会弹出确认对话框
* 点击对话框确认按钮后会重置本插件所有选项为默认选项, 之后会自动刷新当前界面
+ * `清理离线缓存`
+
+ * 删除所有的离线缓存文件
+ * 缓存文件目录: `工作空间/temp/.wakatime/cache`
+ * 点击该按钮后会弹出确认对话框
+
+ * 点击对话框确认按钮后会删除缓存文件目录, 之后会自动刷新当前界面
* `Wakapi/WakaTime`: 配置 `Wakapi` 或 `WakaTime` 功能与服务
* `常规设置`
@@ -166,6 +181,15 @@
* 在提交的操作活动信息中是否隐藏文档标题
* 对应 `Wakapi/WakaTime` 配置文件 `.wakatime.cfg` 中的 `hide_file_names` 配置项
* 若开启, 则提交的信息中所有 文档标题 都替换为 文档块 ID
+ * `离线缓存`
+
+ * 是否将活动信息缓存在本地文件中
+ * 触发离线缓存的条件
+
+ * 关闭 `Wakapi/WakaTime > 常规设置 > 心跳连接`
+ * 或无法访问 `Wakapi/WakaTime` 服务
+ * 缓存文件目录: `工作空间/temp/.wakatime/cache`
+ * 对应 `Wakapi/WakaTime` 配置文件 `.wakatime.cfg` 中的 `offline` 配置项
* `ID 包含列表`
* 仅提交 ID 路径中包含列表字段的文档
@@ -245,7 +269,7 @@
```javascript
path.includes("请从这里开始") && /^思源笔记用户指南\/请从这里开始/.test(path)
```
- * 排除列表
+ * `排除列表`
* 提交时排除路径中包含列表字段的文档
* 对应 `Wakapi/WakaTime` 配置文件 `.wakatime.cfg` 中的 `exclude` 配置项
diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json
index 1e3dcdf..229b18d 100644
--- a/public/i18n/en_US.json
+++ b/public/i18n/en_US.json
@@ -2,6 +2,11 @@
"displayName": "WakaTime",
"settings": {
"generalSettings": {
+ "cleanCache": {
+ "description": "Delete all offline cache files (page refresh after deletion)",
+ "text": "Clean",
+ "title": "Clean offline cache"
+ },
"reset": {
"description": "Reset all settings options to default options (refresh page after reset)",
"text": "Reset",
@@ -12,15 +17,15 @@
"wakatimeSettings": {
"generalTab": {
"editCategory": {
- "description": "Activity type tags for editing operations",
+ "description": "Action type tags for editing operations",
"title": "Edit operation tags"
},
"heartbeats": {
- "description": "Enable to submit activity information to Wakapi/WakaTime service while viewing/editing documents",
+ "description": "Enable to submit action information to Wakapi/WakaTime service while viewing/editing documents",
"title": "Heartbeat connection"
},
"interval": {
- "description": "Time interval (in seconds) to push editing activities to Wakapi/WakaTime server",
+ "description": "Time interval (in seconds) to push editing action information to Wakapi/WakaTime server",
"title": "Push interval"
},
"language": {
@@ -33,7 +38,7 @@
},
"title": "General",
"viewCategory": {
- "description": "Activity type tags for viewing operations",
+ "description": "Action type tags for viewing operations",
"title": "View operation tags"
}
},
@@ -47,12 +52,12 @@
"title": "API URL"
},
"exclude": {
- "description": "Exclude documents with fields listed in the submission path.
Each entry should be on a separate line. Use / to enclose regular expressions.
Corresponds to the exclude option in the Wakapi/WakaTime configuration.",
+ "description": "Exclude documents with fields listed in the submission path.
Each entry should be on a separate line. Use /
to enclose regular expressions.
Corresponds to the exclude
option in the Wakapi/WakaTime configuration.",
"placeholder": "Exclude no notebooks or documents",
"title": "Exclude List"
},
"excludeID": {
- "description": "Exclude documents with fields listed in the submission ID path.
Each entry should be on a separate line. Use / to enclose regular expressions.",
+ "description": "Exclude documents with fields listed in the submission ID path.
Each entry should be on a separate line. Use /
to enclose regular expressions.",
"placeholder": "Exclude no notebooks or documents",
"title": "ID Exclude List"
},
@@ -69,15 +74,19 @@
"title": "Hostname"
},
"include": {
- "description": "Only include documents with fields listed in the submission path.
Each entry should be on a separate line. Use / to enclose regular expressions.
Corresponds to the include option in the Wakapi/WakaTime configuration.",
+ "description": "Only include documents with fields listed in the submission path.
Each entry should be on a separate line. Use /
to enclose regular expressions.
Corresponds to the include
option in the Wakapi/WakaTime configuration.",
"placeholder": "Include all notebooks and documents",
"title": "Include List"
},
"includeID": {
- "description": "Only include documents with fields listed in the submission ID path.
Each entry should be on a separate line. Use / to enclose regular expressions.",
+ "description": "Only include documents with fields listed in the submission ID path.
Each entry should be on a separate line. Use /
to enclose regular expressions.",
"placeholder": "Include all notebooks and documents",
"title": "ID Include List"
},
+ "offline": {
+ "description": "Whether to enable offline caching
When the Wakapi/WakaTime service accesses abnormally or the heartbeat connection is not enabled, the action information will be cached to the ${1} directory
Corresponds to the offline
option in the Wakapi/WakaTime configuration.",
+ "title": "Offline caching"
+ },
"test": {
"description": "Test if connected to Wakapi/WakaTime service correctly",
"messages": {
diff --git a/public/i18n/zh_CHT.json b/public/i18n/zh_CHT.json
index 05ef6f8..8a46a4e 100644
--- a/public/i18n/zh_CHT.json
+++ b/public/i18n/zh_CHT.json
@@ -2,58 +2,63 @@
"displayName": "WakaTime",
"settings": {
"generalSettings": {
+ "cleanCache": {
+ "description": "刪除所有離線緩存文件(刪除後將刷新頁面)",
+ "text": "清理",
+ "title": "清理離線緩存"
+ },
"reset": {
- "description": "重置所有設定選項為預設選項(重置後將重新整理頁面)",
+ "description": "重置所有設置選項為默認選項(重置後將刷新頁面)",
"text": "重置",
- "title": "重置設定選項"
+ "title": "重置設置選項"
},
- "title": "常規設定"
+ "title": "常規設置"
},
"wakatimeSettings": {
"generalTab": {
"editCategory": {
- "description": "為編輯操作設定的活動型別標籤",
+ "description": "為編輯操作設置的活動類型標籤",
"title": "編輯操作標籤"
},
"heartbeats": {
- "description": "開啟後將在檢視/編輯文件時向 Wakapi/WakaTime 服務提交活動資訊",
- "title": "心跳連線"
+ "description": "開啟後將在查看/編輯文檔時向 Wakapi/WakaTime 服務提交活動信息",
+ "title": "心跳連接"
},
"interval": {
- "description": "將編輯活動推送到 Wakapi/WakaTime 伺服器的時間間隔(單位:秒)",
+ "description": "將編輯活動推送到 Wakapi/WakaTime 服務器的時間間隔(單位:秒)",
"title": "推送時間間隔"
},
"language": {
- "description": "自定義思原始檔語言名稱
對應 Wakapi/WakaTime 統計中的 Languages
項",
+ "description": "自定義思源文件語言名稱
對應 Wakapi/WakaTime 統計中的 Languages
項",
"title": "語言名稱"
},
"project": {
"description": "自定義當前工作空間名稱
對應 Wakapi/WakaTime 統計中的 Projects
項",
- "title": "项目名稱"
+ "title": "項目名稱"
},
- "title": "常規設定",
+ "title": "常規設置",
"viewCategory": {
- "description": "為檢視操作設定的活動型別標籤",
- "title": "檢視操作標籤"
+ "description": "為查看操作設置的活動類型標籤",
+ "title": "查看操作標籤"
}
},
"serviceTab": {
"apiKey": {
- "description": "Wakapi/WakaTime API 金鑰
對應 Wakapi/WakaTime 配置中的 api_key
",
- "title": "API 金鑰"
+ "description": "Wakapi/WakaTime API 密鑰
對應 Wakapi/WakaTime 配置中的 api_key
",
+ "title": "API 密鑰"
},
"apiURL": {
"description": "Wakapi/WakaTime API URL
對應 Wakapi/WakaTime 配置中的 api_url
",
"title": "API URL"
},
"exclude": {
- "description": "提交時排除路徑中包含列表欄位的文件
每行一條記錄,若為正則表示式則需要使用 /
包裹
對應 Wakapi/WakaTime 配置中的 exclude
",
- "placeholder": "不排除任何筆記本與文件",
+ "description": "提交時排除路徑中包含列表字段的文檔
每行一條記錄,若為正則表達式則需要使用 /
包裹
對應 Wakapi/WakaTime 配置中的 exclude
",
+ "placeholder": "不排除任何筆記本與文檔",
"title": "排除列表"
},
"excludeID": {
- "description": "提交時排除 ID 路徑中包含列表欄位的文件
每行一條記錄,若為正則表示式則需要使用 /
包裹",
- "placeholder": "不排除任何筆記本與文件",
+ "description": "提交時排除 ID 路徑中包含列表字段的文檔
每行一條記錄,若為正則表達式則需要使用 /
包裹",
+ "placeholder": "不排除任何筆記本與文檔",
"title": "ID 排除列表"
},
"hide_branch_names": {
@@ -61,28 +66,32 @@
"title": "隱藏筆記本名稱"
},
"hide_file_names": {
- "description": "是否隱藏文件標題,開啟後將使用文件塊 ID 替換文件標題
對應 Wakapi/WakaTime 配置中的 hide_file_names
",
- "title": "隱藏文件標題"
+ "description": "是否隱藏文檔標題,開啟後將使用文檔塊 ID 替換文檔標題
對應 Wakapi/WakaTime 配置中的 hide_file_names
",
+ "title": "隱藏文檔標題"
},
"hostname": {
"description": "自定義當前主機名稱
對應 Wakapi/WakaTime 配置中的 hostname
",
"title": "主機名稱"
},
"include": {
- "description": "僅提交路徑中包含列表欄位的文件
每行一條記錄,若為正則表示式則需要使用 /
包裹
對應 Wakapi/WakaTime 配置中的 include
",
- "placeholder": "包含所有筆記本與文件",
+ "description": "僅提交路徑中包含列表字段的文檔
每行一條記錄,若為正則表達式則需要使用 /
包裹
對應 Wakapi/WakaTime 配置中的 include
",
+ "placeholder": "包含所有筆記本與文檔",
"title": "包含列表"
},
"includeID": {
- "description": "僅提交 ID 路徑中包含列表欄位的文件
每行一條記錄,若為正則表示式則需要使用 /
包裹",
- "placeholder": "包含所有筆記本與文件",
+ "description": "僅提交 ID 路徑中包含列表字段的文檔
每行一條記錄,若為正則表達式則需要使用 /
包裹",
+ "placeholder": "包含所有筆記本與文檔",
"title": "ID 包含列表"
},
+ "offline": {
+ "description": "是否開啟離線緩存功能
開啟後當 Wakapi/WakaTime 服務訪問異常或未開啟心跳連接時會將活動信息緩存至 ${1} 目錄
對應 Wakapi/WakaTime 配置中的 offline
",
+ "title": "離線緩存"
+ },
"test": {
- "description": "測試是否正確連線到 Wakapi/WakaTime 服務",
+ "description": "測試是否正確連接到 Wakapi/WakaTime 服務",
"messages": {
- "error": "無法與 ${1} 服務建立連線",
- "success": "與 ${1} 服務的連線正常"
+ "error": "無法與 ${1} 服務建立連接",
+ "success": "與 ${1} 服務的連接正常"
},
"text": "測試",
"title": "測試 Wakapi/WakaTime 服務"
@@ -91,7 +100,7 @@
"description": "Wakapi/WakaTime API 請求超時時間
對應 Wakapi/WakaTime 配置中的 timeout
",
"title": "超時時間"
},
- "title": "服務設定"
+ "title": "服務設置"
},
"title": "Wakapi/WakaTime"
}
diff --git a/public/i18n/zh_CN.json b/public/i18n/zh_CN.json
index ab4f730..9ff74d1 100644
--- a/public/i18n/zh_CN.json
+++ b/public/i18n/zh_CN.json
@@ -2,6 +2,11 @@
"displayName": "WakaTime",
"settings": {
"generalSettings": {
+ "cleanCache": {
+ "description": "删除所有离线缓存文件(删除后将刷新页面)",
+ "text": "清理",
+ "title": "清理离线缓存"
+ },
"reset": {
"description": "重置所有设置选项为默认选项(重置后将刷新页面)",
"text": "重置",
@@ -78,6 +83,10 @@
"placeholder": "包含所有笔记本与文档",
"title": "ID 包含列表"
},
+ "offline": {
+ "description": "是否开启离线缓存功能
开启后当 Wakapi/WakaTime 服务访问异常或未开启心跳连接时会将活动信息缓存至 ${1} 目录
对应 Wakapi/WakaTime 配置中的 offline
",
+ "title": "离线缓存"
+ },
"test": {
"description": "测试是否正确连接到 Wakapi/WakaTime 服务",
"messages": {
diff --git a/public/plugin.json b/public/plugin.json
index e4068d3..57c38a9 100644
--- a/public/plugin.json
+++ b/public/plugin.json
@@ -2,7 +2,7 @@
"name": "wakatime",
"author": "Zuoqiu Yingyi",
"url": "https://github.com/Zuoqiu-Yingyi/siyuan-plugin-wakatime",
- "version": "0.1.2",
+ "version": "0.2.0",
"minAppVersion": "2.9.9",
"backends": [
"all"
diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte
index f193675..a66521b 100644
--- a/src/components/Settings.svelte
+++ b/src/components/Settings.svelte
@@ -33,6 +33,7 @@
import type { IConfig } from "@/types/config";
import type { I18N } from "@/utils/i18n";
+ import WakaTimePlugin from "@/index";
export let config: IConfig; // 传入的配置项
export let plugin: InstanceType; // 插件实例
@@ -54,6 +55,17 @@
);
}
+ function cleanCache() {
+ plugin.siyuan.confirm(
+ i18n.settings.generalSettings.cleanCache.title, // 标题
+ i18n.settings.generalSettings.cleanCache.description, // 文本
+ async () => {
+ await plugin.clearCache(); // 重置配置
+ globalThis.location.reload(); // 刷新页面
+ }, // 确认按钮回调
+ );
+ }
+
/* 测试服务 */
async function testService(): Promise {
const status = await plugin.testService();
@@ -155,6 +167,20 @@
>
+
+ -
+
+
+
+
+ -
+ {
+ config.wakatime.offline = e.detail.value;
+ await updated();
+ }}
+ />
+
+
- ;
public readonly client: InstanceType;
+ public readonly cache: InstanceType>; // 缓存
+ public readonly caches: InstanceType>[]; // 历史缓存
public readonly notebook = new Map(); // 笔记本 ID => 笔记本信息
protected readonly SETTINGS_DIALOG_ID: string;
protected readonly context: Context.IContext; // 心跳连接上下文
public config: IConfig;
- protected timer: number; // 定时器
+
+ protected heartbeatTimer: number; // 心跳定时器
+ protected cacheCheckTimer: number; // 缓存检查定时器
constructor(options: any) {
super(options);
this.logger = new Logger(this.name);
this.client = new Client(undefined, "fetch");
+ this.cache = new Cache(this.client, WakaTimePlugin.OFFLINE_CACHE_PATH);
+ this.caches = [];
this.SETTINGS_DIALOG_ID = `${this.name}-settings-dialog`;
+
this.context = {
url: this.wakatimeHeartbeatsUrl,
method: "POST",
@@ -107,6 +124,7 @@ export default class WakaTimePlugin extends siyuan.Plugin {
icon_wakatime_wakapi,
].join(""));
+ /* 加载配置文件 */
this.loadData(WakaTimePlugin.GLOBAL_CONFIG_NAME)
.then(config => {
this.config = mergeIgnoreArray(DEFAULT_CONFIG, config || {}) as IConfig;
@@ -125,8 +143,10 @@ export default class WakaTimePlugin extends siyuan.Plugin {
/* 编辑区点击 */
this.eventBus.on("click-editorcontent", this.clickEditorContentEventListener);
-
});
+
+ /* 加载缓存数据 */
+ this.cache.load();
}
onLayoutReady(): void {
@@ -137,7 +157,8 @@ export default class WakaTimePlugin extends siyuan.Plugin {
this.eventBus.off("loaded-protyle", this.loadedProtyleEventListener);
this.eventBus.off("click-editorcontent", this.clickEditorContentEventListener);
- clearInterval(this.timer);
+ clearInterval(this.heartbeatTimer);
+ clearInterval(this.cacheCheckTimer);
this.commit();
}
@@ -163,6 +184,16 @@ export default class WakaTimePlugin extends siyuan.Plugin {
return this.updateConfig(mergeIgnoreArray(DEFAULT_CONFIG) as IConfig);
}
+ /* 清理缓存 */
+ public async clearCache(directory: string = WakaTimePlugin.OFFLINE_CACHE_PATH): Promise {
+ try {
+ await this.client.removeFile({ path: directory });
+ return true;
+ } catch (error) {
+ return false
+ }
+ }
+
/* 更新插件配置 */
public async updateConfig(config?: IConfig): Promise {
if (config && config !== this.config) {
@@ -175,10 +206,13 @@ export default class WakaTimePlugin extends siyuan.Plugin {
/* 更新定时器 */
public updateTimer(interval: number = this.config.wakatime.interval) {
- if (this.timer) {
- clearInterval(this.timer);
- }
- this.timer = setInterval(this.commit, interval * 1_000);
+ /* 心跳定时器 */
+ clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = setInterval(this.commit, interval * 1_000);
+
+ /* 缓存检查定时器 */
+ clearInterval(this.cacheCheckTimer);
+ this.cacheCheckTimer = setInterval(this.checkCache, WakaTimePlugin.CACHE_CHECK_INTERVAL);
}
/* 更新 wakatime 请求上下文 */
@@ -236,16 +270,105 @@ export default class WakaTimePlugin extends siyuan.Plugin {
this.context.actions.push(...valid_actions);
if (this.context.actions.length > 0) {
- if (this.config.wakatime.heartbeats) { // 是否发送心跳连接
+ const actions = this.context.actions.slice(); // 数组浅拷贝
+ this.context.actions.length = 0;
+
+ /* 构造心跳连接请求 */
+ const requests: Heartbeats.IRequest[] = [];
+ for (let i = 0; i < actions.length; i += WakaTimePlugin.WAKATIME_HEARTBEATS_BULK) {
// WakaTime 限制一次最多提交 25 条记录
- for (let i = 0; i < this.context.actions.length; i += 25) {
- this.sentHeartbeats(this.context.actions.slice(i, i + 25));
+ requests.push(this.buildHeartbeatsRequest(actions.slice(i, i + WakaTimePlugin.WAKATIME_HEARTBEATS_BULK)))
+ }
+
+ if (this.config.wakatime.heartbeats) { // 提交数据
+ for (const request of requests) {
+ await this.sentHeartbeats(
+ request,
+ request => {
+ if (this.config.wakatime.offline) {
+ this.cache.push(request.payload);
+ }
+ }
+ ); // 发送载荷
}
}
- this.context.actions.length = 0;
+ else { // 不提交数据
+ if (this.config.wakatime.offline) { // 若开启离线缓存
+ this.cache.push(...requests.map(request => request.payload)); // 写入缓存
+ }
+ }
+ await this.cache.save(); // 缓存持久化
}
};
+ /* 检查缓存 */
+ protected readonly checkCache = async () => {
+ const cache_files_name = await this.cache.getAllCacheFileName(); // 所有缓存文件名称
+
+ /* 初始化历史缓存对象列表 */
+ this.caches.length = 0;
+ this.caches.push(...cache_files_name.map(filename => new Cache(
+ this.client,
+ WakaTimePlugin.OFFLINE_CACHE_PATH,
+ filename,
+ )));
+
+ /* 定时提交缓存 */
+ if (this.caches.length > 0) {
+ for (const cache of this.caches) {
+ if (this.config.wakatime.heartbeats) { // 提交
+ await cache.load(); // 加载缓存文件
+
+ const exceptions: TCacheDatum[] = []; // 提交缓存时发生异常
+
+ /* 依次提交缓存内容 */
+ for (let index = 0; index < cache.length; ++index) {
+ const payload = cache.at(index);
+
+ /* 提交缓存 */
+ await this.sentHeartbeats(
+ this.buildHeartbeatsRequest(payload),
+ request => exceptions.push(request.payload),
+ );
+
+ if (index === 0 && exceptions.length > 0) {
+ /**
+ * 第一次提交出现问题
+ * 可能用户处于离线状态
+ * 本次不再进行提交
+ */
+ return;
+ }
+
+ /* 休眠 */
+ await sleep(WakaTimePlugin.CACHE_COMMIT_INTERVAL);
+ }
+
+ if (exceptions.length > 0) {
+ /* 存在异常, 保存异常提交到缓存文件 */
+ cache.clear();
+ cache.push(...exceptions);
+ await cache.save();
+
+ /**
+ * 本轮提交存在异常
+ * 可能用户网络状态可能不稳定
+ * 本次不再进行提交
+ */
+ return;
+ }
+ else {
+ /* 不存在异常, 删除缓存文件 */
+ await cache.remove();
+ }
+ }
+ else { // 不提交
+ return;
+ }
+ }
+ }
+ }
+
/* 总线事件监听器 */
protected readonly webSocketMainEventListener = (e: IWebSocketMainEvent) => {
// this.logger.debug(e);
@@ -259,7 +382,7 @@ export default class WakaTimePlugin extends siyuan.Plugin {
this.addEditEvent(operation.id);
}
});
- transaction.doOperations?.forEach(operation => {
+ transaction.undoOperations?.forEach(operation => {
if (operation.id) {
this.addEditEvent(operation.id);
}
@@ -461,11 +584,12 @@ export default class WakaTimePlugin extends siyuan.Plugin {
}
/**
- * 发送心跳连接
- * REF: https://wakatime.com/developers#heartbeats
+ * 构造心跳连接请求
+ * @param payload 心跳连接载荷
+ * @returns 心跳连接请求
*/
- public async sentHeartbeats(payload: Heartbeats.IAction | Heartbeats.IAction[]) {
- return this.client.forwardProxy({
+ public buildHeartbeatsRequest(payload: Heartbeats.IAction | Heartbeats.IAction[]): Heartbeats.IRequest {
+ const request: Heartbeats.IRequest = {
url: Array.isArray(payload)
? `${this.context.url}.bulk`
: this.context.url,
@@ -475,7 +599,31 @@ export default class WakaTimePlugin extends siyuan.Plugin {
],
timeout: this.config.wakatime.timeout * 1_000,
payload,
- });
+ };
+ return request;
+ }
+
+ /**
+ * 发送心跳连接
+ * REF: https://wakatime.com/developers#heartbeats
+ * @param request 心跳连接请求
+ * @param reject 心跳连接失败时的回调
+ */
+ public async sentHeartbeats(
+ request: Heartbeats.IRequest,
+ reject: (request: Heartbeats.IRequest) => void,
+ ) {
+ try {
+ const response = await this.client.forwardProxy(request);
+ if (200 <= response.data.status && response.data.status < 300) {
+ }
+ else {
+ reject(request);
+ }
+ return response;
+ } catch (error) {
+ reject(request);
+ }
}
/* 测试服务状态 */
diff --git a/src/types/config.d.ts b/src/types/config.d.ts
index 525ab85..fa3f159 100644
--- a/src/types/config.d.ts
+++ b/src/types/config.d.ts
@@ -23,12 +23,15 @@ export interface IActivity {
}
export interface IWakaTime {
+ // REF: https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md
api_url: string; // API URL
api_key: string; // API KEY
timeout: number; // 请求超时时间 (单位: s)
hide_branch_names: boolean; // 是否隐藏笔记本名 (使用笔记本 ID 代替)
hide_file_names: boolean; // 是否隐藏文件路径 (使用文档 ID 代替)
+ offline: boolean; // 是否启用离线时缓存
+
includeID: string[]; // ID 包含列表, 在 ID 中过滤, 为空则包含所有笔记本与文档
excludeID: string[]; // ID 排除列表, 在 ID 中过滤, 为空则不排除任何笔记本与文档
diff --git a/src/types/wakatime.d.ts b/src/types/wakatime.d.ts
index 1c6876a..7544733 100644
--- a/src/types/wakatime.d.ts
+++ b/src/types/wakatime.d.ts
@@ -17,6 +17,7 @@
import type { BlockID } from "@workspace/types/siyuan";
import type { Category, Type } from "@/wakatime/heartbeats";
+import type { types } from "@siyuan-community/siyuan-sdk";
/**
* 心跳连接
@@ -64,6 +65,14 @@ export namespace Heartbeats {
// ,
is_write?: boolean;
}
+
+ export interface IRequest extends types.kernel.api.network.forwardProxy.IPayload {
+ headers: [
+ Context.IHeaders,
+ ],
+ timeout: number;
+ payload: IAction | IAction[],
+ }
}
export namespace Context {
@@ -104,4 +113,3 @@ export namespace Context {
actions: Heartbeats.IAction[]; // 待提交的活动
}
}
-
diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts
new file mode 100644
index 0000000..9a82c9c
--- /dev/null
+++ b/src/utils/jsonl.ts
@@ -0,0 +1,41 @@
+/**
+ * Copyright (C) 2023 Zuoqiu Yingyi
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * jsonlines 解析
+ * @param text 文本
+ * @returns 解析结果
+ */
+export function parse(text: string): T[] {
+ return text.split("\n")
+ .filter(line => line.trim().length > 0)
+ .map(line => JSON.parse(line));
+}
+
+/**
+ * jsonlines 序列化
+ * @param data 数据
+ * @returns 序列化结果
+ */
+export function stringify(data: T[]): string {
+ return data.map((item) => JSON.stringify(item)).join("\n");
+}
+
+export default {
+ parse,
+ stringify,
+}
diff --git a/src/wakatime/cache.ts b/src/wakatime/cache.ts
new file mode 100644
index 0000000..548a4ae
--- /dev/null
+++ b/src/wakatime/cache.ts
@@ -0,0 +1,284 @@
+/**
+ * Copyright (C) 2023 Zuoqiu Yingyi
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import moment from "@workspace/utils/date/moment";
+import JSONL from "@/utils/jsonl";
+
+import type { Client } from "@siyuan-community/siyuan-sdk";
+import type { Heartbeats } from "@/types/wakatime";
+
+export type TCacheDatum = Heartbeats.IAction | Heartbeats.IAction[];
+
+export type TCache = {
+ [P in keyof Array]?: Array[P];
+}
+
+export class Cache implements TCache {
+ /**
+ * 构造缓存文件名
+ * @param date 时间日期
+ * @param format 时间日期格式化字符串
+ * REF: https://momentjs.com/docs/#/parsing/string-format/
+ * @param extension 文件扩展名
+ * @returns 文件名
+ */
+ public static buildCacheFileName(
+ date: Date = new Date(),
+ format: string = "YYYY-MM-DD",
+ extension: string = "jsonl",
+ ): string {
+ return `${moment(date).format(format)}.${extension}`;
+ }
+
+ protected filepath!: string; // 缓存文件路径
+
+ protected readonly data: T[] = []; // 缓存的数据
+ protected readonly lines: string[] = []; // 缓存文件文本
+
+ constructor(
+ public readonly client: InstanceType, // 思源客户端
+ public readonly directory: string, // 缓存文件目录
+ protected filename: string = undefined, // 缓存文件名
+ ) {
+ this.init(this.filename);
+ }
+
+ [n: number]: T;
+
+ /* 初始化 */
+ protected init(
+ filename: string = Cache.buildCacheFileName(),
+ ): void {
+ this.filename = filename;
+ this.filepath = this.buildCacheFilePath();
+ this.clear();
+ }
+
+ /**
+ * 构造缓存文件路径
+ * @param directory 目录路径
+ * @param filename 文件名
+ * @returns 文件路径
+ */
+ public buildCacheFilePath(
+ directory: string = this.directory,
+ filename: string = this.filename,
+ ): string {
+ return `${directory}/${filename}`;
+ }
+
+ /**
+ * 获取所有缓存文件的路径
+ * @param directory 缓存文件目录路径
+ * @returns 文件路径列表
+ */
+ public async getAllCacheFilePath(directory: string = this.directory): Promise {
+ const files = await this.client.readDir({ path: directory });
+ return files.data
+ .filter(file => file.isDir === false)
+ .map(file => this.buildCacheFilePath(directory, file.name));
+ }
+
+ /**
+ * 获取所有缓存文件的名称
+ * @param directory 缓存文件目录路径
+ * @returns 文件路径列表
+ */
+ public async getAllCacheFileName(directory: string = this.directory): Promise {
+ const files = await this.client.readDir({ path: directory });
+ return files.data
+ .filter(file => file.isDir === false)
+ .map(file => file.name);
+ }
+
+ /**
+ * 清空数据
+ */
+ public clear(): void {
+ this.length = 0;
+ }
+
+ get length(): number {
+ return this.data.length;
+ }
+
+ set length(value: number) {
+ this.data.length = value;
+ this.lines.length = value;
+ }
+
+ at(index: number): T {
+ return this.data.at(index);
+ }
+
+ toString(): string {
+ return this.lines.join("\n");
+ }
+
+ toLocaleString(): string {
+ return this.toString();
+ }
+
+ push(...items: T[]): number {
+ this.data.push(...items);
+ this.lines.push(...items.map(datum => JSON.stringify(datum)));
+ return this.length;
+ }
+
+ pop(): T | undefined {
+ this.lines.pop();
+ return this.data.pop();
+ }
+
+ shift(): T | undefined {
+ this.lines.shift();
+ return this.data.shift();
+ }
+
+ unshift(...items: T[]): number {
+ this.data.unshift(...items);
+ this.lines.unshift(...items.map(datum => JSON.stringify(datum)));
+ return this.length;
+ }
+
+ slice(start?: number, end?: number): T[] {
+ return this.data.slice(start, end);
+ }
+
+ splice(start: number, deleteCount?: number, ...items: T[]): T[] {
+ return this.data.splice(start, deleteCount, ...items);
+ }
+
+ forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void {
+ this.data.forEach(callbackfn, thisArg);
+ }
+
+ map(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[] {
+ return this.data.map(callbackfn, thisArg);
+ }
+
+ /**
+ * 迭代器, 可用于 for...of 循环
+ * REF: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol/iterator
+ */
+ [Symbol.iterator](): IterableIterator {
+ return this.data[Symbol.iterator]();
+ }
+
+ /**
+ * 类型标签
+ */
+ public get [Symbol.toStringTag]() {
+ return 'Cache';
+ }
+
+ /**
+ * 强制类型转换
+ */
+ public [Symbol.toPrimitive](hint: "number" | "string" | "default") {
+ switch (hint) {
+ case "number":
+ return this.length;
+ case "string":
+ return this.toString();
+ default:
+ return this.data;
+ }
+ }
+
+ /**
+ * 加载数据
+ * @param filepath 文件路径
+ * @returns 是否加载成功
+ */
+ public async load(filepath: string = this.filepath): Promise {
+ /* 检查文件是否存在 */
+ const files = await this.client.readDir({ path: this.directory });
+ if (files.data.some(file => file.name === this.filename && file.isDir === false)) {
+ /* 若文件存在则读取文件 */
+ const text = await this.client.getFile({ path: filepath }, "text");
+ this.clear();
+ this.push(...JSONL.parse(text));
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * 移除数据
+ * @param filepath 文件路径
+ * @returns 是否移除成功
+ */
+ public async remove(filepath: string = this.filepath): Promise {
+ /* 检查文件是否存在 */
+ const files = await this.client.readDir({ path: this.directory });
+ if (files.data.some(file => file.name === this.filename && file.isDir === false)) {
+ /* 若文件存在则移除文件 */
+ const text = await this.client.removeFile({ path: filepath });
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * 缓存持久化 (自动更新缓存文件名)
+ * @param update (在需要时) 更新文件名
+ * @param filepath 文件路径
+ * @returns 缓存是否持久化成功
+ */
+ public async save(
+ update: boolean = true,
+ filepath: string = this.filepath,
+ ): Promise {
+ try {
+ /* 持久缓存 */
+ const result = await this._save(filepath);
+
+ if (update) {
+ const cache_file_name = Cache.buildCacheFileName();
+ if (cache_file_name !== this.filename) { // 需要初始化缓存
+ /* 初始化缓存 */
+ this.init();
+ }
+ }
+
+ return result;
+ } catch (error) {
+ return false;
+ }
+ }
+
+ /**
+ * 保存缓存数据为 jsonlines 文件
+ * @param filepath 文件路径
+ * @param terminator 行终止符
+ * @returns 是否持久化成功
+ */
+ protected async _save(
+ filepath: string,
+ terminator: string = "\n",
+ ): Promise {
+ if (this.data.length > 0) {
+ await this.client.putFile({
+ path: filepath,
+ file: this.lines.join(terminator),
+ });
+ return true;
+ }
+ return false;
+ }
+}