├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── README_ZH_CN.md ├── esbuild.config.mjs ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── DataRecorder.ts ├── DocTracker.ts ├── ListParser.ts ├── MetaDataParser.ts ├── TableParser.ts ├── main.ts └── stats.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: "18.x" 20 | 21 | - name: Build plugin 22 | run: | 23 | npm install 24 | npm run build 25 | 26 | - name: Create release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | run: | 30 | tag="${GITHUB_REF#refs/tags/}" 31 | 32 | gh release create "$tag" \ 33 | --title="$tag" \ 34 | --draft \ 35 | main.js manifest.json styles.css -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 LeCheenaX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wordflow Tracker 2 | ![image](https://img.shields.io/github/v/release/LeCheenaX/WordFlow-Tracker?label=Version&link=https%3A%2F%2Fgithub.com%2FLeCheenaX%2FWordFlow-Tracker%2Freleases%2Flatest) ![image](https://img.shields.io/github/downloads/LeCheenaX/WordFlow-Tracker/total?logo=Obsidian&label=Downloads&labelColor=%237C3AED&color=%235b5b5b&link=https%3A%2F%2Fgithub.com%2FLeCheenaX%2FWordFlow-Tracker%2Freleases%2Flatest) 3 | 4 | [中文文档](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md) | [English](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file) 5 | 6 | ## Introduction 7 | WorkFlow Tracker is a lite plugin that track your edits on each note and automatically record these edits statistics to your periodic note, like your daily note. 8 | 9 | ![wordflow3](https://github.com/user-attachments/assets/e323477d-d38e-4a6c-8d98-83bda5818a07) 10 | 11 | ### Core Features 12 | - Tracking the number of edits, editied words per note. This will reflect on the status bar at the bottom of note. 13 | ![image](https://github.com/user-attachments/assets/88e1d16b-893f-46a4-aa66-210a372ef753) 14 | - Record the modified data automatically when the note is closed. Alternatively, use command or button to record all notes. 15 | - Display changes in a bar style to show the portion of original contents v.s. modified contents. 16 | ![image](https://github.com/user-attachments/assets/56c8336a-4761-4fed-99b7-3f6453de416a) 17 | - Record edited statistics such as total words you edited today, to the YAML(Frontmatter) of daily note. Other plugins such as heatmap could use these metadata to generate analysis. 18 | 19 | ![image](https://github.com/user-attachments/assets/1e5bbe85-a943-4d10-b81c-ecef5e6b15bb) 20 | - Customization of which data to be recorded with ${dataName}, see in [Supported String Interpolations](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file#supported-string-interpolations) below. 21 | - Customization of how the data to be recorded, like inserting a table or a list to the specified position of your note. 22 | ### How does this plugin collect data? 23 | 24 | We fetch the edit statistcs by access the history field of Obsidian editor, which is the place to store the undo/redo history of Obsidian. 25 | - No extra history database is created, thus don't worry about the performance burdens in large vault. 26 | - No extra data file is created or exposed. This resolves the privacy concerns. 27 | 28 | > All statics are fetched by diectly reading the Obsidian data, without adding additional thread to record the data, which means that enabling the recording will bring almost no performance loss or extra RAM occupation. 29 | > 30 | > The temporary edit stats collected by the plugin are destroyed after recording to your note, and the Obsidian will destory the history data after you close the application. 31 | 32 | ### Guide for beginners 33 | Step 1: Download and [install](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file#installation) the plugin. 34 | 35 | Step 2: Enable the plugin in Obsidian > Settings > Community plugins. 36 | 37 | Step 3: In Wordflow Tracker settings, specify your [periodic note folder](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file#recorder-basics) for placing your periodic notes, in which the edit stats will be saved. 38 | 39 | Now the plugin will automatically track the edits you made and display them in the status bar. The edits stats will also be recorded to your periodic note, when any one of the following is met: 40 | 1. you switch from editing mode to reading mode in Obsidian; 41 | 2. you close a tab of notes after editing them; 42 | 3. you manually click the button "Record wordflows from edited notes" in the left ribbon of Obsidian; 43 | 4. you manually run the command "Record wordflows from edited notes to periodic notes" in Obsidian; 44 | 5. the automatic recording interval is timed out, which could be set in the setting of Wordflow Tracker plugin, to record all edited notes. 45 | 46 | Note: the tracker will be set to 0 once the note is recorded. 47 | 48 | ### Advanced guide for customization 49 | #### Apply templates to newly created notes before recording 50 | Make sure your template will be applied to notes under the same [periodic note folder](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file#recorder-basics). 51 | 52 | If your newly created notes will be renamed by other plugins, such as **Templates**(core plugin) or **Templater**(community plugin), make sure that the name that other plugin specified is the same as [periodic note format](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file#recorder-basics) 53 | 54 | #### Customize which data to be recorded 55 | In wordflow recording syntax, you can add or delete the data in one of the following formats: 56 | 57 | - **Table:** 58 | 59 | Open any note in Obsidian, and add a blank table with: 60 | ``` 61 | 62 | | | 63 | |-| 64 | | | 65 | ``` 66 | Then, specify the name in heading for ${modifiedNote}, such as "Note Name" and add "${modifiedNote}" to the row. 67 | 68 | ![image](https://github.com/user-attachments/assets/de0e8909-727e-44d2-9cec-c647d51af48c) 69 | 70 | Now click the 'add column after' button, and specify the new heading names and any [string interpolations](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file#supported-string-interpolations) you would like. 71 | 72 | ![image](https://github.com/user-attachments/assets/0027b8f9-49f9-4f25-a8b4-38d369c6a115) 73 | 74 | Lastly, select and copy the whole table, and paste it into Wordflow Tracker settings. 75 | 76 | ![image](https://github.com/user-attachments/assets/de26aee0-e051-42b6-8fc1-e18e41db2f60) 77 | 78 | Note: ${modifiedNote} must exist in the table syntax, or the recorder will have trouble merging the existing data of note with the new data 79 | 80 | - **Bullet List:** 81 | 82 | Add a linebreak, press the tab key for proper spacing, and specify any name you expect for this data. 83 | 84 | Lastly, add a [string interpolations](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file#supported-string-interpolations) like "${docWords}" 85 | 86 | ![image](https://github.com/user-attachments/assets/288f6fa4-1d0a-4187-aa9d-4b6b7e90e7bc) 87 | 88 | Note: ${modifiedNote} must exist in the bullet list syntax, or the recorder will have trouble merging the existing data of note with the new data 89 | 90 | - **Metadata:** 91 | 92 | Just like adding a metadata in "source mode", you can add a property name ends with ':', and a [string interpolations](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file#supported-string-interpolations) after it, like "${totalWords}" 93 | 94 | #### Record edit stats to both note content and yaml(frontmatter) 95 | In plugin settings, create a recorder by clicking the add button: 96 | 97 | ![image](https://github.com/user-attachments/assets/a1eff9ee-6d56-4ebe-9d07-aaa3ca004d6e) 98 | 99 | Then, adjust the perodic note folder and note format to the same as the other recorder, to record on the same note. 100 | 101 | Lastly, adjust the record content type to a different one. 102 | 103 | Note that you should **avoid having the same record content type of 2 recorders that target on the same note**. For example, avoid having one recorder which inserts table to the bottom of today's daily note, while having the other recorder which inserts table to a custom position of today's daily note. 104 | 105 | #### Record edit stats to a dynamic folder 106 | You can record edit statistics to not only a static folder, such as "Daily Notes/2025-03-23.md", but also on a dynamic folder like: "Daily Notes/2025-03/2025-03-23.md". 107 | 108 | For details regarding how to implement this, see [Enable dynamic folder](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file#recorder-basics) 109 | 110 | Please also ensure that this folder is the same folder where templates from other plugin will be applied. 111 | 112 | ## Settings documentation 113 | ### Recorder 114 | - **Create**: Create a new recorder so that the edit stats in tracker will be additionally recorded. Common usages are as followed: 115 | - Create a recorder for another periodic note: current recorder will record to daily note, and you create an additional one to record to monthly note. 116 | - Create a recorder for a different recording type: current recorder will record edits per note as table rows to your daily note, and you create another recorder to record total edits to the YAML of daily notes. 117 | 118 | ![image](https://github.com/user-attachments/assets/56a03e3c-930c-4d0e-b901-a07e95099105) 119 | - **Rename**: Rename your recorders. 120 | - **Delete**: Delete the current recorder and abandon its settings. 121 | ### Recorder Basics 122 | - **Periodic note folder:** Set the folder for daily notes or weekly note to place, which should correspond to the same folder of Obsidian daily note plugin and of templater plugin(if installed). 123 | - **Enable dynamic folder:** Record the note to a dynamic folder rather than a static folder. If enabled, the folder must be in a [moment compatible format](https://momentjs.com/docs/#/displaying/format/). 124 | 125 | | Dynamic folder format | Corresponding folder in vault | Periodic note format | Note path in vault | 126 | | ---------------------- | ----------------------------- | -------------------- | --------------------------------- | 127 | | [Daily Notes/]YYYY-MM | Daily Notes/2025-03 | YYYY-MM-DD | Daily Notes/2025-03/2025-03-21.md | 128 | | [Monthly Notes/]YYYY | Monthly Notes/2025 | MMM YYYY | Monthly Notes/2025/Mar 2025.md | 129 | 130 | - **Periodic note format:** Set the file name for newly created daily notes or weekly note, which should correspond to the same format setting of Obsidian daily note plugin and of templater plugin(if installed). 131 | ### Recording Settings 132 | - **Record content type:** Select a type of content to record on specified notes. Currently, table and bullet list are supported. 133 | - Note: when using a table format, the modified note must be at the first column. 134 | - **Insert to position:** If using a custom position, the start position and end position must exist and be unique in periodic note! Make sure your template is correctly applied while creating new periodic note. 135 | - **Wordflow recording syntax:** Used for customizaing recording content. The regular expressions are supported with '${modifiedNote}', you can also generate link to the note by using a '[[${modifiedNote}]]'. 136 | 137 | ### Supported String Interpolations 138 | 139 | 140 | | String Interpolation   | Description | Compatible Record Types | Example | Note | 141 | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 142 | | ${modifiedNote}     | the Obsidian path to modified note | table, bullet list | Daily Notes/2025-03-23.md | should always be put at the first colomn in a table, or the parent element in a bullet list. | 143 | | ${editedTimes} | the number of edits in a period per note. | table, bullet list | 100 | in Obsidian rule, inputted contents whose intervals are less than 0.5 second will be considered one edit. Each edit can be undone by pressing 'ctrl' + 'z', or redone by pressing 'ctrl' + 'shift' + 'z'. | 144 | | ${editedWords} | the number of words you edited in a period per note. | table, bullet list | 550 | equals to addedWords + deletedWords | 145 | | ${changedWords} | the net change of words in a note | table, bullet list | 150 | equals to addedWords - deletedWords | 146 | | ${deletedWords} | the number of words you deleted in a period per note. | table, bullet list | 200 | | 147 | | ${addedWords} | the number of words you added in a period per note. | table, bullet list | 350 | | 148 | | ${docWords} | the number of words per document, by the end of last recording. | table, bullet list | 1000 | includes words in YAML(Fromtmatter), which is not included in Obsidian **word count** core plugin. | 149 | | ${editedPercentage} | (beta testing) the rate of edited words to the total words(edited + original), in a period of editing per note. Very useful when you want to track if the edits are little changes or huge efforts | table, bullet list | 55% | the content is html format, and will be styled to a string. (Using string directly is abandoned due to the growing loss of accuracy with the recorder updates this string. ) | 150 | | ${statBar} | (beta testing) the portion of original words, deleted words and added words in html format. Very useful when you want to track if the edits are little changes or huge efforts | table | ![image](https://github.com/user-attachments/assets/c0d929a7-5ea8-4172-9d85-5de5f46e02bd) | the content will be styled to a svg bar, whose color can be customized in styles.css. Example uses the portion of 450:200:150 | 151 | | ${lastModifiedTime} | the last modified time of your note that is recorded to periodic note, you can specify the format of this item in plugin settings | table, bullet list | 2025-03-23 16:00 | | 152 | | ${totalEdits} | the total number of edits of all notes you edited in a period. | metadata | 200 | can be used for other plugins, such as generating a heatmap | 153 | | ${totalWords} | the total number of edited words of all notes you edited in a period. | metadata | 2000 | can be used for other plugins, such as generating a heatmap | 154 | 155 | 156 | 157 | ## Development Roadmap 158 | See [Development Roadmap](https://github.com/LeCheenaX/WordFlow-Tracker/wiki/Development-RoadMap) for known issues and planned features! 159 | 160 | What to know how this project is built? Or wanna collaborate on this plugin? See details at https://deepwiki.com/LeCheenaX/WordFlow-Tracker 161 | 162 | ## Installation 163 | ### Install in Obsidian 164 | Open obsidian settings > community plugins > browse,in the pop up windows, search for Wordflow Tracker, and click the install button. 165 | 166 | After installed, click the enable button to start the experience. 167 | 168 | ### Manually installing the plugin 169 | 170 | Copy over `main.js`, `manifest.json`, `styles.css` to your vault `VaultFolder/.obsidian/plugins/wordflow-tracker/`. 171 | 172 | ### Install via BRAT 173 | See [BRAT docs](https://github.com/TfTHacker/obsidian42-brat). 174 | 175 | ## Similar plugins 176 | This lite plugin tries to offer unique experience for tracking edits periodically with least obstacles. However, you can try the following alternatives if interested: 177 | - [Obsipulse plugin](https://github.com/jsifalda/obsipulse-plugin) 178 | - [Daily File Logger](https://github.com/ashlovepink/daily-file-logger) 179 | -------------------------------------------------------------------------------- /README_ZH_CN.md: -------------------------------------------------------------------------------- 1 | # Wordflow Tracker 2 | ![image](https://img.shields.io/github/v/release/LeCheenaX/WordFlow-Tracker?label=Version&link=https%3A%2F%2Fgithub.com%2FLeCheenaX%2FWordFlow-Tracker%2Freleases%2Flatest) ![image](https://img.shields.io/github/downloads/LeCheenaX/WordFlow-Tracker/total?logo=Obsidian&label=Downloads&labelColor=%237C3AED&color=%235b5b5b&link=https%3A%2F%2Fgithub.com%2FLeCheenaX%2FWordFlow-Tracker%2Freleases%2Flatest) 3 | 4 | [中文文档](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md) | [English](https://github.com/LeCheenaX/WordFlow-Tracker/tree/main?tab=readme-ov-file) 5 | 6 | ## 介绍 7 | WordFlow Tracker 是一个实时跟踪每个笔记中的编辑数据的轻量插件,并自动将这些编辑数据记录到你的周期性笔记中,例如日记、周记。 8 | 9 | ![wordflow3](https://github.com/user-attachments/assets/e323477d-d38e-4a6c-8d98-83bda5818a07) 10 | 11 | ## 核心功能 12 | - 跟踪每个笔记的编辑次数和编辑字数。这将在笔记底部的状态栏中显示。 13 | 14 | ![image](https://github.com/user-attachments/assets/88e1d16b-893f-46a4-aa66-210a372ef753) 15 | - 在笔记关闭时自动记录修改的数据。或者,使用命令或按钮记录所有笔记。记录后,跟踪器将重置为0。 16 | - 以比例条样式显示更改,展示原始内容(黄色)与修改内容(红色、绿色)的比例。 17 | ![image](https://github.com/user-attachments/assets/56c8336a-4761-4fed-99b7-3f6453de416a) 18 | - 将编辑统计数据(例如你今天编辑的总字数)记录到日记的 YAML(Frontmatter)中。其他插件(如热图)可以使用这些元数据生成分析。 19 | 20 | ![image](https://github.com/user-attachments/assets/1e5bbe85-a943-4d10-b81c-ecef5e6b15bb) 21 | - 自定义要记录的数据,使用${dataName}语法,详见下方[支持的字符串插值](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E5%AD%97%E7%AC%A6%E4%B8%B2%E6%8F%92%E5%80%BC)。 22 | - 自定义数据记录的方式,例如将表格或列表插入到笔记的指定位置。 23 | 24 | ### 此插件如何收集数据? 25 | 26 | 我们通过访问Obsidian编辑器的历史字段(Historic Field)来获取编辑统计数据,历史字段即Obsidian本体存储撤销/重做历史的地方。 27 | - 此插件以轻量化为目的开发,不创建额外的历史数据库,因此在大仓库中的负担。 28 | - 追踪数据过程不创建临时文件,也不会暴露数据,因此不用担心隐私问题。 29 | > 所有统计数据都是通过直接读取Obsidian数据获取的,没有添加额外的线程来记录数据,这意味着启用跟踪几乎不会带来性能损失或额外的内存占用。 30 | > 31 | > 该插件收集的临时数据在记录到指定的周期笔记中后即自动销毁,Obsidian本体也会在关闭应用后清理所有历史数据。 32 | 33 | ### 上手指南 34 | 步骤1:下载并[安装](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md#%E5%AE%89%E8%A3%85)该插件。 35 | 36 | 步骤2:在 Obsidian > 设置 > 社区插件中启用该插件。 37 | 38 | 步骤3:在 Wordflow Tracker 设置中,指定你的[周期笔记文件夹(Periodic Note Folder)](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md#%E5%9F%BA%E7%A1%80%E8%AE%BE%E7%BD%AE),编辑统计数据将在此文件夹中的周期笔记中保存。 39 | 40 | 好了!现在插件会自动跟踪你所做的编辑,并显示在状态栏中。当以下任一情况发生时,编辑统计数据将被记录到周期笔记中: 41 | 1. 从 Obsidian 的编辑模式切换到预览模式。 42 | 2. 在编辑笔记后关闭笔记所在的标签页。 43 | 3. 手动点击 Obsidian 左侧功能区的“Record wordflows from edited notes”按钮。 44 | 4. 手动在 Obsidian 中运行“Record wordflows from edited notes to periodic notes”命令。 45 | 5. 自动记录间隔达到设置的时间,该时间可以在 Wordflow Tracker 插件设置中设定,以定期记录所有编辑过的笔记。 46 | 47 | 注意:记录完笔记后,跟踪器将归零。 48 | 49 | ### 高级自定义指南 50 | #### 在记录之前将模板应用到新创建的笔记 51 | 确保你的设置的笔记模板将应用到同一个[周期笔记文件夹(Periodic Note Folder)](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md#%E5%9F%BA%E7%A1%80%E8%AE%BE%E7%BD%AE)下。 52 | 53 | 如果你新创建的笔记会被其他插件重命名,例如核心插件“模板”或社区插件“Templater”,请确保其他插件指定的名称与[周期笔记格式(Periodic Note Format)](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md#%E5%9F%BA%E7%A1%80%E8%AE%BE%E7%BD%AE)相同。 54 | 55 | #### 自定义要记录的数据 56 | 在 Wordflow 记录语法中,你可以使用以下格式之一添加或删除数据: 57 | 58 | - **表格:** 59 | 60 | 在 Obsidian 中打开任意一个笔记,并通过以下方式添加一个空白表格: 61 | ``` 62 | | | 63 | |-| 64 | | | 65 | ``` 66 | 然后,在标题中为 ${modifiedNote}指定名称,例如“Note Name”,并在行中添加“${modifiedNote}”。 67 | 68 | ![image](https://github.com/user-attachments/assets/de0e8909-727e-44d2-9cec-c647d51af48c) 69 | 70 | 点击“在之后添加列”按钮,为新列指定标题名称和任何你想要添加的[字符串插值](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E5%AD%97%E7%AC%A6%E4%B8%B2%E6%8F%92%E5%80%BC)。 71 | 72 | ![image](https://github.com/user-attachments/assets/0027b8f9-49f9-4f25-a8b4-38d369c6a115) 73 | 74 | 最后,选择并复制整个表格,然后粘贴到 Wordflow Tracker 设置中。 75 | 76 | ![image](https://github.com/user-attachments/assets/de26aee0-e051-42b6-8fc1-e18e41db2f60) 77 | 78 | 注意:${modifiedNote} 必须存在于表格语法中,否则记录器将无法合并笔记的现有数据与新数据。 79 | 80 | 81 | - **无序列表:** 82 | 83 | 添加一个换行符,按 tab 键进行适当的空格间隔,并输入你希望显示的数据名称。随后,添加形如“${docWords}”的[字符串插值](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E5%AD%97%E7%AC%A6%E4%B8%B2%E6%8F%92%E5%80%BC)。 84 | 85 | ![image](https://github.com/user-attachments/assets/288f6fa4-1d0a-4187-aa9d-4b6b7e90e7bc) 86 | 87 | 注意:${modifiedNote} 必须存在于无序列表语法中,否则记录器将无法合并笔记的现有数据与新数据。 88 | 89 | - **元数据:** 90 | 91 | 就像在“源代码模式下”添加元数据一样,添加一个以冒号结尾的属性名称,并在其后添加“[字符串插值](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E5%AD%97%E7%AC%A6%E4%B8%B2%E6%8F%92%E5%80%BC)”,例如“${totalWords}”。 92 | 93 | #### 将编辑统计同时记录到正文和元数据(YAML)中 94 | 在插件设置中,通过单击添加按钮来创建一个新的记录器: 95 | 96 | ![image](https://github.com/user-attachments/assets/a1eff9ee-6d56-4ebe-9d07-aaa3ca004d6e) 97 | 98 | 然后,将周期笔记文件夹和笔记格式调整到与其他记录器相同,以便记录到同一个笔记。 99 | 100 | 最后,将记录内容类型调整为不同类型。 101 | 102 | 注意,你应该避免将针对同一笔记的两个记录器的记录内容类型设置为相同。例如,避免一个记录器将表格插入到今日日记的底部,而另一个记录器将表格插入到今日日记的自定义位置。 103 | 104 | #### 将编辑统计数据记录到动态文件夹 105 | 你可以将编辑统计数据记录到不仅是一个静态文件夹,例如“Daily Notes/2025-03-23.md”,还可以记录到一个动态文件夹,例如:“Daily Notes/2025-03/2025-03-23.md”。 106 | 具体实现方法详见[开启动态文件夹](https://github.com/LeCheenaX/WordFlow-Tracker/blob/main/README_ZH_CN.md#%E5%9F%BA%E7%A1%80%E8%AE%BE%E7%BD%AE)。 107 | 108 | ## 设置 109 | ### 记录器设置 110 | - **创建新记录器**: 创建新记录器来额外记录需要的功能,常用情况如下: 111 | - 为不同的周期笔记创建: 假设当前记录器会记录编辑数据到日记中,你可以添加一个新记录器使编辑数据还能记录到每周笔记,或者每月笔记中。 112 | - 为不同的记录数据创建: 假设当前记录器会在日记中记录你编辑过的每条笔记,并以表格形式呈现, 你可以添加一个新记录器使编辑数据会汇总并记录到 YAML(Frontmatter) 中. 113 | 114 | ![image](https://github.com/user-attachments/assets/56a03e3c-930c-4d0e-b901-a07e95099105) 115 | 116 | - **重命名**: 重命名记录器以便区分。 117 | - **删除**: 删除现在的记录器。 118 | ### 基础设置 119 | - **周期笔记文件夹 (Periodic note folder)**:设置每日笔记、周记等周期笔记的存放文件夹路径。需与以下插件配置保持完全一致: 120 | - Obsidian 原生「日记」插件 121 | - Templater 模板插件(若已安装) 122 | - **开启动态周期笔记文件夹(Enable dynamic folder)**: 每日笔记、周记等周期笔记的存放文件夹将变为动态路径。 该路径需要使用 [moment 兼容格式](https://momentjs.com/docs/#/displaying/format/) 设置。 开启之后效果可见如下表格: 123 | 124 | | 周期笔记文件夹(开启动态文件夹) | 对应Obsidian的文件夹 | 周期笔记格式 | 对应Obsidian的文件 | 125 | | ---------------------------------- | -------------------- | ------------- | --------------------------------- | 126 | | [Daily Notes/]YYYY-MM | Daily Notes/2025-03 | YYYY-MM-DD | Daily Notes/2025-03/2025-03-21.md | 127 | | [Monthly Notes/]YYYY | Monthly Notes/2025 | MMM YYYY | Monthly Notes/2025/Mar 2025.md | 128 | 129 | - **周期笔记格式 (Periodic note format)**:设置新创建的周期笔记(如日/周记)的文件名格式。需与以下插件配置保持完全一致: 130 | - Obsidian 原生「日记」插件 131 | - Templater 模板插件(若已安装) 132 | ### 记录设置 133 | - 记录内容类型 (Record content type):选择在周期笔记中插入内容的格式类型。当前支持: 134 | - 表格 (Table) : 以表格形式记录(⚠️ 使用表格时,modifiedNote 必须位于第一列) 135 | - 无序列表 (Bullet List) : 以列表形式记录 136 | - 插入位置 (Insert to position):支持底部插入和自定义插入 137 | - ⚠️ 若选择「自定义位置」需满足: 起始标记 (start position) 和结束标记 (end position) 必须在周期笔记中存在且唯一 138 | - 确保创建新周期笔记时模板已正确应用这些标记 139 | - Wordflow 记录语法 (Wordflow recording syntax):通过字符串插值自定义记录内容,如使用 ${modifiedNote} 获取修改笔记的路径,或使用 [[${modifiedNote}]] 格式生成笔记链接。 140 | 141 | ### 支持的字符串插值 142 | 143 | | 字符串插值 | 描述 | 支持记录类型 | 示例 | 备注 | 144 | | ------------------- | ------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | 145 | | ${modifiedNote} | 修改后的笔记的笔记路径 | 表格、无序列表 | Daily Notes/2025-03-23.md | 表格中始终应放在第一列,无序列表中应放在父元素中。 | 146 | | ${editedTimes} | 笔记在一段时间内的编辑次数 | 表格、无序列表 | 100 | 根据 Obsidian 规则,输入间隔小于0.5秒的所有输入被视为一次编辑。每次编辑都可以通过按 `ctrl` + `z` 撤销或者按 `ctrl` + `shift` + `z` 重做操作。 | 147 | | ${editedWords} | 笔记在一段时间内的编辑字数 | 表格、无序列表 | 550 | 等于 addedWords + deletedWords | 148 | | ${changedWords} | 笔记中字数的净变化 | 表格、无序列表 | 150 | 等于 addedWords - deletedWords | 149 | | ${deletedWords} | 笔记在一段时间内删除的字数 | 表格、无序列表 | 200 | | 150 | | ${addedWords} | 笔记在一段时间内添加的字数 | 表格、无序列表 | 350 | | 151 | | ${docWords} | 文档的总字数(截止上次记录) | 表格、无序列表 | 1000 | 包括 YAML(Frontmatter)中的字数,而 Obsidian 的字数统计核心插件不包括这部分。 | 152 | | ${editedPercentage} | (beta)一段时间内编辑字数占总字数(编辑字数+原始字数)的比率,用于判断编辑是微调还是较大更改 | 表格、无序列表 | 55% | 内容是 HTML 格式,但样式会自动转为文字样式。(直接使用字符串因记录器更新字符串而导致准确性逐步降低而被弃用。) | 153 | | ${statBar} | (beta)以 HTML 格式展示原始字数、删除字数和添加字数的比例。用于判断编辑是否为微调还是还是较大更改 | 仅表格 | ![image](https://github.com/user-attachments/assets/c0d929a7-5ea8-4172-9d85-5de5f46e02bd) | 自动转化样式为 SVG 比例条,颜色可以在 styles.css 中自定义。示例中的比例为 450:200:150。 | 154 | | ${lastModifiedTime} | 笔记的最后修改时间,你可以在插件设置中指定此项目的格式 | 表格、无序列表 | 2025-03-23 16:00 | | 155 | | ${totalEdits} | 所有笔记在一段时间内的总编辑次数 | 元数据 | 200 | 可供其他插件使用,例如生成热力图。 | 156 | | ${totalWords} | 所有笔记在一段时间内的总编辑字数 | 元数据 | 2000 | 可供其他插件使用,例如生成热力图。 | 157 | 158 | ## 开发路线图 159 | 参见[开发路线图](https://github.com/LeCheenaX/WordFlow-Tracker/wiki/Development-RoadMap)了解已知问题和计划功能! 160 | 161 | ## 安装 162 | ### 通过Obsidian插件市场安装 163 | 在Obsidian设置 > 社区插件中,点击浏览社区插件。搜索 “Wordflow Tracker” 并点击安装。 164 | 165 | ### 手动安装插件 166 | 将`main.js`、`manifest.json`、`styles.css`复制到你的仓库`VaultFolder/.obsidian/plugins/wordflow-tracker/`中。 167 | 168 | ### 通过BRAT安装 169 | 参见[BRAT文档](https://github.com/TfTHacker/obsidian42-brat)。 170 | 171 | ## 类似插件 172 | 这个轻量级插件试图以最少的障碍提供独特的周期性编辑跟踪体验。然而,如果你感兴趣,可以尝试以下替代方案: 173 | - [Obsipulse插件](https://github.com/jsifalda/obsipulse-plugin) 174 | - [每日文件记录器](https://github.com/ashlovepink/daily-file-logger) 175 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "wordflow-tracker", 3 | "name": "Wordflow Tracker", 4 | "version": "1.3.1", 5 | "minAppVersion": "1.7.0", 6 | "description": "Track the changes and stats of your edited note files automatically. Record the modified notes and statistics to your daily note with various customizations!.", 7 | "author": "LeCheenaX", 8 | "authorUrl": "https://github.com/LeCheenaX", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sample-plugin", 3 | "version": "1.0.1", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.17.3", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | }, 24 | "dependencies": { 25 | "@codemirror/commands": "^6.8.0", 26 | "@codemirror/language": "^6.10.8" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/DataRecorder.ts: -------------------------------------------------------------------------------- 1 | import WordflowTrackerPlugin, { RecorderConfig } from "./main"; 2 | import { DocTracker } from './DocTracker'; 3 | import { moment, Notice, TFile } from 'obsidian'; 4 | import { TableParser } from './TableParser'; 5 | import { BulletListParser } from './ListParser'; 6 | import { MetaDataParser } from "./MetaDataParser"; 7 | 8 | export class DataRecorder { 9 | public existingDataMap: Map = new Map(); 10 | private newDataMap: Map = new Map(); 11 | private enableDynamicFolder: boolean; 12 | private periodicNoteFolder: string; 13 | private periodicNoteFormat: string; 14 | public recordType: string; 15 | public timeFormat: string; 16 | public sortBy: string; 17 | public isDescend: boolean; 18 | public filterZero: boolean; 19 | public tableSyntax: string; 20 | public listSyntax: string; 21 | public metadataSyntax: string; 22 | public insertPlace: string; 23 | public insertPlaceStart: string; 24 | public insertPlaceEnd: string; 25 | 26 | // private classes 27 | private Parser: TableParser | BulletListParser | MetaDataParser; 28 | 29 | constructor( 30 | private plugin: WordflowTrackerPlugin, 31 | private trackerMap: Map, 32 | private config?: RecorderConfig 33 | //private tracker?: DocTracker, 34 | ){ 35 | this.loadSettings(); 36 | } 37 | 38 | public loadSettings(){ 39 | if (!this.config){ 40 | this.enableDynamicFolder = this.plugin.settings.enableDynamicFolder; 41 | this.periodicNoteFolder = this.plugin.settings.periodicNoteFolder; 42 | this.periodicNoteFormat = this.plugin.settings.periodicNoteFormat; 43 | this.recordType = this.plugin.settings.recordType; 44 | this.timeFormat = this.plugin.settings.timeFormat; 45 | this.sortBy = this.plugin.settings.sortBy; 46 | this.isDescend = this.plugin.settings.isDescend; 47 | this.filterZero = this.plugin.settings.filterZero; 48 | this.tableSyntax = this.plugin.settings.tableSyntax; 49 | this.listSyntax = this.plugin.settings.bulletListSyntax; 50 | this.metadataSyntax = this.plugin.settings.metadataSyntax; 51 | this.insertPlace = this.plugin.settings.insertPlace; 52 | this.insertPlaceStart = this.plugin.settings.insertPlaceStart; 53 | this.insertPlaceEnd = this.plugin.settings.insertPlaceEnd; 54 | } else { 55 | this.enableDynamicFolder = this.config.enableDynamicFolder; 56 | this.periodicNoteFolder = this.config.periodicNoteFolder; 57 | this.periodicNoteFormat = this.config.periodicNoteFormat; 58 | this.recordType = this.config.recordType; 59 | this.timeFormat = this.config.timeFormat; 60 | this.sortBy = this.config.sortBy; 61 | this.isDescend = this.config.isDescend; 62 | this.filterZero = this.config.filterZero; 63 | this.tableSyntax = this.config.tableSyntax; 64 | this.listSyntax = this.config.bulletListSyntax; 65 | this.metadataSyntax = this.config.metadataSyntax; 66 | this.insertPlace = this.config.insertPlace; 67 | this.insertPlaceStart = this.config.insertPlaceStart; 68 | this.insertPlaceEnd = this.config.insertPlaceEnd; 69 | } 70 | //new Notice(`Setting changed! Record type:${this.recordType}`, 3000) 71 | this.loadParsers(); 72 | } 73 | 74 | private loadParsers(){ 75 | switch (this.recordType){ 76 | case 'table': 77 | this.Parser = new TableParser(this, this.plugin); 78 | break; 79 | case 'bulletList': 80 | this.Parser = new BulletListParser(this, this.plugin); 81 | break; 82 | case 'metadata': 83 | this.Parser = new MetaDataParser(this, this.plugin); 84 | break; 85 | default: 86 | new Notice('⚠️ Record type is not defined in this recorder!'); 87 | throw new Error('⚠️ Record type is not defined in this recorder!'); 88 | } 89 | 90 | this.Parser.loadSettings() 91 | } 92 | 93 | public async record(tracker?:DocTracker): Promise { 94 | //console.log('try to Load Tracker of closed note:',tracker) 95 | // Load tracker data 96 | await this.loadTrackerData(tracker); 97 | //this.backUpData(); 98 | /* 99 | this.newDataMap.forEach((NewData)=>{ 100 | console.log('newData:', NewData.filePath, ' words:', NewData.editedWords) 101 | }) 102 | */ 103 | if (this.newDataMap.size == 0) return; 104 | // Get the target note file 105 | const recordNote = await this.getOrCreateRecordNote(); 106 | if (!recordNote) { 107 | new Notice ("⚠️ Failed to get or create record note!\nData backed up to console!"); 108 | console.error("⚠️ Failed to get or create record note"); 109 | await this.backUpData(); 110 | return; 111 | } 112 | //console.log('Current Parser:',this.recordType) 113 | // Load existing data 114 | await this.loadExistingData(recordNote); 115 | /* 116 | this.existingDataMap.forEach((ExistingData)=>{ 117 | console.log('existingData:', ExistingData.filePath, ' words:', ExistingData.editedWords) 118 | }) 119 | */ 120 | // Merge data 121 | let mergedData: MergedData[]; 122 | if (this.recordType != 'metadata'){ 123 | mergedData = this.mergeData(); 124 | } else { 125 | mergedData = this.mergeTotalData(); 126 | } 127 | //console.log('mergedData:',mergedData) 128 | // Generate and update content 129 | const newContent = this.Parser.generateContent(mergedData); 130 | //console.log('newContent:',newContent) 131 | switch (this.insertPlace){ 132 | case 'custom': 133 | await this.updateNoteToCustom(recordNote, newContent); 134 | break; 135 | case 'yaml': 136 | await this.updateNoteToYAML(recordNote, newContent); 137 | break 138 | default: // default insert to bottom if not found 139 | await this.updateNoteToBottom(recordNote, newContent); 140 | break; 141 | } 142 | } 143 | 144 | private async getOrCreateRecordNote(): Promise { 145 | const recordNoteName = moment().format(this.periodicNoteFormat); 146 | const recordNoteFolder = (this.enableDynamicFolder)? moment().format(this.periodicNoteFolder): this.periodicNoteFolder; 147 | const isRootFolder: boolean = (recordNoteFolder.trim() == '')||(recordNoteFolder.trim() == '/'); 148 | let recordNotePath = (isRootFolder)? '': recordNoteFolder+'/'; 149 | recordNotePath += recordNoteName + '.md'; 150 | let recordNote = this.plugin.app.vault.getFileByPath(recordNotePath); 151 | //console.log('recordNotePath:',recordNotePath) 152 | if (!recordNote) { 153 | try { 154 | if (!isRootFolder && !this.plugin.app.vault.getFolderByPath(recordNoteFolder.trim())) { 155 | try{ 156 | await this.plugin.app.vault.createFolder(recordNoteFolder.trim()) 157 | new Notice(`Periodic folder ${recordNoteFolder.trim()} doesn't exist!\n Auto created. `, 3000) 158 | } catch (error) { 159 | new Notice(`⚠️ Failed to create record note folder: ${error}\nData backed up to console!`); 160 | console.error("⚠️ Failed to create record note folder:", error); 161 | await this.backUpData(); 162 | return null; 163 | } 164 | } 165 | await this.plugin.app.vault.create(recordNotePath, ''); 166 | // Wait for file creation to complete 167 | await new Promise(resolve => setTimeout(resolve, 2000)); 168 | recordNote = this.plugin.app.vault.getFileByPath(recordNotePath); 169 | new Notice(`Periodic note ${recordNotePath} doesn't exist!\n Auto created under ${this.periodicNoteFolder}. `, 3000) 170 | } catch (error) { 171 | new Notice(`⚠️ Failed to create record note: ${error}\nData backed up to console!`) 172 | console.error("⚠️ Failed to create record note:", error); 173 | await this.backUpData(); 174 | return null; 175 | } 176 | } 177 | 178 | return recordNote; 179 | } 180 | 181 | private async loadExistingData(recordNote: TFile): Promise { 182 | this.existingDataMap = await this.Parser.extractData(recordNote); 183 | } 184 | 185 | private async loadTrackerData(p_tracker?:DocTracker): Promise { 186 | this.newDataMap.clear(); 187 | if (!p_tracker){ 188 | for (const [filePath, tracker] of this.trackerMap.entries()) { 189 | if (!this.filterZero || tracker.editedTimes!=0){ 190 | await tracker.countActiveWords(); // generate accurate words for NewData by the time of recording 191 | this.newDataMap.set(filePath, new NewData(tracker)); 192 | } 193 | tracker.resetEdit(); // deleted await for performance 194 | } 195 | } else { 196 | //console.log('trackerClosed:',p_tracker) 197 | if (!this.filterZero || p_tracker.editedTimes!=0){ 198 | // no active editor now 199 | await p_tracker.countInactiveWords(); // generate accurate words for NewData by the time of recording 200 | this.newDataMap.set(p_tracker.filePath, new NewData(p_tracker)); // only record given data 201 | } 202 | //console.log('newDataMap:', this.newDataMap); 203 | p_tracker.resetEdit(); // deleted await for performance 204 | } 205 | } 206 | 207 | private mergeData(): MergedData[] { 208 | const mergedDataMap = new Map(); 209 | 210 | // First add all new data 211 | for (const [filePath, newData] of this.newDataMap.entries()) { 212 | mergedDataMap.set(filePath, new MergedData(newData)); 213 | 214 | // If there's existing data for this file, merge it 215 | if (this.existingDataMap.has(filePath)) { 216 | const existingData = this.existingDataMap.get(filePath); 217 | const mergedData = mergedDataMap.get(filePath); 218 | if (mergedData && existingData) { // for passing ts 219 | mergedData.mergeWith(existingData); 220 | } 221 | } 222 | } 223 | 224 | //console.log('mergedDataMap:', mergedDataMap) 225 | // Add remaining existing data that has no new updates 226 | for (const [filePath, existingData] of this.existingDataMap.entries()) { 227 | if (!mergedDataMap.has(filePath)) { 228 | mergedDataMap.set(filePath, new MergedData(undefined, existingData)); 229 | } 230 | } 231 | //console.log('mergedDataMap:',mergedDataMap) 232 | 233 | // Convert to array for sorting 234 | const mergedData = Array.from(mergedDataMap.values()); 235 | 236 | // Sort the merged data 237 | mergedData.sort((a, b) => { 238 | let aVal: any, bVal: any; 239 | 240 | switch (this.sortBy) { 241 | case 'lastModifiedTime': 242 | aVal = typeof a.lastModifiedTime === 'number' ? a.lastModifiedTime : 0; 243 | bVal = typeof b.lastModifiedTime === 'number' ? b.lastModifiedTime : 0; 244 | break; 245 | case 'editedWords': 246 | aVal = a.editedWords; 247 | bVal = b.editedWords; 248 | break; 249 | case 'editedTimes': 250 | aVal = a.editedTimes; 251 | bVal = b.editedTimes; 252 | break; 253 | case 'editedPercentage': 254 | aVal = a.editedPercentage.percentage; 255 | bVal = b.editedPercentage.percentage; 256 | break; 257 | case 'modifiedNote': 258 | aVal = a.filePath; 259 | bVal = b.filePath; 260 | break; 261 | default: 262 | aVal = a.filePath; 263 | bVal = b.filePath; 264 | } 265 | 266 | // Apply sort direction 267 | if (typeof aVal === 'number' && typeof bVal === 'number') { 268 | return this.isDescend ? bVal - aVal : aVal - bVal; 269 | } 270 | 271 | // Handle string comparison 272 | return this.isDescend 273 | ? String(bVal).localeCompare(String(aVal)) 274 | : String(aVal).localeCompare(String(bVal)); 275 | }); 276 | 277 | return mergedData; 278 | } 279 | 280 | private mergeTotalData(): MergedData[] { 281 | const ExistingData = this.existingDataMap.get('|M|E|T|A|D|A|T|A|'); 282 | const MergedTotalData = new MergedData(); 283 | if (ExistingData){ 284 | MergedTotalData.totalEdits = ExistingData.totalEdits; 285 | MergedTotalData.totalWords = ExistingData.totalWords; 286 | } else { 287 | MergedTotalData.totalEdits = 0; 288 | MergedTotalData.totalWords = 0; 289 | } 290 | //console.log('Total Before merging:',[MergedTotalData]) 291 | for (const [filePath, newData] of this.newDataMap.entries()) { 292 | MergedTotalData.totalEdits += newData.editedTimes; 293 | MergedTotalData.totalWords += newData.editedWords; 294 | } 295 | //console.log('Total After merging:',[MergedTotalData]) 296 | return [MergedTotalData]; 297 | } 298 | 299 | private async backUpData(): Promise 300 | { 301 | const recorderName: string = (this.config)? this.config.name: this.plugin.settings.name; 302 | if (!this.newDataMap.size) 303 | console.log(`${recorderName}.backUpData: No data need to back up.`); 304 | else { 305 | console.groupCollapsed(`${recorderName}.backUpData: `); 306 | console.log('The following data is not recorded due to errors in the console. You can manually update these data to periodic notes.') 307 | this.newDataMap.forEach((NewData, key) => { 308 | console.log( 309 | `File: ${NewData.filePath}`, 310 | NewData 311 | ); 312 | }); 313 | console.groupEnd(); 314 | } 315 | } 316 | 317 | private async updateNoteToBottom(recordNote: TFile, newContent: string): Promise { 318 | const noteContent = await this.plugin.app.vault.read(recordNote); 319 | const lines = noteContent.split('\n'); 320 | 321 | const existingContent: string | null = await this.Parser.getContent(recordNote); 322 | 323 | if (existingContent){ 324 | await this.plugin.app.vault.process(recordNote, (data) => { 325 | return data.replace(existingContent, newContent.trimStart()); 326 | }); 327 | } else { // If no existing content of the right type found, append to the end of document 328 | const linebreaks = noteContent.endsWith('\n\n') ? '' : 329 | noteContent.endsWith('\n') ? '\n' : '\n\n'; 330 | 331 | await this.plugin.app.vault.process(recordNote, (data) => { 332 | return data.concat( linebreaks + newContent); 333 | }); 334 | } 335 | } 336 | 337 | private async updateNoteToCustom(recordNote: TFile, newContent: string): Promise { 338 | const noteContent = await this.plugin.app.vault.read(recordNote); 339 | 340 | if ((this.insertPlaceStart == '') || (this.insertPlaceEnd == '')){ 341 | await this.backUpData(); 342 | new Notice(`⚠️ Could not replace content without setting start place and end place!\nData backed up to console!`); 343 | throw new Error (`⚠️ Could not replace content without setting start place and end place!`); 344 | } 345 | 346 | const regex = new RegExp(`${this.insertPlaceStart}[\\s\\S]*?(?=${this.insertPlaceEnd})`); 347 | if (regex.test(noteContent)) { 348 | await this.plugin.app.vault.process(recordNote, (data) => { 349 | // build regular expressions across lines but not greedy matching 350 | const regex = new RegExp( 351 | `(${this.insertPlaceStart})(.*?)(${this.insertPlaceEnd})`, 352 | 's' // dotAll mode 353 | ); 354 | 355 | return data.replace(regex, `$1\n${newContent}\n$3`); 356 | }); 357 | } else { 358 | new Notice("⚠️ ERROR updating note: " + recordNote.path + "! Please check console error.\n" + "Data backed up to console!"); 359 | console.error(`⚠️ ERROR: The given pattern "${this.insertPlaceStart} ... ${this.insertPlaceEnd}" is not found in ${recordNote.path}!`); 360 | await this.backUpData(); 361 | } 362 | } 363 | 364 | private async updateNoteToYAML(recordNote: TFile, newContent: string): Promise { 365 | const existingContent: string | null = await this.Parser.getContent(recordNote); 366 | const [YAMLStartIndex, YAMLEndIndex]: [number, number] = await this.Parser.getIndex(recordNote); 367 | 368 | if (existingContent){ 369 | //console.log('existingContent:',existingContent) 370 | await this.plugin.app.vault.process(recordNote, (data) => { 371 | return data.replace(existingContent, newContent.trim()); 372 | }); 373 | } else if(YAMLStartIndex != -1){ // no existing data in yaml 374 | await this.plugin.app.vault.process(recordNote, (data) => { 375 | const dataLines = data.split('\n'); 376 | // Insert the new content before the closing '---' line 377 | dataLines.splice(YAMLEndIndex, 0, newContent.trimStart()); 378 | //console.log('datalines:',dataLines) 379 | return dataLines.join('\n'); 380 | }); 381 | } else { // no yaml, create one 382 | await this.plugin.app.vault.process(recordNote, (data) => { 383 | const yamlHeader = '---\n' + newContent.trim() + '\n---\n'; 384 | return yamlHeader + data; 385 | }); 386 | } 387 | } 388 | 389 | } 390 | 391 | // Class to represent data from existing records 392 | export class ExistingData { 393 | filePath: string; 394 | lastModifiedTime: number|null; 395 | editedWords: number; 396 | editedTimes: number; 397 | addedWords: number; 398 | deletedWords: number; 399 | changedWords: number; 400 | docWords: number; 401 | editedPercentage: EditedPercentage 402 | statBar: StatBar; 403 | totalWords: number; 404 | totalEdits: number; 405 | 406 | constructor() { 407 | this.lastModifiedTime = null; 408 | this.editedWords = 0; 409 | this.editedTimes = 0; 410 | this.addedWords = 0; 411 | this.deletedWords = 0; 412 | this.changedWords = 0; 413 | this.docWords = 0; 414 | this.editedPercentage = new EditedPercentage(); 415 | this.statBar = new StatBar(); 416 | this.totalWords = 0; 417 | this.totalEdits = 0; 418 | } 419 | } 420 | 421 | // Class to represent data from new DocTracker objects 422 | export class NewData { 423 | filePath: string; 424 | lastModifiedTime: number; 425 | editedWords: number; 426 | editedTimes: number; 427 | addedWords: number; 428 | deletedWords: number; 429 | changedWords: number; 430 | docWords: number; 431 | originalWords: number; 432 | editedPercentage: EditedPercentage 433 | statBar: StatBar; 434 | 435 | constructor(tracker: DocTracker) { 436 | this.filePath = tracker.filePath; 437 | this.lastModifiedTime = tracker.lastModifiedTime; 438 | this.editedWords = tracker.editedWords; 439 | this.editedTimes = tracker.editedTimes; 440 | this.addedWords = tracker.addedWords; 441 | this.deletedWords = tracker.deletedWords; 442 | this.changedWords = tracker.changedWords; 443 | this.docWords = tracker.docWords; 444 | this.originalWords = tracker.originalWords; 445 | this.editedPercentage = new EditedPercentage(); 446 | this.editedPercentage.fromTracker(tracker); 447 | this.statBar = new StatBar(); 448 | this.statBar.fromTracker(tracker); 449 | } 450 | } 451 | 452 | // Result of merging existing and new data 453 | export class MergedData { 454 | filePath: string; 455 | lastModifiedTime: number | string; 456 | editedWords: number; 457 | editedTimes: number; 458 | addedWords: number; 459 | deletedWords: number; 460 | changedWords: number; 461 | editedPercentage: EditedPercentage; 462 | statBar: StatBar; 463 | docWords: number; 464 | isNew: boolean; 465 | totalWords: number; 466 | totalEdits: number; 467 | 468 | constructor(newData?: NewData, existingData?: ExistingData) { 469 | if (newData) { 470 | this.filePath = newData.filePath; 471 | this.lastModifiedTime = newData.lastModifiedTime; 472 | this.editedWords = newData.editedWords; 473 | this.editedTimes = newData.editedTimes; 474 | this.addedWords = newData.addedWords; 475 | this.deletedWords = newData.deletedWords; 476 | this.changedWords = newData.changedWords; 477 | this.docWords = newData.docWords; 478 | this.editedPercentage = newData.editedPercentage; 479 | this.statBar = newData.statBar; 480 | this.isNew = true; 481 | } else if (existingData) { 482 | this.filePath = existingData.filePath; 483 | this.lastModifiedTime = existingData.lastModifiedTime? existingData.lastModifiedTime:''; 484 | this.editedWords = existingData.editedWords; 485 | this.editedTimes = existingData.editedTimes; 486 | this.addedWords = existingData.addedWords; 487 | this.deletedWords = existingData.deletedWords; 488 | this.changedWords = existingData.changedWords; 489 | this.docWords = existingData.docWords; 490 | this.editedPercentage = existingData.editedPercentage; 491 | this.statBar = existingData.statBar; 492 | this.isNew = false; 493 | } else { 494 | this.filePath = '|M|E|T|A|D|A|T|A|'; 495 | } 496 | } 497 | 498 | // Merge existing data into this record 499 | public mergeWith(existingData: ExistingData): void { 500 | // Add to accumulating fields 501 | this.editedWords += existingData.editedWords; 502 | this.editedTimes += existingData.editedTimes; 503 | this.addedWords += existingData.addedWords; 504 | this.deletedWords += existingData.deletedWords; 505 | this.changedWords += existingData.changedWords; 506 | 507 | // Let existing data outweighs the new data 508 | this.editedPercentage.setEdits( 509 | existingData.editedPercentage.originalWords, 510 | this.deletedWords, 511 | this.addedWords 512 | ) 513 | 514 | this.statBar.setEdits( 515 | existingData.statBar.originalWords, 516 | this.deletedWords, 517 | this.addedWords 518 | ) 519 | 520 | //console.log('newDocWords:', this.docWords) 521 | //console.log('newPercentage:',this.editedPercentage.percentage, '%') 522 | 523 | // Leave blank to let new data outweighs the unspecified properties (lastModifiedTime, docWords, etc.) 524 | } 525 | } 526 | 527 | class EditedPercentage{ 528 | originalWords: number; 529 | deletedWords: number; 530 | addedWords: number; 531 | percentage: number; 532 | 533 | public fromNote(editedPercentage: string): boolean{ 534 | const elemRegex = /]*?><\/span>/gi; 535 | 536 | let elemMatch; 537 | if ((elemMatch = elemRegex.exec(editedPercentage)) !== null) { 538 | const elem = elemMatch[0]; 539 | 540 | // extract Data, ignoring uppercase 541 | this.percentage = parseInt(elem.match(/data-percentage="([^"]*)"/i)?.[1] || "0"); 542 | this.originalWords = parseInt(elem.match(/data-originWords="([^"]*)"/i)?.[1] || "0"); 543 | this.deletedWords = parseInt(elem.match(/data-delWords="([^"]*)"/i)?.[1] || "0"); 544 | this.addedWords = parseInt(elem.match(/data-addWords="([^"]*)"/i)?.[1] || "0"); 545 | 546 | return true; 547 | } 548 | 549 | return false; 550 | } 551 | 552 | public fromTracker(tracker: DocTracker){ 553 | this.originalWords = tracker.originalWords; 554 | this.deletedWords = tracker.deletedWords; 555 | this.addedWords = tracker.addedWords; 556 | this.calcPercentage(); 557 | } 558 | 559 | // override current values with given values, and recalculate percentage. 560 | public setEdits(originalWords: number, deletedWords: number, addedWords: number){ 561 | this.originalWords = originalWords; 562 | this.deletedWords = deletedWords; 563 | this.addedWords = addedWords; 564 | this.calcPercentage(); 565 | } 566 | 567 | public toNote(): string{ 568 | return `` 569 | } 570 | 571 | private calcPercentage(){ 572 | this.percentage = 573 | (this.addedWords + this.deletedWords) 574 | /(this.originalWords + this.addedWords + this.deletedWords) 575 | *100; 576 | this.percentage = Math.floor(this.percentage); 577 | } 578 | } 579 | 580 | class StatBar{ 581 | originalWords: number; 582 | deletedWords: number; 583 | addedWords: number; 584 | originPortion: number; 585 | delPortion: number; 586 | addPortion: number; 587 | 588 | public fromNote(statBar: string): boolean{ 589 | const elemRegex = /]*?>/i; 590 | 591 | const elemMatch = elemRegex.exec(statBar); 592 | if (elemMatch) { 593 | const elem = elemMatch[0]; 594 | 595 | this.originalWords = parseInt(elem.match(/data-origin-words="([^"]*)"/i)?.[1] || "0"); 596 | this.deletedWords = parseInt(elem.match(/data-deleted-words="([^"]*)"/i)?.[1] || "0"); 597 | this.addedWords = parseInt(elem.match(/data-added-words="([^"]*)"/i)?.[1] || "0"); 598 | 599 | const originWidthMatch = statBar.match(/class="stat-bar origin"[^>]*?width:\s*(\d+)%/i); 600 | const deletedWidthMatch = statBar.match(/class="stat-bar deleted"[^>]*?width:\s*(\d+)%/i); 601 | const addedWidthMatch = statBar.match(/class="stat-bar added"[^>]*?width:\s*(\d+)%/i); 602 | 603 | this.originPortion = originWidthMatch ? parseInt(originWidthMatch[1]) : 0; 604 | this.delPortion = deletedWidthMatch ? parseInt(deletedWidthMatch[1]) : 0; 605 | this.addPortion = addedWidthMatch ? parseInt(addedWidthMatch[1]) : 0; 606 | 607 | return true; 608 | } 609 | 610 | return false; 611 | } 612 | 613 | public fromTracker(tracker: DocTracker){ 614 | this.originalWords = tracker.originalWords; 615 | this.deletedWords = tracker.deletedWords; 616 | this.addedWords = tracker.addedWords; 617 | this.calcPortions(); 618 | } 619 | 620 | // override current values with given values, and recalculate portions. 621 | public setEdits(originalWords: number, deletedWords: number, addedWords: number){ 622 | this.originalWords = originalWords; 623 | this.deletedWords = deletedWords; 624 | this.addedWords = addedWords; 625 | this.calcPortions(); 626 | } 627 | 628 | public toNote(): string{ 629 | return `` 630 | } 631 | 632 | private calcPortions(){ 633 | this.originPortion = 634 | (this.originalWords) 635 | /(this.originalWords + this.addedWords + this.deletedWords) 636 | *100; 637 | this.originPortion = Math.floor(this.originPortion); 638 | 639 | this.delPortion = 640 | (this.deletedWords) 641 | /(this.originalWords + this.addedWords + this.deletedWords) 642 | *100; 643 | this.delPortion = Math.floor(this.delPortion); 644 | 645 | this.addPortion = 100 - this.delPortion - this.originPortion 646 | } 647 | } -------------------------------------------------------------------------------- /src/DocTracker.ts: -------------------------------------------------------------------------------- 1 | import { debounce, Editor, EventRef, MarkdownView, Plugin, TFile, Stat, View, ViewState, Notice } from "obsidian"; 2 | import WordflowTrackerPlugin from "./main"; 3 | import { wordsCounter } from "./stats"; 4 | import { historyField } from "@codemirror/commands"; 5 | 6 | const DEBUG = true as const; 7 | 8 | export class DocTracker{ 9 | public lastDone: number = 0; 10 | public lastUndone: number = 0; 11 | public editedTimes: number = 0; 12 | public editedWords: number = 0; 13 | public addedWords: number = 0; 14 | public deletedWords: number = 0; 15 | public changedWords: number = 0; 16 | public isActive: boolean = false; 17 | public lastModifiedTime: number; // unix timestamp 18 | public docLength: number = 0; 19 | public docWords: number = 0; 20 | public originalWords: number = 0; 21 | 22 | private debouncedTracker: ReturnType | null; 23 | private editorListener: EventRef | null = null; 24 | private addWordsCt: Function 25 | private deleteWordsCt: Function 26 | 27 | constructor( 28 | public filePath: string, 29 | private activeEditor: MarkdownView | null, 30 | private plugin: WordflowTrackerPlugin, 31 | ) { 32 | this.initialize(); 33 | } 34 | 35 | private async initialize() { 36 | this.lastModifiedTime = Number(this.plugin.app.vault.getFileByPath(this.filePath)?.stat.mtime); 37 | this.addWordsCt = wordsCounter(); 38 | this.deleteWordsCt = wordsCounter(); 39 | await this.activate(); 40 | await this.countOrigin(); 41 | await sleep(1000); // when open new notes, update with delay 42 | this.updateStatusBarTracker(); 43 | 44 | // if (DEBUG) console.log(`DocTracker.initialize: created for ${this.filePath}`); 45 | } 46 | 47 | private trackChanges() { 48 | //if (DEBUG) console.log("DocTracker.trackChanges: tracking history changes of ", this.activeEditor?.file?.path); 49 | 50 | // direct way to prove means cm destoyed, without having to find activeEditor.cm.destroyed: true. 51 | /*if (!this.activeEditor?.file) { 52 | //@ts1-expect-error 53 | const closedFileName:string = this.activeEditor?.inlineTitleEl.textContent; 54 | this.plugin.trackerMap.delete(closedFileName); 55 | if (DEBUG) console.log("DocTracker.trackChanges: closed ", closedFileName); 56 | return; 57 | }*/ 58 | /* Given up Protection */ 59 | // the following may be necessary because of the release function 60 | /* 61 | if (this.activeEditor.file?.basename != this.fileName) { 62 | this.plugin.trackerMap.set(this.activeEditor.file?.basename, new Map(this, false)); 63 | console.log("Set inactive by historyTracker:", this.activeEditor.file?.basename); //debug 64 | return; 65 | } // protection 66 | */ 67 | // @ts-expect-error 68 | const history = this.activeEditor?.editor.cm.state.field(historyField); // changed on 1.3.0, add delay to avoid error, rather than disable the error prompting 69 | // done on 1.0.1 | abandon false positive errors prompting 70 | 71 | //console.log('historyField:', this.activeEditor?.editor.cm.state.field(historyField)) 72 | 73 | const currentDone = history.done.length; 74 | const currentUndone = history.undone.length; 75 | 76 | // 计算变化差异(原核心逻辑) 77 | const doneDiff: number = currentDone - this.lastDone; 78 | const undoneDiff: number = currentUndone - this.lastUndone; // warn: may be reset to 0 when undo and do sth. 79 | const historyCleared: number = ((currentDone + undoneDiff) < this.lastDone)?(this.lastDone - 100 - undoneDiff):0; // cannot use <= lastDone because of a debounce bug 80 | /* 81 | if(DEBUG){ 82 | //@ts-expect-error 83 | let doc = this.activeEditor.editor.cm.state.sliceDoc(0); // return string 84 | // @ts-expect-error 85 | let docLength = this.activeEditor.editor.cm.state.doc.length; 86 | console.log("DocTracker.trackChanges: 捕获操作:", { 87 | done: currentDone, 88 | undone: currentUndone, 89 | lastDone: this.lastDone, 90 | lastUndone: this.lastUndone, 91 | doneDiff: doneDiff, 92 | undoneDiff: undoneDiff, 93 | docLength: docLength, 94 | doc: doc, 95 | lastChangedTime: this.changedTimes, 96 | lastChangedWords: this.editedWords, 97 | }); 98 | console.log("DocTracker.trackChanges: CurrentHistory:", history); 99 | 100 | if (historyCleared){ 101 | console.log("DocTracker.trackChanges: Detected ", historyCleared, " cleared history events!"); 102 | } 103 | } 104 | */ 105 | 106 | // done | need to exclude the case where initial history done state has blank value but length is 1 107 | // done | need to ensure that toA will not change before printing, as a long changes may be joining into one done event. This requires to check if no inputting and if yes pause 0.5 second. 108 | // only done events need to consider separated inputs that may not be caught by the debouncer function 109 | if ((doneDiff + historyCleared > 0) && currentDone > 1 ){ 110 | for ( let i=(doneDiff+historyCleared); i>0; i--){ 111 | history.done[currentDone-i].changes.iterChanges((fromA:Number, toA:Number, fromB:Number, toB:Number, inserted: string | Text ) => { // inserted is Text in cm, string|Text in Obsidian 112 | //@ts-expect-error 113 | const theOther = this.activeEditor.editor.cm.state.sliceDoc(fromA,toA); 114 | inserted = inserted.toString(); 115 | 116 | //@ts-expect-error 117 | let addFix = (fromA!=0 && this.activeEditor.editor.cm.state.sliceDoc(fromA-1,fromA) == ' ')?1 :0; 118 | //@ts-expect-error 119 | let deleteFix = (fromB!=0 && this.activeEditor.editor.cm.state.sliceDoc(fromB-1,fromB) == ' ')?1 :0; 120 | 121 | const addedWords = this.addWordsCt(theOther) + addFix; 122 | const deletedWords = this.deleteWordsCt(inserted) + deleteFix; 123 | const mWords = addedWords + deletedWords; 124 | //console.log('Do added:', addedWords, '\nDo deleted:', deletedWords, 'total:', mWords) 125 | /* if (DEBUG){ 126 | console.log(`Do adding texts: "${theOther}" from ${fromA} to ${toA} in current document, \ndo deleting texts: "${inserted}" from ${fromB} to ${toB} in current document.`); 127 | //console.log("Modified Words: ", mWords); 128 | } 129 | */ 130 | this.editedWords += mWords; 131 | this.addedWords += addedWords; 132 | this.deletedWords += deletedWords; 133 | this.changedWords += (addedWords - deletedWords); 134 | }); 135 | } 136 | 137 | this.editedTimes += (doneDiff + historyCleared); // multiple changes should be counted only one time. 138 | } 139 | 140 | // done | when fixed doneDiff is detected minus, and done events is added to undone. 141 | if ((doneDiff + historyCleared < 0)&&((undoneDiff + doneDiff + historyCleared) == 0 )){ 142 | history.undone[currentUndone-1].changes.iterChanges((fromA:Number, toA:Number, fromB:Number, toB:Number, inserted: string | Text ) => { // inserted is Text in cm, string|Text in Obsidian 143 | // @ts-expect-error 144 | const theOther = this.activeEditor.editor.cm.state.sliceDoc(fromA,toA); 145 | inserted = inserted.toString(); 146 | 147 | const deletedWords = this.addWordsCt(inserted); 148 | const addedWords = this.deleteWordsCt(theOther); 149 | const mWords = addedWords + deletedWords; 150 | /* if (DEBUG) { 151 | console.log(`Undo adding texts: "${inserted}" from ${fromB} to ${toB} from previous document, \nundo deleting texts: "${theOther}" from ${fromA} to ${toA} from previous document.`); 152 | console.log("Modified Words: ", mWords); 153 | } 154 | */ 155 | this.editedWords += mWords; 156 | this.addedWords += addedWords; 157 | this.deletedWords += deletedWords; 158 | this.changedWords += (addedWords - deletedWords); 159 | }); 160 | 161 | this.editedTimes += undoneDiff; // multiple changes should be counted only one time. 162 | } 163 | 164 | 165 | 166 | this.lastDone = currentDone; 167 | this.lastUndone = currentUndone; 168 | if (Number(history.prevTime) !== 0) this.lastModifiedTime = Number(history.prevTime); 169 | this.updateStatusBarTracker(); 170 | /* 171 | console.log(`DocTracker.trackChanges: [${this.filePath}]:`, { 172 | currentEditedTimes: this.editedTimes, 173 | currentEditedWords: this.editedWords, 174 | AddedWords: this.addedWords, 175 | DeletedWords: this.deletedWords, 176 | ChangedWords: this.changedWords, 177 | lastRecordedWords: this.docWords, 178 | lastModifiedTime: this.lastModifiedTime, 179 | }); 180 | */ 181 | } 182 | 183 | private updateStatusBarTracker(){ 184 | this.plugin.statusBarContent = `${this.editedTimes}` + ' edits: ' + `${this.editedWords}` + ' words'; 185 | //if(DEBUG) this.plugin.statusBarContent += ` ${this.filePath}`; 186 | this.plugin.statusBarTrackerEl.setText(this.plugin.statusBarContent); 187 | //if (DEBUG) console.log(`UpdateStatusBar: ${this.plugin.statusBarContent}`); 188 | } 189 | 190 | 191 | public async activate(){ 192 | if(!this.plugin.app.workspace.getActiveViewOfType(MarkdownView)) { 193 | if(DEBUG) console.log("DocTracker.activate: No active editor!"); 194 | return; 195 | } 196 | if(this.isActive) return; // ensure currently not active 197 | 198 | this.activeEditor = this.plugin.app.workspace.getActiveViewOfType(MarkdownView); 199 | // if (DEBUG) console.log("DocTracker.activate: editor:", this.activeEditor) 200 | await sleep(20); // Warning: cm will be delayed for 3-5 ms to be bound to the updated editor. 201 | // @ts-expect-error 202 | const history = this.activeEditor?.editor.cm.state.field(historyField); // reference will be destroyed after initialization 203 | 204 | this.lastDone = (history.done.length>1)? history.done.length: 1; 205 | this.lastUndone = history.undone.length; 206 | //@ts-expect-error 207 | this.docLength = this.activeEditor?.editor.cm.state.doc.length; 208 | 209 | // 创建独立防抖实例 210 | this.debouncedTracker = debounce(this.trackChanges.bind(this), 1000, true); // Modified from official value 500 ms to 1000 ms, for execution delay. Modified from 1000 to 800 to test. 211 | 212 | // 绑定编辑器事件 213 | this.editorListener = this.plugin.app.workspace.on('editor-change', (editor: Editor, view: MarkdownView) => { 214 | if (this.debouncedTracker != null) 215 | this.debouncedTracker(); 216 | //console.log('DocTracker.activate: listener registered') 217 | }); 218 | this.updateStatusBarTracker(); 219 | this.isActive = true; 220 | } 221 | 222 | public release(){ 223 | if (this.debouncedTracker) 224 | this.debouncedTracker.run(); 225 | else { 226 | new Notice ("Tracker is cleared before releasing!"); 227 | console.error("Tracker is cleared before releasing!"); 228 | } 229 | // if (DEBUG) console.log(`Tracker released for: ${this.filePath}`); 230 | }; 231 | 232 | public async countActiveWords(){ 233 | const totalWordsCt = wordsCounter(); 234 | //@ts-expect-error 235 | this.docWords = totalWordsCt(this.activeEditor?.editor.cm.state.sliceDoc(0)); 236 | } 237 | 238 | public async countInactiveWords(){ 239 | this.docWords = this.originalWords + this.changedWords; 240 | } 241 | 242 | public deactivate(){ 243 | if (this.editorListener) { 244 | this.plugin.app.workspace.offref(this.editorListener); 245 | this.editorListener = null; 246 | } 247 | if (this.isActive) { 248 | this.release(); 249 | this.isActive = false; // ensure that this will only run once 250 | // if (DEBUG) console.log("DocTracker.deactivate: Set ", this.filePath," inactive!"); // debug 251 | } 252 | this.debouncedTracker = null; // dereference the debouncer 253 | } 254 | 255 | public async resetEdit(){ 256 | await sleep(500); // for multiple recorders to record before cleared. 257 | this.editedTimes = 0; 258 | this.editedWords = 0; 259 | this.addedWords = 0; 260 | this.deletedWords = 0; 261 | this.changedWords = 0; 262 | this.updateStatusBarTracker(); 263 | } 264 | 265 | // Warning: Do not use! This will destroy even the editor of Obsidian! Let Obsidian decide when to destroy! 266 | /* 267 | public destroy(){ 268 | this.deactivate(); 269 | this.activeEditor = null; 270 | this.plugin.trackerMap.delete(this.filePath); 271 | // prevent more than one calls 272 | this.destroy = () => { 273 | throw new Error('DocTracker instance already destroyed'); 274 | }; 275 | } 276 | */ 277 | 278 | private async countOrigin(){ 279 | const totalWordsCt = wordsCounter(); 280 | await sleep(100); // set delay for the activeEditor to load 281 | //@ts-expect-error 282 | this.originalWords = totalWordsCt(this.activeEditor?.editor.cm.state.sliceDoc(0)); 283 | } 284 | } 285 | 286 | -------------------------------------------------------------------------------- /src/ListParser.ts: -------------------------------------------------------------------------------- 1 | import { DataRecorder, ExistingData, MergedData } from "./DataRecorder"; 2 | import { moment, Plugin, TFile } from 'obsidian'; 3 | import WordflowTrackerPlugin from "./main"; 4 | 5 | export class BulletListParser{ 6 | //private recordType: string; 7 | private timeFormat: string; 8 | //private sortBy: string; 9 | //private isDescend: boolean; 10 | private syntax: string; 11 | private noteContent: string | null; 12 | 13 | // special variables 14 | private patterns: any[] = []; 15 | private bulletListPatterns: any[] = []; 16 | private patternLineNum: number; 17 | 18 | constructor( 19 | private DataRecorder: DataRecorder, 20 | private plugin: WordflowTrackerPlugin 21 | ){} 22 | 23 | public loadSettings(){ 24 | this.timeFormat = this.DataRecorder.timeFormat; 25 | //this.sortBy = this.DataRecorder.sortBy; 26 | //this.isDescend = this.DataRecorder.isDescend; 27 | this.syntax = this.DataRecorder.listSyntax; 28 | this.setBulletListPatterns(); // has varName 29 | this.setPatterns(); // doesnot has varName 30 | } 31 | 32 | public async extractData(recordNote: TFile): Promise< Map > { 33 | this.noteContent = await this.plugin.app.vault.read(recordNote); 34 | const lines = this.noteContent.split('\n'); 35 | const existingDataMap: Map = new Map(); 36 | 37 | // Find list groups by matching patterns 38 | for (let i = 0; i < lines.length; i++) { 39 | let isGroupStart = true; 40 | let groupData: Record = {}; 41 | 42 | // Check if current line could be start of a list group 43 | for (let j = 0; j < this.patternLineNum; j++) { 44 | const pattern = this.bulletListPatterns[j]; 45 | const lineToCheck = i + j; 46 | 47 | if (lineToCheck >= lines.length) { 48 | isGroupStart = false; 49 | break; 50 | } 51 | 52 | const currentLine = lines[lineToCheck]; 53 | const matchesStart = currentLine.startsWith(pattern.start); 54 | const matchesEnd = pattern.end === '\n' || 55 | currentLine.endsWith(pattern.end.replace('\n', '')); 56 | 57 | if (!matchesStart || !matchesEnd) { 58 | isGroupStart = false; 59 | break; 60 | } 61 | 62 | // Extract value if pattern has a variable name 63 | if (pattern.varName) { 64 | const startPos = pattern.start.length; 65 | const endPos = pattern.end === '\n' ? 66 | currentLine.length : 67 | currentLine.length - pattern.end.replace('\n', '').length; 68 | 69 | // Map variable names from template to property names in ListData 70 | const varValue = currentLine.substring(startPos, endPos); 71 | 72 | groupData[pattern.varName] = varValue; 73 | 74 | } 75 | } 76 | 77 | if (isGroupStart) { 78 | const ListData = new ExistingData(); 79 | if (groupData.modifiedNote) { 80 | ListData.filePath = groupData.modifiedNote; 81 | } 82 | 83 | // Parse editedWords 84 | if (groupData.editedWords !== undefined) { 85 | ListData.editedWords = parseInt(groupData.editedWords) || 0; 86 | } 87 | 88 | // Parse editedTimes 89 | if (groupData.editedTimes !== undefined) { 90 | ListData.editedTimes = parseInt(groupData.editedTimes) || 0; 91 | } 92 | 93 | // Parse 4 newly added words data 94 | if (groupData.addedWords !== undefined) { 95 | ListData.addedWords = parseInt(groupData.addedWords) || 0; 96 | } 97 | 98 | if (groupData.deletedWords !== undefined) { 99 | ListData.deletedWords = parseInt(groupData.deletedWords) || 0; 100 | } 101 | 102 | if (groupData.changedWords !== undefined) { 103 | ListData.changedWords = parseInt(groupData.changedWords) || 0; 104 | } 105 | 106 | if (groupData.docWords !== undefined) { 107 | ListData.docWords = parseInt(groupData.docWords) || 0; 108 | } 109 | 110 | // Parse lastModifiedTime if present 111 | if (groupData.lastModifiedTime !== undefined) { 112 | try { 113 | ListData.lastModifiedTime = moment(groupData.lastModifiedTime, this.timeFormat).valueOf(); 114 | } catch (e) { 115 | ListData.lastModifiedTime = null; 116 | } 117 | } else { 118 | ListData.lastModifiedTime = null; 119 | } 120 | 121 | // Parse percentage 122 | if (groupData.editedPercentage !== undefined) { 123 | ListData.editedPercentage.fromNote(groupData.editedPercentage); 124 | } 125 | 126 | if (groupData.statBar !== undefined){ 127 | ListData.statBar.fromNote(groupData.statBar) 128 | } 129 | 130 | existingDataMap.set(ListData.filePath, ListData); 131 | 132 | // Skip to the end of this group to continue search 133 | i += this.patternLineNum - 1; 134 | } 135 | } 136 | 137 | return existingDataMap; 138 | } 139 | 140 | // get the starting index of the array, element of which records the line number and the line content of the noteContent 141 | public async getIndex(recordNote: TFile): Promise<[number, number]> { 142 | if(!this.noteContent) this.noteContent = await this.plugin.app.vault.read(recordNote); 143 | const lines = this.noteContent.split('\n'); 144 | let startLine = -1; 145 | let endLine = -1; 146 | 147 | // Find list groups by matching patterns 148 | for (let i = 0; i < lines.length; i++) { 149 | let isGroupStart = true; 150 | 151 | // Check if current line could be start of a list group 152 | for (let j = 0; j < this.patternLineNum; j++) { 153 | const pattern = this.patterns[j]; 154 | const lineToCheck = i + j; 155 | 156 | if (lineToCheck >= lines.length) { 157 | isGroupStart = false; 158 | break; 159 | } 160 | 161 | const currentLine = lines[lineToCheck]; 162 | const matchesStart = currentLine.startsWith(pattern.start); 163 | const matchesEnd = pattern.end === '\n' || 164 | currentLine.endsWith(pattern.end.replace('\n', '')); 165 | 166 | if (!matchesStart || !matchesEnd) { 167 | isGroupStart = false; 168 | break; 169 | } 170 | } 171 | 172 | if (isGroupStart) { 173 | // Found a valid list group 174 | if (startLine === -1) { 175 | startLine = i; 176 | } 177 | 178 | // Update end line to include this group 179 | endLine = i + this.patternLineNum - 1; 180 | 181 | // Skip to the end of this group to continue search 182 | i = endLine; 183 | } 184 | } 185 | 186 | return (startLine !== -1 && endLine !== -1)? [startLine, endLine]: [-1, -1]; 187 | } 188 | 189 | public async getContent(recordNote: TFile): Promise { 190 | // Get list boundaries 191 | if(!this.noteContent) this.noteContent = await this.plugin.app.vault.read(recordNote); 192 | const [startIndex, endIndex] = await this.getIndex(recordNote); 193 | const lines = this.noteContent.split('\n'); 194 | 195 | if (startIndex != -1 && endIndex != -1){ 196 | const listContent = lines.slice(startIndex, endIndex + 1).join('\n'); 197 | 198 | return listContent; 199 | } else { 200 | return null; 201 | } 202 | } 203 | 204 | public generateContent(mergedData: MergedData[]): string { 205 | // Implement bullet list generation 206 | let output = '\n'; 207 | 208 | for (const data of mergedData) { 209 | let line = this.syntax 210 | .replace(/\${modifiedNote}/g, data.filePath) 211 | .replace(/\${lastModifiedTime}/g, typeof data.lastModifiedTime === 'number' 212 | ? moment(data.lastModifiedTime).format(this.timeFormat) 213 | : data.lastModifiedTime as string) 214 | .replace(/\${editedWords}/g, data.editedWords.toString()) 215 | .replace(/\${editedTimes}/g, data.editedTimes.toString()) 216 | .replace(/\${addedWords}/g, data.addedWords.toString()) 217 | .replace(/\${deletedWords}/g, data.deletedWords.toString()) 218 | .replace(/\${changedWords}/g, data.changedWords.toString()) 219 | .replace(/\${docWords}/g, data.docWords.toString()) 220 | .replace(/\${editedPercentage}/g, data.editedPercentage.toNote()) 221 | .replace(/\${statBar}/g, data.statBar.toNote()); 222 | 223 | output += (line.endsWith('\n'))? line : line + '\n'; 224 | } 225 | 226 | return output.trim(); 227 | } 228 | 229 | private setBulletListPatterns(){ 230 | // Parse the list syntax to extract patterns 231 | const syntaxLines = this.syntax.split('\n'); 232 | this.patternLineNum = syntaxLines.length; 233 | 234 | // Extract patterns from each line of syntax 235 | for (let i = 0; i < this.patternLineNum; i++) { 236 | const line = syntaxLines[i]; 237 | const varStart = line.indexOf('${'); 238 | 239 | if (varStart === -1) { 240 | this.bulletListPatterns.push({ start: line, end: '\n', varName: '' }); 241 | continue; 242 | } 243 | 244 | const varEnd = line.indexOf('}', varStart); 245 | if (varEnd === -1) { 246 | this.bulletListPatterns.push({ start: line.substring(0, varStart), end: '\n', varName: '' }); 247 | continue; 248 | } 249 | 250 | const varName = line.substring(varStart + 2, varEnd); 251 | this.bulletListPatterns.push({ 252 | start: line.substring(0, varStart), 253 | end: line.substring(varEnd + 1), 254 | varName 255 | }); 256 | } 257 | } 258 | 259 | private setPatterns(){ 260 | // Parse the list syntax to extract patterns 261 | const syntaxLines = this.syntax.split('\n'); 262 | this.patternLineNum = syntaxLines.length; 263 | 264 | // Extract patterns from each line of syntax 265 | for (let i = 0; i < this.patternLineNum; i++) { 266 | const line = syntaxLines[i]; 267 | const varStart = line.indexOf('${'); 268 | 269 | if (varStart === -1) { 270 | this.patterns.push({ start: line, end: '\n' }); 271 | continue; 272 | } 273 | 274 | const varEnd = line.indexOf('}', varStart); 275 | if (varEnd === -1) { 276 | this.patterns.push({ start: line.substring(0, varStart), end: '\n' }); 277 | continue; 278 | } 279 | 280 | this.patterns.push({ 281 | start: line.substring(0, varStart), 282 | end: line.substring(varEnd + 1) 283 | }); 284 | } 285 | } 286 | }; 287 | 288 | // used for list data that belong to a single list group, not multiple list groups 289 | /* 290 | export class ListParser{ 291 | //private recordType: string; 292 | private timeFormat: string; 293 | //private sortBy: string; 294 | //private isDescend: boolean; 295 | private syntax: string; 296 | 297 | // special variables 298 | private patterns: any[] = []; 299 | private listPatterns: any[] = []; 300 | private patternLineNum: number; 301 | 302 | constructor( 303 | private DataRecorder: DataRecorder, 304 | ){} 305 | 306 | public loadSettings(){ 307 | this.timeFormat = this.DataRecorder.timeFormat; 308 | //this.sortBy = this.DataRecorder.sortBy; 309 | //this.isDescend = this.DataRecorder.isDescend; 310 | this.syntax = this.DataRecorder.listSyntax; 311 | this.setListPatterns(); // has varName 312 | this.setPatterns(); // doesnot has varName 313 | } 314 | 315 | public async extractData(noteContent: string): Promise< ExistingData | null > { 316 | const lines = noteContent.split('\n'); 317 | 318 | // Find list groups by matching patterns 319 | for (let i = 0; i < lines.length; i++) { 320 | let isGroupStart = true; 321 | let groupData: Record = {}; 322 | 323 | // Check if current line could be start of a list group 324 | for (let j = 0; j < this.patternLineNum; j++) { 325 | const pattern = this.listPatterns[j]; 326 | const lineToCheck = i + j; 327 | 328 | if (lineToCheck >= lines.length) { 329 | isGroupStart = false; 330 | break; 331 | } 332 | 333 | const currentLine = lines[lineToCheck]; 334 | const matchesStart = currentLine.startsWith(pattern.start); 335 | const matchesEnd = pattern.end === '\n' || 336 | currentLine.endsWith(pattern.end.replace('\n', '')); 337 | 338 | if (!matchesStart || !matchesEnd) { 339 | isGroupStart = false; 340 | break; 341 | } 342 | 343 | // Extract value if pattern has a variable name 344 | if (pattern.varName) { 345 | const startPos = pattern.start.length; 346 | const endPos = pattern.end === '\n' ? 347 | currentLine.length : 348 | currentLine.length - pattern.end.replace('\n', '').length; 349 | 350 | // Map variable names from template to property names in ListData 351 | const varValue = currentLine.substring(startPos, endPos); 352 | 353 | groupData[pattern.varName] = varValue; 354 | 355 | } 356 | } 357 | 358 | if (isGroupStart) { 359 | const ListData = new ExistingData(); 360 | if (groupData.modifiedNote) { 361 | ListData.filePath = groupData.modifiedNote; 362 | } 363 | 364 | // Parse editedWords 365 | if (groupData.editedWords !== undefined) { 366 | ListData.editedWords = parseInt(groupData.editedWords) || 0; 367 | } 368 | 369 | // Parse editedTimes 370 | if (groupData.editedTimes !== undefined) { 371 | ListData.editedTimes = parseInt(groupData.editedTimes) || 0; 372 | } 373 | 374 | // Parse lastModifiedTime if present 375 | if (groupData.lastModifiedTime !== undefined) { 376 | try { 377 | ListData.lastModifiedTime = Date.parse(groupData.lastModifiedTime); 378 | } catch (e) { 379 | ListData.lastModifiedTime = null; 380 | } 381 | } else { 382 | ListData.lastModifiedTime = null; 383 | } 384 | 385 | // Calculate percentage 386 | ListData.editedPercentage = ListData.editedWords > 0 ? 387 | Math.floor((ListData.editedWords / 1) * 100) + '%' : '0%'; 388 | 389 | return ListData; 390 | // Skip to the end of this group to continue search 391 | //i += this.patternLineNum - 1; 392 | } 393 | } 394 | 395 | // No list group is detected after iteration 396 | return null; 397 | } 398 | 399 | // get the starting index of the array, element of which records the line number and the line content of the noteContent 400 | public getIndex(noteContent: string): [number, number] { 401 | const lines = noteContent.split('\n'); 402 | let startLine = -1; 403 | let endLine = -1; 404 | 405 | // Find list groups by matching patterns 406 | for (let i = 0; i < lines.length; i++) { 407 | let isGroupStart = true; 408 | 409 | // Check if current line could be start of a list group 410 | for (let j = 0; j < this.patternLineNum; j++) { 411 | const pattern = this.patterns[j]; 412 | const lineToCheck = i + j; 413 | 414 | if (lineToCheck >= lines.length) { 415 | isGroupStart = false; 416 | break; 417 | } 418 | 419 | const currentLine = lines[lineToCheck]; 420 | const matchesStart = currentLine.startsWith(pattern.start); 421 | const matchesEnd = pattern.end === '\n' || 422 | currentLine.endsWith(pattern.end.replace('\n', '')); 423 | 424 | if (!matchesStart || !matchesEnd) { 425 | isGroupStart = false; 426 | break; 427 | } 428 | } 429 | 430 | if (isGroupStart) { 431 | // Found a valid list group 432 | if (startLine === -1) { 433 | startLine = i; 434 | } 435 | 436 | // Update end line to include this group 437 | endLine = i + this.patternLineNum - 1; 438 | 439 | // Skip to the end of this group to continue search 440 | i = endLine; 441 | } 442 | } 443 | 444 | return (startLine !== -1 && endLine !== -1)? [startLine, endLine]: [-1, -1]; 445 | } 446 | 447 | public generateContent(mergedData: MergedData[]): string { 448 | // Implement bullet list generation 449 | let output = '\n'; 450 | 451 | for (const data of mergedData) { 452 | let line = this.syntax 453 | .replace(/\${modifiedNote}/g, data.filePath) 454 | .replace(/\${lastModifiedTime}/g, typeof data.lastModifiedTime === 'number' 455 | ? moment(data.lastModifiedTime).format(this.timeFormat) 456 | : data.lastModifiedTime as string) 457 | .replace(/\${editedWords}/g, data.editedWords.toString()) 458 | .replace(/\${editedTimes}/g, data.editedTimes.toString()) 459 | .replace(/\${editedPercentage}/g, data.editedPercentage); 460 | 461 | output += line + '\n'; 462 | } 463 | 464 | return output; 465 | } 466 | 467 | private setListPatterns(){ 468 | // Parse the list syntax to extract patterns 469 | const syntaxLines = this.syntax.split('\n'); 470 | this.patternLineNum = syntaxLines.length; 471 | 472 | // Extract patterns from each line of syntax 473 | for (let i = 0; i < this.patternLineNum; i++) { 474 | const line = syntaxLines[i]; 475 | const varStart = line.indexOf('${'); 476 | 477 | if (varStart === -1) { 478 | this.listPatterns.push({ start: line, end: '\n', varName: '' }); 479 | continue; 480 | } 481 | 482 | const varEnd = line.indexOf('}', varStart); 483 | if (varEnd === -1) { 484 | this.listPatterns.push({ start: line.substring(0, varStart), end: '\n', varName: '' }); 485 | continue; 486 | } 487 | 488 | const varName = line.substring(varStart + 2, varEnd); 489 | this.listPatterns.push({ 490 | start: line.substring(0, varStart), 491 | end: line.substring(varEnd + 1), 492 | varName 493 | }); 494 | } 495 | } 496 | 497 | private setPatterns(){ 498 | // Parse the list syntax to extract patterns 499 | const syntaxLines = this.syntax.split('\n'); 500 | this.patternLineNum = syntaxLines.length; 501 | 502 | // Extract patterns from each line of syntax 503 | for (let i = 0; i < this.patternLineNum; i++) { 504 | const line = syntaxLines[i]; 505 | const varStart = line.indexOf('${'); 506 | 507 | if (varStart === -1) { 508 | this.patterns.push({ start: line, end: '\n' }); 509 | continue; 510 | } 511 | 512 | const varEnd = line.indexOf('}', varStart); 513 | if (varEnd === -1) { 514 | this.patterns.push({ start: line.substring(0, varStart), end: '\n' }); 515 | continue; 516 | } 517 | 518 | this.patterns.push({ 519 | start: line.substring(0, varStart), 520 | end: line.substring(varEnd + 1) 521 | }); 522 | } 523 | } 524 | }; 525 | */ -------------------------------------------------------------------------------- /src/MetaDataParser.ts: -------------------------------------------------------------------------------- 1 | import { DataRecorder, ExistingData, MergedData } from "./DataRecorder"; 2 | import { MetadataCache, moment, TFile } from 'obsidian'; 3 | import WordflowTrackerPlugin from "./main"; 4 | 5 | export class MetaDataParser{ 6 | //private recordType: string; 7 | private timeFormat: string; 8 | //private sortBy: string; 9 | //private isDescend: boolean; 10 | private syntax: string; 11 | //private noteContent: string | null; 12 | 13 | // special variables 14 | private patterns: any[] = []; 15 | 16 | constructor( 17 | private DataRecorder: DataRecorder, 18 | private plugin: WordflowTrackerPlugin 19 | ){} 20 | 21 | public loadSettings(){ 22 | this.timeFormat = this.DataRecorder.timeFormat; 23 | //this.sortBy = this.DataRecorder.sortBy; 24 | //this.isDescend = this.DataRecorder.isDescend; 25 | this.syntax = this.DataRecorder.metadataSyntax; 26 | } 27 | 28 | public async extractData(recordNote: TFile): Promise< Map> { 29 | const [startIndex, endIndex] = await this.getIndex(recordNote); 30 | const existingDataMap: Map = new Map(); 31 | 32 | // Return empty map if no YAML block found 33 | if (startIndex === -1 || endIndex === -1) { 34 | return existingDataMap; 35 | } 36 | 37 | if (this.patterns.length === 0) { 38 | this.setPatterns(); 39 | } 40 | 41 | let YAMLData: Record = {}; 42 | // parse syntax to fetch varName and user customized name for varName variable. Then, read and record user customized name in frontmatter 43 | for (const pattern of this.patterns) { 44 | // Extract the variable name from the pattern 45 | const syntaxLine = this.syntax.split('\n').find(line => 46 | line.includes(pattern.start) && line.includes('${') && line.includes('}')); 47 | 48 | if (!syntaxLine) continue; 49 | 50 | const varStart = syntaxLine.indexOf('${') + 2; 51 | const varEnd = syntaxLine.indexOf('}', varStart); 52 | if (varStart <= 1 || varEnd <= varStart) continue; 53 | 54 | const varName = syntaxLine.substring(varStart, varEnd); 55 | if(!varName) continue; 56 | 57 | const customName = syntaxLine.trim().substring(0,syntaxLine.indexOf(':')); // user customized name for ${varName}, for example: 'Total Edits' is the custom name for '${totalEdits} as specified in default settings' 58 | //console.log(`found ${varName}: ${this.plugin.app.metadataCache.getFileCache(recordNote)?.frontmatter?.[customName]}`) 59 | if (this.plugin.app.metadataCache.getFileCache(recordNote)?.frontmatter?.[customName]){ 60 | YAMLData[varName] = this.plugin.app.metadataCache.getFileCache(recordNote)?.frontmatter?.[customName] 61 | } else { 62 | YAMLData[varName] = undefined; 63 | } 64 | } 65 | 66 | // If no data was found, return empty map 67 | if (Object.keys(YAMLData).length === 0) { 68 | return existingDataMap; 69 | } 70 | 71 | const extractedData = new ExistingData(); 72 | // Parse varNames 73 | if (YAMLData.totalWords !== undefined) { 74 | extractedData.totalWords = parseInt(YAMLData.totalWords) || 0; 75 | } 76 | if (YAMLData.totalEdits !== undefined) { 77 | extractedData.totalEdits = parseInt(YAMLData.totalEdits) || 0; 78 | } 79 | // unique string as key to fetch existing data 80 | existingDataMap.set('|M|E|T|A|D|A|T|A|', extractedData); 81 | return existingDataMap; 82 | } 83 | 84 | // get the starting index of the array, element of which records the line number and the line content of the noteContent 85 | public async getIndex(recordNote: TFile): Promise<[number, number]> { 86 | const frontmatterPos = this.plugin.app.metadataCache.getFileCache(recordNote)?.frontmatterPosition 87 | 88 | // Check if YAML exists 89 | if (frontmatterPos) { 90 | return [frontmatterPos.start.line, frontmatterPos.end.line] 91 | } 92 | 93 | return [-1, -1]; 94 | } 95 | 96 | public async getContent(recordNote: TFile): Promise { 97 | const noteContent = await this.plugin.app.vault.read(recordNote); 98 | const [startIndex, endIndex] = await this.getIndex(recordNote); 99 | const lines = noteContent.split('\n'); 100 | 101 | // Return null if no YAML block found 102 | if (startIndex === -1 || endIndex === -1) { 103 | return null; 104 | } 105 | 106 | // Make sure patterns are set 107 | if (this.patterns.length === 0) { 108 | this.setPatterns(); 109 | } 110 | 111 | // Extract YAML lines (excluding the --- markers) 112 | const yamlLines = lines.slice(startIndex + 1, endIndex); 113 | 114 | let firstVarLine = -1; 115 | let lastVarLine = -1; 116 | 117 | // Find the line numbers of the first and last variable patterns 118 | for (let i = 0; i < yamlLines.length; i++) { 119 | const line = yamlLines[i]; 120 | 121 | // Check if line matches any of our patterns 122 | for (const pattern of this.patterns) { 123 | if (line.trim().startsWith(pattern.start.trim())) { 124 | // Found a matching line 125 | if (firstVarLine === -1) { 126 | firstVarLine = i; 127 | } 128 | lastVarLine = i; 129 | } 130 | } 131 | } 132 | 133 | // If no variable patterns found, return null 134 | if (firstVarLine === -1 || lastVarLine === -1) { 135 | return null; 136 | } 137 | 138 | // Extract the content between firstVarLine and lastVarLine (inclusive) 139 | const extractedLines = yamlLines.slice(firstVarLine, lastVarLine + 1); 140 | return extractedLines.join('\n'); 141 | } 142 | 143 | public generateContent(mergedData: MergedData[]): string { 144 | // Implement metadata generation 145 | let output = ''; 146 | 147 | for (const data of mergedData) { 148 | let line = this.syntax 149 | .replace(/\${totalEdits}/g, data.totalEdits.toString()) 150 | .replace(/\${totalWords}/g, data.totalWords.toString()) 151 | 152 | output += line + '\n'; 153 | } 154 | 155 | return output; 156 | } 157 | 158 | private setPatterns(){ 159 | // Parse the metadata syntax to extract patterns 160 | const syntaxLines = this.syntax.split('\n'); 161 | 162 | 163 | // Extract patterns from each line of syntax 164 | for (let i = 0; i < syntaxLines.length; i++) { 165 | const line = syntaxLines[i]; 166 | if ((!(line.contains('${') && 167 | line.contains('}')) 168 | ) && 169 | (line.trim() != '') 170 | ) throw Error(`Invaid metadata syntax! Ensure each line contains the \${} !`); 171 | 172 | const varStart = line.indexOf('${'); 173 | 174 | const varEnd = line.indexOf('}', varStart); 175 | 176 | this.patterns.push({ 177 | start: line.substring(0, varStart), 178 | end: line.substring(varEnd + 1) 179 | }); 180 | } 181 | } 182 | }; -------------------------------------------------------------------------------- /src/TableParser.ts: -------------------------------------------------------------------------------- 1 | import { DataRecorder, ExistingData, MergedData } from "./DataRecorder"; 2 | import { moment, TFile } from 'obsidian'; 3 | import WordflowTrackerPlugin from "./main"; 4 | 5 | export class TableParser{ 6 | //private recordType: string; 7 | private timeFormat: string; 8 | //private sortBy: string; 9 | //private isDescend: boolean; 10 | private syntax: string; 11 | private noteContent: string | null; 12 | //private existingDataMap: Map; 13 | 14 | constructor( 15 | private DataRecorder: DataRecorder, 16 | private plugin: WordflowTrackerPlugin 17 | ){} 18 | 19 | public loadSettings(){ 20 | this.timeFormat = this.DataRecorder.timeFormat; 21 | //this.sortBy = this.DataRecorder.sortBy; 22 | //this.isDescend = this.DataRecorder.isDescend; 23 | this.syntax = this.DataRecorder.tableSyntax; 24 | } 25 | 26 | public async extractData(recordNote: TFile): Promise< Map > { 27 | this.noteContent = await this.plugin.app.vault.read(recordNote); 28 | const lines = this.noteContent.split('\n'); 29 | const existingDataMap: Map = new Map(); 30 | const [tableStartIndex, tableEndIndex] = await this.getIndex(recordNote); 31 | 32 | if (tableStartIndex !== -1) { 33 | const headerVarMapping = this.createHeaderVarMapping(); 34 | 35 | const headerRow = lines[tableStartIndex]; 36 | const dataRows = lines.slice(tableStartIndex + 2, tableEndIndex + 1); // skip header row and split row 37 | 38 | for (const row of dataRows) { 39 | if (row.trim().startsWith('|') && row.trim().endsWith('|')) { 40 | const parsedData = this.parseTableRow(row, headerRow, headerVarMapping); 41 | if (parsedData) { 42 | existingDataMap.set(parsedData.filePath, parsedData); 43 | } 44 | } 45 | } 46 | } 47 | 48 | return existingDataMap; 49 | } 50 | 51 | public async getIndex(recordNote: TFile): Promise<[number, number]> { 52 | if(!this.noteContent) this.noteContent = await this.plugin.app.vault.read(recordNote); 53 | const [headerTemplate, separatorTemplate] = this.syntax 54 | .split('\n') 55 | .filter(l => l.trim()) 56 | .slice(0, 2); 57 | 58 | if (!headerTemplate || !separatorTemplate) { 59 | throw Error ('Invalid table syntax!\n Please check in settings.') 60 | } 61 | 62 | // Create regex patterns for header and separator 63 | // This will match regardless of exact spacing or number of dashes 64 | const headerColumns = headerTemplate.split('|') 65 | .filter(part => part.trim()) 66 | .map(part => part.trim()); 67 | 68 | const headerRegexStr = '\\|\\s*' + headerColumns.join('\\s*\\|\\s*') + '\\s*\\|'; 69 | const headerRegex = new RegExp(headerRegexStr, 'i'); 70 | 71 | // Separator regex - matches any table separator row with the same number of columns 72 | const columnCount = headerColumns.length; 73 | const separatorRegexStr = `\\|(?:\\s*-+\\s*\\|){${columnCount}}`; 74 | const separatorRegex = new RegExp(separatorRegexStr); 75 | 76 | // Find table in the document 77 | const lines = this.noteContent.split('\n'); 78 | let tableStartIndex = -1; 79 | let tableEndIndex = -1; 80 | 81 | for (let i = 0; i < lines.length; i++) { 82 | if (headerRegex.test(lines[i])) { 83 | // Found potential header, check next line for separator 84 | if (i + 1 < lines.length && separatorRegex.test(lines[i + 1])) { 85 | tableStartIndex = i; 86 | 87 | // Now find the end of the table 88 | for (let j = tableStartIndex + 2; j <= lines.length; j++) { 89 | // Case1: Table ends at a blank line or end of document 90 | if (!lines[j] || lines[j].trim() === '') { 91 | tableEndIndex = j - 1; 92 | break; 93 | } 94 | // Case2: next line is not a table row 95 | if (!lines[j].trim().startsWith('|') || !lines[j].trim().endsWith('|')) { 96 | tableEndIndex = j - 1; 97 | break; 98 | } 99 | 100 | // Case3: we find another table header 101 | if (j + 1 < lines.length && 102 | headerRegex.test(lines[j]) && 103 | separatorRegex.test(lines[j + 1])) { 104 | tableEndIndex = j - 1; 105 | break; 106 | } 107 | } 108 | 109 | // case4: if no table end index found after iteration, we will enforce throwing error to protect note file. 110 | } 111 | } 112 | } 113 | //console.log('tablestart:',tableStartIndex,'tableend:', tableEndIndex) 114 | return [tableStartIndex, tableEndIndex]; 115 | } 116 | 117 | public async getContent(recordNote: TFile): Promise { 118 | if (!this.noteContent) this.noteContent = await this.plugin.app.vault.read(recordNote); 119 | const [startIndex, endIndex] = await this.getIndex(recordNote); 120 | const lines = this.noteContent.split('\n'); 121 | 122 | if (startIndex != -1 && endIndex !== -1){ 123 | const tableContent = lines.slice(startIndex, endIndex + 1).join('\n'); 124 | return tableContent; 125 | } else if (startIndex != -1 && endIndex == -1){ 126 | throw new Error (`Could not find the end of existing table but find the table start!`); 127 | } else { 128 | return null; 129 | } 130 | } 131 | 132 | public generateContent(mergedData: MergedData[]): string { 133 | const [header, separator, ...templateRows] = this.syntax 134 | .split('\n') 135 | .filter(l => l.trim()); 136 | 137 | if (!header || !separator || templateRows.length === 0) { 138 | console.warn("Invalid table syntax"); 139 | return ''; 140 | } 141 | 142 | const rowTemplate = templateRows.join('\n'); 143 | 144 | // Generate rows from the merged data 145 | const rows = mergedData.map(data => { 146 | return rowTemplate.replace( 147 | /\${(\w+)}/g, 148 | (_, varName: string) => { 149 | switch (varName) { 150 | case 'modifiedNote': 151 | return data.filePath; 152 | case 'lastModifiedTime': 153 | return typeof data.lastModifiedTime === 'number' 154 | ? moment(data.lastModifiedTime).format(this.timeFormat) 155 | : data.lastModifiedTime as string; 156 | case 'editedWords': 157 | return data.editedWords.toString(); 158 | case 'editedTimes': 159 | return data.editedTimes.toString(); 160 | case 'addedWords': 161 | return data.addedWords.toString(); 162 | case 'deletedWords': 163 | return data.deletedWords.toString(); 164 | case 'changedWords': 165 | return data.changedWords.toString(); 166 | case 'docWords': 167 | return data.docWords.toString(); 168 | case 'editedPercentage': 169 | return data.editedPercentage.toNote(); 170 | case 'statBar': 171 | return data.statBar.toNote(); 172 | default: 173 | return ''; 174 | } 175 | } 176 | ); 177 | }); 178 | 179 | // check if all data is empty 180 | if (rows.every(row => row.trim() === '')){ 181 | return ''; 182 | } 183 | 184 | // Assemble the complete table 185 | return [ 186 | '', 187 | header, 188 | separator, 189 | ...rows 190 | ].join('\n').trimEnd(); 191 | } 192 | 193 | // used for extract varName from table syntax in plugin setting 194 | private createHeaderVarMapping(): Record { 195 | const mapping: Record = {}; 196 | 197 | // fetch heading and rows from table syntax 198 | const syntaxLines = this.syntax.split('\n').filter(l => l.trim()); 199 | if (syntaxLines.length !== 3) { 200 | throw Error ('Table syntax requires strictly 3 lines!\nThe heading line, the split line, and one row for data inserting.') 201 | } 202 | 203 | const headerTemplate = syntaxLines[0]; 204 | const rowTemplate = syntaxLines[2]; 205 | 206 | // parsing header and row columns 207 | const headerColumns = headerTemplate.split('|').map(part => part.trim()).filter(Boolean); 208 | const rowColumns = rowTemplate.split('|').map(part => part.trim()).filter(Boolean); 209 | 210 | // parsing map 211 | for (let i = 0; i < Math.min(headerColumns.length, rowColumns.length); i++) { 212 | const headerText = headerColumns[i]; 213 | const matches = rowColumns[i].match(/\${(\w+)}/); 214 | if (matches && matches[1]) { 215 | mapping[headerText] = matches[1]; 216 | } 217 | } 218 | 219 | return mapping; 220 | } 221 | 222 | private parseTableRow(row: string, headerRow: string, headerVarMapping: Record): ExistingData | null { 223 | const entry = new ExistingData(); 224 | 225 | // fetch heading and data rows from table syntax 226 | const headerColumns = headerRow.split('|').map(part => part.trim()).filter(Boolean); 227 | const dataColumns = row.split('|').map(part => part.trim()).filter(Boolean); 228 | 229 | // match data 230 | for (let i = 0; i < Math.min(headerColumns.length, dataColumns.length); i++) { 231 | const headerText = headerColumns[i]; 232 | const varName = headerVarMapping[headerText]; 233 | if (!varName) continue; 234 | 235 | const value = dataColumns[i]; 236 | 237 | switch (varName) { 238 | case 'modifiedNote': 239 | entry.filePath = value.replace(/^\[\[+|\]\]+$/g, ''); 240 | break; 241 | 242 | case 'editedWords': 243 | entry.editedWords = parseInt(value) || 0; 244 | break; 245 | 246 | case 'editedTimes': 247 | entry.editedTimes = parseInt(value) || 0; 248 | break; 249 | 250 | case 'addedWords': 251 | entry.addedWords = parseInt(value) || 0; 252 | break; 253 | 254 | case 'deletedWords': 255 | entry.deletedWords = parseInt(value) || 0; 256 | break; 257 | 258 | case 'changedWords': 259 | entry.changedWords = parseInt(value) || 0; 260 | break; 261 | 262 | case 'docWords': 263 | entry.docWords = parseInt(value) || 0; 264 | break; 265 | 266 | case 'lastModifiedTime': 267 | try { 268 | if (value && value.trim()) { 269 | entry.lastModifiedTime = moment(value, this.timeFormat).valueOf(); 270 | } else { 271 | entry.lastModifiedTime = null; 272 | } 273 | } catch (e) { 274 | entry.lastModifiedTime = null; 275 | } 276 | break; 277 | 278 | case 'editedPercentage': 279 | entry.editedPercentage.fromNote(value); 280 | break; 281 | 282 | case 'statBar': 283 | entry.statBar.fromNote(value); 284 | break; 285 | } 286 | } 287 | 288 | if (!entry.filePath) throw Error ('${modifiedNote} is not recognized in the table syntax, which is necessary!') 289 | return entry; 290 | } 291 | 292 | }; -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // add EditorTransaction 2 | import { App, ButtonComponent, debounce, Editor, EventRef, MarkdownView, Modal, Notice, normalizePath, Plugin, PluginSettingTab, Setting, TextAreaComponent, TextComponent, TFile, DropdownComponent } from 'obsidian'; 3 | //import { EditorState, StateField, Extension, ChangeSet, Transaction } from "@codemirror/state"; 4 | //import { historyField, history } from "@codemirror/commands"; 5 | //import { EditorView, PluginValue, ViewPlugin, ViewUpdate } from "@codemirror/view"; 6 | //import { wordsCounter } from "./stats"; 7 | import { DocTracker } from './DocTracker'; 8 | import { DataRecorder } from './DataRecorder'; 9 | 10 | // Remember to rename these classes and interfaces! 11 | const DEBUG = true as const; 12 | 13 | export interface WordflowMetaSettings { 14 | name: string; 15 | enableDynamicFolder: boolean; 16 | periodicNoteFolder: string; 17 | periodicNoteFormat: string; 18 | recordType: string; 19 | tableSyntax: string; 20 | bulletListSyntax: string; 21 | metadataSyntax: string; 22 | timeFormat: string; 23 | sortBy: string; 24 | isDescend: boolean; 25 | filterZero: boolean; 26 | autoRecordInterval: string; 27 | insertPlace: string; 28 | insertPlaceStart: string; 29 | insertPlaceEnd: string; 30 | } 31 | 32 | export interface WordflowSettings extends WordflowMetaSettings{ 33 | // for multiple recorders 34 | Recorders: RecorderConfig[]; 35 | } 36 | 37 | export interface RecorderConfig extends WordflowMetaSettings { 38 | id: string; 39 | name: string; 40 | } 41 | 42 | const DEFAULT_SETTINGS: WordflowSettings = { 43 | name: 'Default Recorder', 44 | enableDynamicFolder: false, 45 | periodicNoteFolder: '', 46 | periodicNoteFormat: 'YYYY-MM-DD', 47 | recordType: 'table', 48 | insertPlace: 'bottom', 49 | tableSyntax: `| Note | Edited Words | Last Modified Time |\n| ------------------- | ---------------- | ------------------- |\n| [[\${modifiedNote}]] | \${editedWords} | \${lastModifiedTime} |`, 50 | bulletListSyntax: `- \${modifiedNote}\n - Edits: \${editedTimes}\n - Edited Words: \${editedWords}`, 51 | metadataSyntax: `Total edits: \${totalEdits}\nTotal words: \${totalWords}`, 52 | timeFormat: 'YYYY-MM-DD HH:mm', 53 | sortBy: 'lastModifiedTime', 54 | isDescend: true, 55 | filterZero: true, 56 | autoRecordInterval: '0', // disable 57 | insertPlaceStart: '', 58 | insertPlaceEnd: '', 59 | 60 | // for multiple recorders 61 | Recorders: [], 62 | } 63 | 64 | 65 | export default class WordflowTrackerPlugin extends Plugin { 66 | settings: WordflowSettings; 67 | private activeTrackers: Map = new Map(); // for multiple notes editing 68 | private pathToNameMap: Map = new Map(); // 新增:反向映射用于重命名检测 69 | public trackerMap: Map = new Map(); // give up nested map 70 | public statusBarTrackerEl: HTMLElement; // for status bar tracking 71 | public statusBarContent: string; // for status bar content editing 72 | public DocRecorders: DataRecorder[] = []; 73 | 74 | async onload() { 75 | await this.loadSettings(); 76 | 77 | const defaultRecorder = new DataRecorder(this, this.trackerMap); 78 | this.DocRecorders.push(defaultRecorder); 79 | for (const recorderConfig of this.settings.Recorders) { 80 | const recorder = new DataRecorder(this, this.trackerMap, recorderConfig); 81 | this.DocRecorders.push(recorder); 82 | } 83 | 84 | 85 | const debouncedHandler = this.instantDebounce(this.activeDocHandler.bind(this), 50); 86 | // Warning: don't change the delay, we need 50ms delay to trigger activeDocHandler twice when opening new files. 87 | // if (DEBUG) console.log("Following files were opened:", this.potentialEditors.map(f => f)); 88 | 89 | // This creates an icon in the left ribbon. 90 | const ribbonIconEl = this.addRibbonIcon('file-clock', 'Record wordflows from edited notes', (evt: MouseEvent) => { 91 | // Called when the user clicks the icon. 92 | new Notice(`Try recording wordflows to periodic note!`, 3000); 93 | 94 | for (const DocRecorder of this.DocRecorders) { 95 | DocRecorder.record(); 96 | } 97 | 98 | }); 99 | // Perform additional things with the ribbon 100 | //ribbonIconEl.addClass('my-plugin-ribbon-class'); 101 | 102 | // This adds a status bar item to the bottom of the app. Does not work on mobile apps. 103 | this.statusBarTrackerEl = this.addStatusBarItem(); 104 | 105 | // This adds a simple command that can be triggered anywhere 106 | this.addCommand({ 107 | id: 'record-wordflows-from-edited-notes-to-periodic-note', 108 | name: 'Record wordflows from edited notes to periodic note', 109 | callback: () => { 110 | for (const DocRecorder of this.DocRecorders) { 111 | DocRecorder.record(); 112 | } 113 | new Notice(`Try recording wordflows to periodic note!`, 3000); 114 | } 115 | }); 116 | /* 117 | // This adds an editor command that can perform some operation on the current editor instance 118 | this.addCommand({ 119 | id: 'sample-editor-command', 120 | name: 'Sample editor command', 121 | editorCallback: (editor: Editor, view: MarkdownView) => { 122 | console.log(editor.getSelection()); 123 | editor.replaceSelection('Sample Editor Command'); 124 | } 125 | }); 126 | // This adds a complex command that can check whether the current state of the app allows execution of the command 127 | this.addCommand({ 128 | id: 'open-sample-modal-complex', 129 | name: 'Open sample modal (complex)', 130 | checkCallback: (checking: boolean) => { 131 | // Conditions to check 132 | const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); 133 | if (markdownView) { 134 | // If checking is true, we're simply "checking" if the command can be run. 135 | // If checking is false, then we want to actually perform the operation. 136 | if (!checking) { 137 | new SampleModal(this.app).open(); 138 | } 139 | 140 | // This command will only show up in Command Palette when the check function returns true 141 | return true; 142 | } 143 | } 144 | }); 145 | */ 146 | 147 | // This adds a settings tab so the user can configure various aspects of the plugin 148 | this.addSettingTab(new WordflowSettingTab(this.app, this)); 149 | 150 | /* Registered Events */ 151 | // Update tracking files after rename events 152 | this.registerEvent(this.app.vault.on('rename', (file, oldPath) => { 153 | if (file instanceof TFile) { 154 | const oldName = this.pathToNameMap.get(oldPath); 155 | if (oldName && this.activeTrackers.has(oldName)) { 156 | // 迁移追踪记录到新文件名 157 | this.activeTrackers.set(file.basename, true); 158 | this.activeTrackers.delete(oldName); 159 | 160 | // 更新路径映射 161 | this.pathToNameMap.delete(oldPath); 162 | this.pathToNameMap.set(file.path, file.basename); 163 | } 164 | } 165 | })); 166 | 167 | this.registerEvent(this.app.workspace.on('active-leaf-change', () => { 168 | debouncedHandler(); 169 | })); 170 | 171 | this.registerEvent(this.app.workspace.on('layout-change', () => { 172 | debouncedHandler(); 173 | })); 174 | 175 | if (this.app.workspace.getActiveViewOfType(MarkdownView)?.getMode() == "source") 176 | { 177 | debouncedHandler(); 178 | } 179 | 180 | 181 | // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) 182 | // Using this function will automatically remove the event listener when this plugin is disabled. 183 | /* 184 | this.registerDomEvent(document, 'click', (evt: MouseEvent) => { 185 | console.log('click', evt); 186 | //test to switch between editor 187 | if(this.app.workspace.activeEditor) 188 | { 189 | console.log(this.app.workspace.activeEditor.file?.basename) 190 | } 191 | }); 192 | */ 193 | 194 | // When registering intervals, this function will automatically clear the interval when the plugin is disabled. 195 | if (this.settings.autoRecordInterval && Number(this.settings.autoRecordInterval) != 0){ 196 | this.registerInterval(window.setInterval(() => { 197 | for (const DocRecorder of this.DocRecorders) { 198 | DocRecorder.record(); 199 | } 200 | new Notice(`Auto try recording wordflows to periodic note!`, 3000); 201 | }, Number(this.settings.autoRecordInterval) * 1000)); 202 | } 203 | } 204 | 205 | 206 | // add private functions since here 207 | private async activeDocHandler(){ 208 | await sleep( 209 | (this.app.workspace.getActiveViewOfType(MarkdownView))? 0 : 200 // set delay for newly opened file to fetch the actual view, while maintaining the ability to fast switch files 210 | ) 211 | 212 | // console.log("trackerMap",this.trackerMap); 213 | //console.log("editor:", this.app.workspace.getActiveViewOfType(MarkdownView)); // bug & warning: get null when open new files, get old files if no delay 214 | if (this.app.workspace.getActiveViewOfType(MarkdownView)?.getMode() == "source"){ 215 | // if (DEBUG) new Notice(`Now Edit Mode!`); // should call content in if (activeEditor) 216 | // done | need to improve when plugin starts, the cursor must at active document 217 | const activeEditor = this.app.workspace.getActiveViewOfType(MarkdownView); 218 | // if (DEBUG) console.log("Editing file:",this.app.workspace.activeEditor?.file?.basename) // debug 219 | 220 | this.activateTracker(activeEditor); // activate without delay 221 | 222 | await sleep(100); // set delay for getting the correct opened files for deactivating and deleting Map 223 | const potentialEditors = new Set(this.getAllOpenedFiles()); 224 | // console.log(potentialEditors) // debug 225 | this.trackerMap.forEach(async (tracker, filePath) => { 226 | if(potentialEditors.has(filePath)) { 227 | if (filePath !== activeEditor?.file?.path) tracker.deactivate(); 228 | } 229 | else{ 230 | tracker.deactivate(); 231 | let count = 0; 232 | for (const DocRecorder of this.DocRecorders) { 233 | if(DocRecorder.filterZero && 234 | tracker.editedTimes == 0 && 235 | tracker.editedWords == 0) 236 | continue; 237 | DocRecorder.record(tracker); 238 | ++count; 239 | } 240 | this.trackerMap.delete(filePath); 241 | if (count){ 242 | new Notice(`Edits from ${filePath} are recorded.`, 1000) 243 | // if (DEBUG) console.log("Closed file:", filePath, " is recorded.") 244 | } 245 | } 246 | }); 247 | 248 | 249 | } 250 | else{ 251 | // deregister all inactive files 252 | await sleep(100); // set delay for getting the correct opened files for deactivating and deleting Map 253 | const potentialEditors = new Set(this.getAllOpenedFiles()); 254 | // console.log(potentialEditors) // debug 255 | this.trackerMap.forEach(async (tracker, filePath)=>{ 256 | if(potentialEditors.has(filePath)) tracker.deactivate(); 257 | else{ 258 | tracker.deactivate(); 259 | let count = 0; 260 | for (const DocRecorder of this.DocRecorders) { 261 | if(DocRecorder.filterZero && 262 | tracker.editedTimes == 0 && 263 | tracker.editedWords == 0) 264 | continue; 265 | DocRecorder.record(tracker); 266 | ++count; 267 | } 268 | this.trackerMap.delete(filePath); 269 | if (count){ 270 | new Notice(`Edits from ${filePath} are recorded.`, 1000) 271 | // if (DEBUG) console.log("Closed file:", filePath, " is recorded.") 272 | } 273 | } 274 | }); 275 | this.statusBarTrackerEl.setText(''); // clear status bar 276 | //if (DEBUG) console.log(`activeDocHandler: status bar cleared`); 277 | } 278 | }; 279 | 280 | 281 | // create or activate DocTracker 282 | private async activateTracker(activeEditor: MarkdownView | null) { 283 | if (!activeEditor?.file?.path) return; 284 | let activeFilePath = activeEditor?.file?.path; 285 | // rename后更新路径映射 286 | this.pathToNameMap.set(activeEditor?.file?.path, activeFilePath); 287 | 288 | // console.log("Calling:", activeFilePath, " activeState:", this.trackerMap.get(activeFilePath)?.isActive) // debug 289 | // normal process 290 | 291 | if (!this.trackerMap.has(activeFilePath)){ 292 | // track active File 293 | const newTracker = new DocTracker(activeFilePath, activeEditor, this); 294 | this.trackerMap.set(activeFilePath, newTracker); 295 | } 296 | else{ 297 | this.trackerMap.get(activeFilePath)?.activate(); 298 | } 299 | //await sleep(50); // for the process completion 300 | }; 301 | /* 302 | if(DEBUG){ 303 | const trackerEntries:any = []; 304 | this.trackerMap.forEach((tracker, fileName) => { 305 | trackerEntries.push({ 306 | fileName: fileName, 307 | trackerActiveState: tracker.isActive, 308 | lastDone: tracker.lastDone 309 | }); 310 | }); 311 | console.log("Current trackerMap:", trackerEntries); 312 | const potentialEditors2 = this.getAllOpenedFiles(); 313 | console.log("Following files were opened:", potentialEditors2.map(f => f)); 314 | } 315 | */ 316 | 317 | // get markdown files (with path) that are in edit mode from all leaves 318 | private getAllOpenedFiles = (): string[] => { 319 | const files: string[] = []; 320 | const addTFile = (file: string) => { 321 | if (!files.contains(file)) files.push(file); 322 | } 323 | 324 | const MDLeaves = this.app.workspace.getLeavesOfType('markdown'); 325 | //if (DEBUG) console.log("MD Leaves:", MDLeaves); 326 | if (MDLeaves.length < 1) return files; 327 | MDLeaves.forEach(leaf => { 328 | //console.log(leaf); 329 | //if (leaf.view?.getMode() == 'source'){ // Warning: file must have been opened to have function 'getMode()', this influences only start up loading files in saved workspace 330 | // Use getState() instead of getMode() and getFile() 331 | if (leaf.view?.getState().mode == 'source'){ // includes preview mode and source mode 332 | // @ts-expect-error 333 | addTFile(leaf.view.getState().file); // get file path of TFile, or get file path directly, and then add to array | which to get depends on if the files have been opened or not. 334 | }; 335 | // @ts-expect-error 336 | if (leaf.history.backHistory.length > 0){ 337 | // @ts-expect-error 338 | leaf.history.backHistory.forEach( item => { 339 | if (item.state.state.mode == 'source'){ 340 | addTFile(item.state.state.file); 341 | } 342 | }) 343 | } 344 | // @ts-expect-error 345 | if (leaf.history.forwardHistory.length > 0){ 346 | // @ts-expect-error 347 | leaf.history.forwardHistory.forEach( item => { 348 | if (item.state.state.mode == 'source'){ 349 | addTFile(item.state.state.file); 350 | } 351 | }) 352 | } 353 | }) 354 | return files; 355 | }; 356 | 357 | //private debouncedDeactivator = debounce() 358 | 359 | private instantDebounce void>( 360 | fn: T, 361 | wait: number 362 | ): (...args: Parameters) => void { 363 | let lastCallTime = 0; 364 | let timeoutId: number | null = null; 365 | 366 | return function (this: any, ...args: Parameters) { 367 | const now = Date.now(); 368 | 369 | // run immediately and ban running until timeout exceeded 370 | if (now - lastCallTime >= wait) { 371 | // 清理可能存在的残留计时器 372 | if (timeoutId !== null) { 373 | clearTimeout(timeoutId); 374 | timeoutId = null; 375 | } 376 | 377 | // run immediately 378 | fn.apply(this, args); 379 | lastCallTime = now; 380 | 381 | // set timeout 382 | timeoutId = window.setTimeout(() => { 383 | timeoutId = null; 384 | }, wait); 385 | } 386 | }; 387 | } 388 | 389 | onunload() { 390 | this.trackerMap.forEach((tracker, filePath)=>{ 391 | tracker.deactivate(); 392 | }) 393 | this.trackerMap.clear(); 394 | } 395 | 396 | async loadSettings() { 397 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 398 | } 399 | 400 | async saveSettings() { 401 | await this.saveData(this.settings); 402 | } 403 | } 404 | 405 | class ConfirmationModal extends Modal { 406 | constructor( 407 | app: App, 408 | private message: string, 409 | private onConfirm: () => Promise 410 | ) { 411 | super(app); 412 | } 413 | 414 | onOpen() { 415 | const { contentEl } = this; 416 | this.containerEl.addClass("confirm-modal"); 417 | 418 | contentEl.createEl("h3", { 419 | text: "⚠️ Confirmation ", 420 | cls: "confirm-title" 421 | }); 422 | 423 | const messagePara = contentEl.createEl("p", { 424 | cls: "confirm-message" 425 | }); 426 | 427 | messagePara.textContent = this.message; 428 | 429 | const buttonContainer = contentEl.createDiv("confirm-cancel-buttons"); 430 | 431 | new ButtonComponent(buttonContainer) 432 | .setButtonText("Confirm") 433 | .setClass("mod-warning") 434 | .onClick(async () => { 435 | await this.onConfirm(); 436 | this.close(); 437 | }); 438 | 439 | new ButtonComponent(buttonContainer) 440 | .setButtonText("Cancel") 441 | .setClass("mod-neutral") 442 | .onClick(() => this.close()); 443 | } 444 | 445 | onClose() { 446 | const {contentEl} = this; 447 | contentEl.empty(); 448 | } 449 | } 450 | 451 | class WordflowSettingTab extends PluginSettingTab { 452 | plugin: WordflowTrackerPlugin; 453 | // for multiple recorders 454 | private activeRecorderIndex: number = 0; // 0 = default recorder 455 | private recorderTabs: HTMLElement; 456 | private settingsContainer: HTMLElement; 457 | 458 | constructor(app: App, plugin: WordflowTrackerPlugin) { 459 | super(app, plugin); 460 | this.plugin = plugin; 461 | } 462 | 463 | display(): void { 464 | const {containerEl} = this; 465 | containerEl.empty(); 466 | containerEl.classList.add('wordflow-setting-tab'); 467 | 468 | // Create recorder management section 469 | this.createRecorderManagementSection(containerEl); 470 | 471 | // Add separator 472 | //containerEl.createEl('hr', { cls: 'settings-separator' }); 473 | 474 | // Create a container for the recorder settings that won't be cleared 475 | this.settingsContainer = containerEl.createDiv('recorder-settings-container'); 476 | 477 | // Display settings for active recorder 478 | this.displayRecorderSettings(this.activeRecorderIndex); 479 | } 480 | 481 | private createRecorderManagementSection(containerEl: HTMLElement): void { 482 | 483 | // Create recorder selection 484 | const recorderSelectionContainer = containerEl.createDiv('recorder-selection-container'); 485 | 486 | // Show currently active recorder 487 | let activeRecorderName = this.plugin.settings.name; 488 | if (this.activeRecorderIndex > 0) { 489 | activeRecorderName = this.plugin.settings.Recorders[this.activeRecorderIndex - 1].name; 490 | } 491 | 492 | const recorderActions = new Setting(recorderSelectionContainer) 493 | .setName('Current recorder') 494 | .setDesc('Select which recorder configuration to edit.\nYou can add new recorders to save different sets of statistics to different locations.'); 495 | // Only show rename/delete for additional recorders 496 | if (this.activeRecorderIndex > 0) { 497 | recorderActions 498 | .addButton(btn => btn 499 | .setButtonText('Delete') 500 | .setTooltip('Delete') 501 | .setIcon('trash') 502 | .setWarning() 503 | .onClick(() => { 504 | this.removeRecorder(this.activeRecorderIndex - 1); 505 | }) 506 | ); 507 | } 508 | 509 | recorderActions 510 | .addDropdown(dropdown => { 511 | // Add default recorder 512 | dropdown.addOption("0", this.plugin.settings.name); 513 | 514 | // Add additional recorders 515 | this.plugin.settings.Recorders.forEach((recorder, index) => { 516 | dropdown.addOption((index + 1).toString(), recorder.name); 517 | }); 518 | 519 | // Set current selection 520 | dropdown.setValue(this.activeRecorderIndex.toString()); 521 | 522 | // Handle selection change 523 | dropdown.onChange(value => { 524 | this.setActiveRecorder(parseInt(value)); 525 | }); 526 | }) 527 | .addButton(btn => btn 528 | .setButtonText('Rename') 529 | .setIcon('pencil') 530 | .setTooltip('Rename') 531 | .onClick(() => { 532 | this.renameRecorder(this.activeRecorderIndex -1); 533 | }) 534 | ) 535 | .addButton(btn => btn 536 | .setButtonText('Add recorder') 537 | .setIcon('plus') 538 | .setTooltip('Add recorder') 539 | //.setCta() 540 | .onClick( () => { 541 | new ConfirmationModal( 542 | this.app, 543 | 'Please ensure that there are no duplicate record content type per note, or undefined behavior will occur!\n\nExample allowed✅:\n\tRecorder1: Periodic note format = YYYY-MM-DD; Record type = table;\n\tRecorder2: Periodic note format = YYYY-MM-DD; Record type = bullet list;\nExample allowed✅:\n\tRecorder1: Periodic note format = YYYY-MM-DD; Record type = table;\n\tRecorder2: Periodic note format = YYYY-MM; Record type = table;\nExample disallowed❌:\n\tRecorder1: Periodic note format = YYYY-MM-DD; Record type = table; Insert to position = bottom;\n\tRecorder2: Periodic note format = YYYY-MM-DD; Record type = table; Insert to position = custom;', 544 | async () => {this.createNewRecorder();} 545 | ).open() 546 | }) 547 | ); 548 | 549 | new Setting(recorderSelectionContainer) 550 | .setName('Reset all settings') 551 | .setDesc('Reset all settings to the default value.') 552 | .addButton(btn => btn 553 | .setButtonText('Reset settings') 554 | .setWarning() 555 | //.setIcon('alert-triangle') 556 | .onClick(() => this.confirmReset()) 557 | ) 558 | 559 | 560 | // Create title for settings section 561 | /* 562 | containerEl.createEl('h3', { 563 | text: `⏺️${activeRecorderName} settings`, 564 | cls: 'recorder-settings-heading' 565 | }); 566 | */ 567 | new Setting(containerEl) 568 | .setName(`⚙️${activeRecorderName} configurations`) 569 | .setHeading() 570 | .setClass('recorder-settings-heading') 571 | } 572 | 573 | private setActiveRecorder(index: number) { 574 | this.activeRecorderIndex = index; 575 | this.display(); 576 | } 577 | 578 | private async createNewRecorder() { 579 | // Create new recorder with default settings and unique ID 580 | const newId = `recorder-${Date.now()}`; 581 | const newRecorder: RecorderConfig = { 582 | id: newId, 583 | name: `Recorder ${this.plugin.settings.Recorders.length + 1}`, 584 | enableDynamicFolder: DEFAULT_SETTINGS.enableDynamicFolder, 585 | periodicNoteFolder: DEFAULT_SETTINGS.periodicNoteFolder, 586 | periodicNoteFormat: DEFAULT_SETTINGS.periodicNoteFormat, 587 | recordType: DEFAULT_SETTINGS.recordType, 588 | tableSyntax: DEFAULT_SETTINGS.tableSyntax, 589 | bulletListSyntax: DEFAULT_SETTINGS.bulletListSyntax, 590 | metadataSyntax: DEFAULT_SETTINGS.metadataSyntax, 591 | timeFormat: DEFAULT_SETTINGS.timeFormat, 592 | sortBy: DEFAULT_SETTINGS.sortBy, 593 | isDescend: DEFAULT_SETTINGS.isDescend, 594 | filterZero: DEFAULT_SETTINGS.filterZero, 595 | autoRecordInterval: DEFAULT_SETTINGS.autoRecordInterval, 596 | insertPlace: DEFAULT_SETTINGS.insertPlace, 597 | insertPlaceStart: DEFAULT_SETTINGS.insertPlaceStart, 598 | insertPlaceEnd: DEFAULT_SETTINGS.insertPlaceEnd 599 | }; 600 | 601 | this.plugin.settings.Recorders.push(newRecorder); 602 | await this.plugin.saveSettings(); 603 | 604 | // Create a new recorder instance 605 | const recorder = new DataRecorder(this.plugin, this.plugin.trackerMap, newRecorder); 606 | this.plugin.DocRecorders.push(recorder); 607 | 608 | // Switch to the new recorder tab 609 | this.setActiveRecorder(this.plugin.settings.Recorders.length); 610 | } 611 | 612 | private async renameRecorder(index: number) { 613 | if (index == -1) { // for default recorder 614 | const modal = new RecorderRenameModal( 615 | this.app, 616 | this.plugin.settings.name, 617 | async (newName) => { 618 | this.plugin.settings.name = newName; 619 | await this.plugin.saveSettings(); 620 | this.display(); 621 | }); 622 | modal.open(); 623 | } 624 | else { 625 | const recorder = this.plugin.settings.Recorders[index]; 626 | const modal = new RecorderRenameModal(this.app, recorder.name, async (newName) => { 627 | this.plugin.settings.Recorders[index].name = newName; 628 | await this.plugin.saveSettings(); 629 | this.display(); 630 | }); 631 | modal.open(); 632 | } 633 | } 634 | 635 | private async removeRecorder(index: number) { 636 | const modal = new ConfirmationModal( 637 | this.app, 638 | `Are you sure you want to remove "${this.plugin.settings.Recorders[index].name}"?`, 639 | async () => { 640 | // Remove recorder config 641 | this.plugin.settings.Recorders.splice(index, 1); 642 | await this.plugin.saveSettings(); 643 | 644 | // Remove recorder instance 645 | this.plugin.DocRecorders.splice(index + 1, 1); 646 | 647 | // Reset active tab if needed 648 | if (this.activeRecorderIndex > this.plugin.settings.Recorders.length) { 649 | this.activeRecorderIndex = 0; 650 | } 651 | 652 | this.display(); 653 | } 654 | ); 655 | modal.open(); 656 | } 657 | 658 | private displayRecorderSettings(index: number) { 659 | this.settingsContainer.empty(); 660 | 661 | // Get the correct settings object based on the active index 662 | let settings: any; 663 | let recorderInstance: DataRecorder; 664 | 665 | if (index === 0) { 666 | // Default recorder uses main settings 667 | settings = this.plugin.settings; 668 | recorderInstance = this.plugin.DocRecorders[0]; 669 | } else { 670 | // Additional recorders use their own config 671 | settings = this.plugin.settings.Recorders[index - 1]; 672 | recorderInstance = this.plugin.DocRecorders[index]; 673 | } 674 | 675 | // Display all settings using the selected configuration 676 | this.createRecorderSettingsUI(settings, recorderInstance, index); 677 | } 678 | 679 | private createRecorderSettingsUI(settings: any, recorderInstance: DataRecorder, index: number) { 680 | const container = this.settingsContainer; // do not use containerEl, instead, use container to pass the new container element 681 | 682 | container.classList.add('wordflow-setting-tab'); // for styles.css 683 | 684 | new Setting(container).setName('Periodic note to record').setHeading(); 685 | const periodicFolder = new Setting(container) 686 | .setName('Periodic note folder') 687 | .setDesc('Select whether to enable dynamic folder, and set the folder for daily notes or weekly note to place, which should correspond to the same folder of Obsidian daily note plugin and of templater plugin(if installed).') 688 | .addToggle(t => { 689 | const toggle = t; 690 | toggle 691 | .setTooltip('Enable dynamic folder in moment.js format') 692 | .setValue(settings.enableDynamicFolder) 693 | .onChange(async (value) => { 694 | const actualValue = settings.enableDynamicFolder; 695 | // if no actual change, return 696 | if (value === actualValue) return; 697 | // set back to the actual value in data.json rather than the unconfirmed changed value 698 | toggle.setValue(actualValue); 699 | 700 | new ConfirmationModal( 701 | this.app, 702 | `Please make sure that: \n\t1. You will adjust the periodic note folder after toggling dynamic folder. Example formats are as followed: \n\t\tIf dynamic folder is enabled, the moment format must be used: \n\t\t\t[MonthlyLogs\/]MM-YYYY\n\t\tIf dynamic folder is disabled, a folder path must be used: \n\t\t\tLogs\/MonthlyLogs \n\t2. Do not forget to do the same changes to other recorders if you want them to record in the same folder!`, 703 | async () => { 704 | settings.enableDynamicFolder = value; 705 | await this.plugin.saveSettings(); 706 | recorderInstance.loadSettings(); 707 | // now update placeholder based on the setting. 708 | const placeholder = (value)? '[MonthlyLogs\/]MM-YYYY': 'Example: "Monthly logs"'; 709 | this.PeriodicFolderComponent?.setPlaceholder(placeholder); 710 | toggle.setValue(value); // change display value to the new value 711 | } 712 | ).open(); 713 | }) 714 | }) 715 | 716 | .addText(text => { 717 | this.PeriodicFolderComponent = text; 718 | text 719 | .setValue(settings.periodicNoteFolder) 720 | .setPlaceholder((settings.enableDynamicFolder)?'[MonthlyLogs\/]MM-YYYY': 'Example: "Monthly logs"') 721 | .onChange(async (value) => { 722 | settings.periodicNoteFolder = (value == '')? value: normalizePath(value); 723 | await this.plugin.saveSettings(); 724 | recorderInstance.loadSettings(); 725 | }) 726 | }); 727 | 728 | new Setting(container) 729 | .setName('Periodic note format') 730 | .setDesc('Set the file name (in moment format) for newly created daily notes or weekly note, which should correspond to the same format setting of Obsidian daily note plugin and of templater plugin(if installed).') 731 | .addText(text => text 732 | .setPlaceholder('YYYY-MM-DD') 733 | .setValue(settings.periodicNoteFormat) 734 | .onChange(async (value) => { 735 | settings.periodicNoteFormat = value; 736 | await this.plugin.saveSettings(); 737 | recorderInstance.loadSettings(); 738 | }) 739 | ); 740 | 741 | new Setting(container).setName('Recording contents').setHeading(); 742 | 743 | new Setting(container) 744 | .setName('Record content type') 745 | .setDesc('Select a type of content to record on specified notes.') 746 | .addDropdown(d => d 747 | .addOption('table', 'table') 748 | .addOption('bulletList', 'bullet list') 749 | .addOption('metadata', 'metadata') 750 | .setValue(settings.recordType) // need to show the modified value when next loading 751 | .onChange(async (value) => { 752 | settings.recordType = value; 753 | await this.plugin.saveSettings(); 754 | await this.updateSyntax(settings); // warning: must to be put after saving 755 | await this.updateInsertPlace(settings); 756 | // Show or hide subsettings based on dropdown value 757 | this.toggleSortByVisibility(value !== 'metadata'); 758 | this.toggleMTimeVisibility(value !== 'metadata'); 759 | recorderInstance.loadSettings(); 760 | }) 761 | ); 762 | 763 | this.makeMultilineTextSetting( 764 | new Setting(container) 765 | .setName('Wordflow recording syntax') 766 | .setDesc('Modified the syntax with \'${}\' syntax, see doc for supported regular expressions.\n') 767 | .addTextArea(text => { 768 | this.SyntaxComponent = text; 769 | if (settings.recordType == 'table'){ 770 | text.setValue(settings.tableSyntax); 771 | text.onChange(async (value) => { 772 | settings.tableSyntax = value; 773 | await this.plugin.saveSettings(); 774 | recorderInstance.loadSettings(); 775 | }) 776 | } 777 | if (settings.recordType == 'bulletList'){ 778 | text.setValue(settings.bulletListSyntax); 779 | text.onChange(async (value) => { 780 | settings.bulletListSyntax = value; 781 | await this.plugin.saveSettings(); 782 | recorderInstance.loadSettings(); 783 | }) 784 | } 785 | if (settings.recordType == 'metadata'){ 786 | text.setValue(settings.metadataSyntax); 787 | text.onChange(async (value) => { 788 | settings.metadataSyntax = value; 789 | await this.plugin.saveSettings(); 790 | recorderInstance.loadSettings(); 791 | }) 792 | } 793 | }) 794 | ); 795 | 796 | new Setting(container) 797 | .setName('Insert to position') 798 | .setDesc('Insert to this position if no previous record exist. If using a custom position, the start position and end position must exist and be unique in periodic note! Make sure your template is correctly applied while creating new periodic note. ') 799 | .addDropdown(d => { 800 | this.InsertPlaceComponent = d; 801 | if (settings.recordType === 'metadata') { 802 | d.addOption('yaml', 'yaml(frontmatter)'); 803 | } else { 804 | d.addOption('bottom', 'bottom'); 805 | d.addOption('custom', 'custom position'); 806 | } 807 | d.setValue(settings.insertPlace) 808 | .onChange(async (value) => { 809 | settings.insertPlace = value; 810 | await this.plugin.saveSettings(); 811 | // Show or hide subsettings based on dropdown value 812 | this.toggleCustomPositionSettings(value === 'custom'); 813 | recorderInstance.loadSettings(); 814 | }) 815 | }); 816 | 817 | const customSettingsContainer = container.createDiv(); 818 | customSettingsContainer.id = "custom-position-settings"; 819 | // Add custom CSS to remove separation between settings 820 | customSettingsContainer.addClass('wordflow-custom-container'); 821 | // Initially set visibility based on current value 822 | this.toggleCustomPositionSettings(settings.insertPlace === 'custom'); 823 | 824 | const insertPlaceStart = new Setting(customSettingsContainer) 825 | .setName('Start position') 826 | .setDesc('The records should be inserted after this content. Content between start position and end position would be replaced during recording. ') 827 | .addTextArea(text => text 828 | .setValue(settings.insertPlaceStart || '') 829 | .setPlaceholder('Replace with your periodic note content that exist in the periodic note template.\nFor example: ## Modified Note') 830 | .onChange(async (value) => { 831 | settings.insertPlaceStart = value; 832 | await this.plugin.saveSettings(); 833 | recorderInstance.loadSettings(); 834 | })); 835 | const insertPlaceEnd = new Setting(customSettingsContainer) 836 | .setName('End position') 837 | .setDesc('The records should be inserted before this content. Content between start position and end position would be replaced during recording. ') 838 | .addTextArea(text => text 839 | .setValue(settings.insertPlaceEnd || '') 840 | .setPlaceholder('Replace with your periodic note content that exist in the periodic note template.\nFor example: ## The next title after \'## Modified Note\'. ') 841 | .onChange(async (value) => { 842 | settings.insertPlaceEnd = value; 843 | await this.plugin.saveSettings(); 844 | recorderInstance.loadSettings(); 845 | })); 846 | 847 | this.makeMultilineTextSetting(insertPlaceStart); 848 | this.makeMultilineTextSetting(insertPlaceEnd); 849 | 850 | 851 | 852 | const sortBySettingsContainer = container.createDiv(); 853 | sortBySettingsContainer.id = "sort-by-settings"; 854 | // Add custom CSS to remove separation between settings 855 | sortBySettingsContainer.addClass('wordflow-sortby-container'); 856 | // Initially set visibility based on current value 857 | this.toggleSortByVisibility(settings.recordType !== 'metadata'); 858 | 859 | const sortBySetting = new Setting(sortBySettingsContainer) 860 | .setName('Sort by') 861 | .setDesc('Select a type of variables to add recording items in a sequence.') 862 | .addDropdown(d => d 863 | .addOption('lastModifiedTime', 'lastModifiedTime') 864 | .addOption('editedWords', 'editedWords') 865 | .addOption('editedTimes', 'editedTimes') 866 | .addOption('editedPercentage', 'editedPercentage') 867 | .addOption('modifiedNote', 'modifiedNote') 868 | .setValue(settings.sortBy) 869 | .onChange(async (value) => { 870 | settings.sortBy = value; 871 | await this.plugin.saveSettings(); 872 | recorderInstance.loadSettings(); 873 | }) 874 | ) 875 | .addDropdown(d => d 876 | .addOption('true', 'Descend') 877 | .addOption('false', 'Ascend') 878 | .setValue((settings.isDescend).toString()) 879 | .onChange(async (value) => { 880 | settings.isDescend = (value === 'true')?true:false; 881 | await this.plugin.saveSettings(); 882 | recorderInstance.loadSettings(); 883 | }) 884 | ); 885 | 886 | const mTimeFormatSettingsContainer = container.createDiv(); 887 | mTimeFormatSettingsContainer.id = "mtime-format-settings"; 888 | // Add custom CSS to remove separation between settings 889 | mTimeFormatSettingsContainer.addClass('wordflow-mtime-format-container'); 890 | // Initially set visibility based on current value 891 | this.toggleMTimeVisibility(settings.recordType !== 'metadata'); 892 | 893 | const mTimeFormatSetting = new Setting(mTimeFormatSettingsContainer) 894 | .setName('Last modified time format') 895 | .setDesc('Set the format of \'${lastModifiedTime}\' to record on notes.') 896 | .addText(text => text 897 | .setPlaceholder('YYYY-MM-DD | hh:mm') 898 | .setValue(settings.timeFormat) 899 | .onChange(async (value) => { 900 | settings.timeFormat = value; 901 | await this.plugin.saveSettings(); 902 | recorderInstance.loadSettings(); 903 | }) 904 | ); 905 | 906 | new Setting(container).setName('Recording miscellaneous').setHeading(); 907 | new Setting(container) 908 | .setName('Filter out non-modified notes') 909 | .setDesc('Whether the opened notes that are not modified should be excluded while recording. If not excluded, you will get any opened file under editing mode recorded. ') 910 | .addToggle(t => t 911 | .setValue(settings.filterZero) 912 | .onChange(async (value) => { 913 | settings.filterZero = value; 914 | await this.plugin.saveSettings(); 915 | recorderInstance.loadSettings(); 916 | }) 917 | ); 918 | 919 | new Setting(container) 920 | .setName('Automatic recording interval') 921 | .setDesc('Set the interval in seconds, influencing when the plugin should save all tracked records and implement them on periodic notes. Set to 0 to disable. ') 922 | .addText(text => text 923 | .setPlaceholder('Set to 0 to disable') 924 | .setValue(settings.autoRecordInterval) 925 | .onChange(async (value) => { 926 | settings.autoRecordInterval = value; 927 | await this.plugin.saveSettings(); 928 | recorderInstance.loadSettings(); 929 | }) 930 | ); 931 | } 932 | 933 | private PeriodicFolderComponent?: TextComponent; 934 | private SyntaxComponent?: TextAreaComponent; 935 | 936 | private async updateSyntax(settings: any) { 937 | if (!this.SyntaxComponent) return; 938 | switch (settings.recordType){ 939 | case 'table': this.SyntaxComponent.setValue(settings.tableSyntax); break; 940 | case 'bulletList': this.SyntaxComponent.setValue(settings.bulletListSyntax); break; 941 | case 'metadata': this.SyntaxComponent.setValue(settings.metadataSyntax); break; 942 | } 943 | }; 944 | 945 | private InsertPlaceComponent?: DropdownComponent; 946 | private async updateInsertPlace(settings: any): Promise{ 947 | if (!this.InsertPlaceComponent) return; 948 | this.InsertPlaceComponent.selectEl.innerHTML = ''; 949 | if (settings.recordType == 'metadata'){ 950 | this.InsertPlaceComponent.addOption('yaml', 'yaml/frontmatter'); 951 | this.InsertPlaceComponent.setValue('yaml'); 952 | settings.insertPlace = 'yaml'; 953 | } else { 954 | this.InsertPlaceComponent.addOption('bottom', 'bottom'); 955 | this.InsertPlaceComponent.addOption('custom', 'custom position'); 956 | this.InsertPlaceComponent.setValue('bottom'); 957 | settings.insertPlace = 'bottom'; 958 | } 959 | this.toggleCustomPositionSettings(false); 960 | await this.plugin.saveSettings(); 961 | } 962 | 963 | private toggleCustomPositionSettings(show: boolean) { 964 | const customSettingsContainer = document.getElementById("custom-position-settings"); 965 | if (customSettingsContainer) { 966 | customSettingsContainer.style.display = show ? "block" : "none"; 967 | } 968 | } 969 | 970 | private toggleSortByVisibility(show: boolean) { 971 | const sortBySettingsContainer = document.getElementById("sort-by-settings"); 972 | if (sortBySettingsContainer) { 973 | sortBySettingsContainer.style.display = show ? "block" : "none"; 974 | } 975 | } 976 | 977 | private toggleMTimeVisibility(show: boolean) { 978 | const mTimeFormatSettingsContainer = document.getElementById("mtime-format-settings"); 979 | if (mTimeFormatSettingsContainer) { 980 | mTimeFormatSettingsContainer.style.display = show ? "block" : "none"; 981 | } 982 | } 983 | 984 | // modified from https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/src/Config/SettingsTab.ts#L842 985 | private makeMultilineTextSetting(setting: Setting) { 986 | const { settingEl, infoEl, controlEl } = setting; 987 | const textEl: HTMLElement | null = controlEl.querySelector('textarea'); 988 | 989 | // Not a setting with a text field 990 | if (textEl === null) { 991 | return; 992 | } 993 | // for styles.css 994 | settingEl.classList.add('wordflow-multiline-setting'); 995 | infoEl.classList.add('wordflow-info'); 996 | textEl.classList.add('wordflow-textarea'); 997 | }; 998 | 999 | private confirmReset(){ 1000 | new ConfirmationModal( 1001 | this.app, 1002 | "Are you sure to reset all settings of wordflow tracker?", 1003 | () => this.resetSettings() 1004 | ).open(); 1005 | } 1006 | 1007 | private async resetSettings() { 1008 | try { 1009 | this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS) 1010 | await this.plugin.saveSettings(); 1011 | new Notice('✅ Settings are reset to default!', 3000); 1012 | await sleep(100); 1013 | this.display(); 1014 | } catch (error) { 1015 | console.error("Could not reset settings:", error); 1016 | new Notice('❌ Could not reset settings! Check console!'); 1017 | } 1018 | } 1019 | } 1020 | 1021 | class RecorderRenameModal extends Modal { 1022 | private currentName: string; 1023 | private onSubmit: (newName: string) => Promise; 1024 | 1025 | constructor(app: App, currentName: string, onSubmit: (newName: string) => Promise) { 1026 | super(app); 1027 | this.currentName = currentName; 1028 | this.onSubmit = onSubmit; 1029 | } 1030 | 1031 | onOpen() { 1032 | const { contentEl } = this; 1033 | 1034 | new Setting(contentEl) 1035 | .setName("Rename recorder") 1036 | .setHeading 1037 | 1038 | const inputContainer = contentEl.createDiv(); 1039 | const nameInput = inputContainer.createEl("input", { 1040 | type: "text", 1041 | value: this.currentName 1042 | }); 1043 | nameInput.style.width = "100%"; 1044 | nameInput.focus(); 1045 | 1046 | const buttonContainer = contentEl.createDiv("recorder-rename-buttons"); 1047 | buttonContainer.style.marginTop = "1rem"; 1048 | 1049 | new ButtonComponent(buttonContainer) 1050 | .setButtonText("Save") 1051 | .setCta() 1052 | .onClick(async () => { 1053 | const newName = nameInput.value.trim(); 1054 | if (newName) { 1055 | await this.onSubmit(newName); 1056 | this.close(); 1057 | } 1058 | }); 1059 | 1060 | new ButtonComponent(buttonContainer) 1061 | .setButtonText("Cancel") 1062 | .onClick(() => this.close()); 1063 | } 1064 | 1065 | onClose() { 1066 | const { contentEl } = this; 1067 | contentEl.empty(); 1068 | } 1069 | } -------------------------------------------------------------------------------- /src/stats.ts: -------------------------------------------------------------------------------- 1 | export function wordsCounter() { 2 | let inWord = false; 3 | return function (input: string): number { 4 | //console.log("string:", input) //debug 5 | let count = 0; 6 | for (const char of input) { 7 | const isWordChar = /^[\w'-]$/.test(char); 8 | const isCJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(char); 9 | if (isWordChar) { 10 | if (!inWord) { 11 | count++; 12 | inWord = true; 13 | } 14 | } else if (isCJK) { 15 | count++; 16 | inWord = false; 17 | } else { 18 | if (inWord) { 19 | inWord = false; 20 | } 21 | } 22 | } 23 | return count; 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .recorder-selection-container, 2 | .recorder-actions-container { 3 | margin-bottom: 16px; 4 | } 5 | /* 6 | .settings-separator { 7 | margin: 11.25px 0; 8 | }*/ 9 | 10 | .setting-item.recorder-settings-heading { 11 | padding-top: 0px; 12 | padding-bottom: 0px; 13 | } 14 | 15 | .setting-item.recorder-settings-heading .setting-item-name { 16 | font-size: large; 17 | color: var(--text-accent); 18 | /*color: rgb(67, 132, 216);*/ 19 | } 20 | 21 | .confirm-modal .modal { 22 | width: auto; 23 | max-width: 80vw; 24 | min-width: 300px; 25 | margin: 0 auto; 26 | } 27 | 28 | .confirm-modal .modal-content { 29 | display: flex; 30 | flex-direction: column; 31 | } 32 | 33 | .confirm-modal .confirm-message { 34 | white-space: pre-wrap; 35 | overflow-wrap: break-word; 36 | } 37 | 38 | .confirm-modal .confirm-cancel-buttons button { 39 | flex: 1; 40 | max-width: 48%; 41 | } 42 | 43 | .confirm-modal .confirm-cancel-buttons { 44 | display: flex; 45 | justify-content: space-between; 46 | margin-top: auto; 47 | padding-top: 20px; 48 | width: 100%; 49 | gap: 16px; 50 | flex-direction: row-reverse; 51 | } 52 | 53 | .wordflow-setting-tab .wordflow-multiline-setting { 54 | display: block; 55 | } 56 | 57 | .wordflow-setting-tab .wordflow-info { 58 | margin-right: 0; 59 | margin-bottom: 8px; 60 | } 61 | 62 | .wordflow-setting-tab .wordflow-textarea { 63 | min-width: -webkit-fill-available; 64 | min-height: 80px; 65 | } 66 | 67 | .wordflow-custom-container .setting-item { 68 | border-top: none; 69 | padding-top: 0; 70 | } 71 | 72 | .wordflow-custom-container .setting-item:first-child { 73 | border-top: 1px solid var(--background-modifier-border); 74 | padding-top: 12px; 75 | } 76 | 77 | 78 | .wordflow-sortby-container .setting-item:first-child { 79 | border-top: 1px solid var(--background-modifier-border); 80 | padding-top: 12px; 81 | } 82 | 83 | .wordflow-mtime-format-container .setting-item:first-child { 84 | border-top: 1px solid var(--background-modifier-border); 85 | padding-top: 12px; 86 | } 87 | 88 | .edited-percentage::after { 89 | content: attr(data-percentage) "%"; 90 | } 91 | 92 | .stat-bar-container { 93 | display: inline-flex; 94 | width: 160px; 95 | height: 12px; 96 | border-radius: 6px; 97 | overflow: hidden; 98 | } 99 | 100 | .stat-bar { 101 | height: 100%; 102 | } 103 | 104 | .stat-bar.origin { 105 | background-color: #FFD700; 106 | border-radius: 6px 0 0 6px; 107 | } 108 | 109 | .stat-bar.deleted { 110 | background-color: #FF4D4D; 111 | } 112 | 113 | .stat-bar.added { 114 | background-color: #00CC66; 115 | border-radius: 0 6px 6px 0; 116 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "allowSyntheticDefaultImports": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts", 24 | "**/*.css", 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.0.1": "1.7.0" 4 | } --------------------------------------------------------------------------------