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; + } +}