├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── README_ADVANCED.md ├── README_BASIC.md ├── attachment ├── 74103317de5336b5283338c56171f268.png ├── 75cbb797dc58593b204e3e1b47d7146e.png ├── advance-1.png ├── advance-10.png ├── advance-11.png ├── advance-2.png ├── advance-3.png ├── advance-4.png ├── advance-5.png ├── advance-6.png ├── advance-7.png ├── advance-8.png ├── advance-9.png ├── calendar.png ├── contribution-graph-create.gif ├── contribution-graph-edit.gif ├── customized-date-field.png ├── d20ba90e31c16a3c4d79cba9298577de.png ├── fixed-date-range.png ├── hide-rule-indicators.png ├── image-1.png ├── image-2.png ├── image.png ├── month-track.png ├── personized-cell-color.png ├── personizezd-cell-text.png ├── quick-start.png ├── simple-usage.png ├── start-of-week.png └── title-style.png ├── esbuild.config.mjs ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── constants.ts ├── i18 │ ├── en.ts │ ├── messages.ts │ ├── types.ts │ └── zh.ts ├── main.ts ├── processor │ ├── bizErrors.ts │ ├── codeBlockProcessor.ts │ ├── graphProcessError.ts │ ├── types.ts │ └── yamlConfigReconciler.ts ├── query │ ├── baseDataviewSourceQuery.ts │ ├── compositeDataSourceQuery.ts │ ├── dataviewPageDataSourceQuery.ts │ ├── dataviewTaskDataSourceQuery.ts │ ├── filter │ │ └── dataviewDataFilter.ts │ └── types.ts ├── render │ ├── calendarGraphRender.ts │ ├── gitStyleTrackGraphRender.ts │ ├── graphRender.ts │ ├── matrixDataGenerator.ts │ ├── monthTrackGraphRender.ts │ └── renders.ts ├── types.ts ├── util │ ├── dateTimeUtils.ts │ ├── dateUtils.ts │ ├── page.ts │ └── utils.ts └── view │ ├── about │ ├── index.css │ └── index.tsx │ ├── choose │ └── Choose.tsx │ ├── codeblock │ └── CodeblockEditButtonMount.ts │ ├── divider │ └── Divider.tsx │ ├── form │ ├── CellRuleFormItem.tsx │ ├── ColorPicker.tsx │ ├── DataSourceFormItem.tsx │ ├── GraphForm.tsx │ ├── GraphFormModal.tsx │ ├── GraphTheme.tsx │ └── options.tsx │ ├── icon │ └── Icons.tsx │ ├── number-input │ └── index.tsx │ ├── suggest │ ├── Suggest.tsx │ ├── SuggestInput.tsx │ └── SuggestTagInput.tsx │ └── tab │ └── Tab.tsx ├── style └── styles.css ├── 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 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | main.js 3 | dist 4 | out 5 | .gitignore -------------------------------------------------------------------------------- /.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 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | --draft \ 34 | 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 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![](attachment/d20ba90e31c16a3c4d79cba9298577de.png) 3 | 4 | 5 | **English** | [中文文档](https://mp.weixin.qq.com/s/wI8M_C87oZAtCBjFWC8CmA) 6 | 7 | ## What 8 | 9 | Contribution Graph is a plugin for [obsidian.md](https://obsidian.md/) which can generate interactive heatmap graphs like GitHub to track your notes, habits, activity, history, and so on. 10 | 11 | 12 | 13 | buy me a coffee 14 | 15 | 16 | 17 | ## Use Cases 18 | 19 | - Habit Tracker: Count the number of tasks you complete every day. Different numbers will be marked in different colors. 20 | - Note Tracker: Count the number of notes you create every day. Different numbers will be marked in different colors. 21 | - Review Report: Count your notes or tasks for a certain period of time and generate a heat map for a more intuitive review 22 | - and more... 23 | 24 | ## Quick Start 25 | 26 | - Create empty note, then right-click 27 | - Select **Add Heatmap** options 28 | - Click the `save` button, and then a heatmap will be created in note. 29 | 30 | ![Alt text](attachment/contribution-graph-create.gif) 31 | 32 | ## Themes 33 | 34 | - Git Style 35 | 36 | ![alt text](attachment/image-1.png) 37 | 38 | - Month Track 39 | 40 | ![alt text](attachment/image-2.png) 41 | 42 | - Calendar 43 | 44 | ![alt text](attachment/image.png) 45 | 46 | ## Features 47 | 48 | - **Multiple graph types**, support week-track(default), month-track, and calendar view. 49 | - **Personalized style**, you can configure cell colors and fill cells with emojis. 50 | - **Customizable dates**,use fixed date range or latest date to generate graph 51 | - **Interactive charts**, you can customize cell click event, hover to show statistic data 52 | - **Integrate with DataviewJS**, use contribution graph's api to dynamically render charts 53 | 54 | ![](attachment/74103317de5336b5283338c56171f268.png) 55 | 56 | 57 | ### How to Modify Graph? 58 | 59 | Just click the edit button at top right corner 60 | 61 | ![Alt text](attachment/contribution-graph-edit.gif) 62 | 63 | ### Configurations 64 | 65 | | name | description | type | default | sample | required | 66 | | ---------------------- | --------------------------------------------------------------------- | ----------------------- | ---------- | ---------- | ---------------------------------------- | 67 | | title | the title of the graph | string | Contributions | | false | 68 | | titleStyle | the style of the title | object | | | false | 69 | | days | Maximum number of days for the chart to display (starting from today) | number | | 365 | true if miss **fromDate** and **toDate** | 70 | | fromDate | The start date of the chart | date, format yyyy-MM-dd | | 2023-01-01 | true if miss **days** | 71 | | toDate | The end date of the chart | date, format yyyy-MM-dd | | 2023-12-31 | true if miss **days** | 72 | | query | dataview query syntax, contribution graph will use it to count files | string | | | true | 73 | | dateField | Date attributes of files used for data distribution | string | file.ctime | createTime | false | 74 | | startOfWeek | start of week | number | 0 | | false | 75 | | showCellRuleIndicators | Control the display and hiding of cell rule indicator elements | boolean | true | | false | 76 | | cellStyleRules | cell style rule | array | | | false | 77 | 78 | ## More Usage Guides 79 | 80 | - [API Usage, Integrate with DataviewJS ](README_ADVANCED.md) 81 | - [Basic Codeblock Usage](README_BASIC.md) 82 | -------------------------------------------------------------------------------- /README_ADVANCED.md: -------------------------------------------------------------------------------- 1 | ## Examples of Week Track Graph 2 | 3 | The following shows how to render charts using dataviewjs 4 | 5 | ## Create a week track graph for a fixed time period 6 | 7 | ### Week track graph for fixed year 8 | 9 | ![Alt text](attachment/advance-1.png) 10 | 11 | ```js 12 | const from = '2022-01-01' 13 | const to = '2022-12-31' 14 | const data = [ 15 | { 16 | date: '2022-01-01', // yyyy-MM-dd 17 | value: 1 18 | }, 19 | { 20 | date: '2022-02-01', // yyyy-MM-dd 21 | value: 2 22 | }, 23 | { 24 | date: '2022-03-01', // yyyy-MM-dd 25 | value: 3 26 | }, 27 | { 28 | date: '2022-04-01', // yyyy-MM-dd 29 | value: 4 30 | }, 31 | { 32 | date: '2022-05-01', // yyyy-MM-dd 33 | value: 5 34 | } 35 | ] 36 | 37 | const calendarData = { 38 | title: `${from} to ${to}`, // graph title 39 | data: data, // data 40 | fromDate: from, // from date, yyyy-MM-dd 41 | toDate: to // to date, yyyy-MM-dd 42 | } 43 | renderContributionGraph(this.container, calendarData) 44 | ``` 45 | 46 | 47 | ### Week track graph for current year 48 | 49 | ![Alt text](attachment/advance-2.png) 50 | 51 | ```js 52 | const currentYear = new Date().getFullYear() 53 | const from = currentYear + '-01-01' 54 | const to = currentYear + '-12-31' 55 | const data = dv.pages('#project') 56 | .groupBy(p => p.file.ctime.toFormat('yyyy-MM-dd')) // 57 | .map(entry =>{ 58 | return { 59 | date: entry.key, 60 | value: entry.rows.length 61 | } 62 | }) 63 | 64 | const calendarData = { 65 | title: `${from} to ${to}`, 66 | data: data, 67 | fromDate: from, 68 | toDate: to 69 | } 70 | renderContributionGraph(this.container, calendarData) 71 | ``` 72 | 73 | ### Week track graph for current month 74 | 75 | ![Alt text](attachment/advance-3.png) 76 | 77 | ```js 78 | const currentYear = new Date().getFullYear() 79 | const month = new Date().getMonth()// 0~11 80 | const nextMonth = month + 1 81 | const lastDayOfCurrentMonth = new Date(currentYear, nextMonth, 0).getDate() 82 | const formattedLastDayOfCurrentMonth = lastDayOfCurrentMonth < 10 ? '0'+lastDayOfCurrentMonth:lastDayOfCurrentMonth 83 | const formattedMonth = month < 9 ? '0' + (month+1): '' + (month+1) 84 | const from = `${currentYear}-${formattedMonth}-01'` 85 | const to = `${currentYear}-${formattedMonth}-${formattedLastDayOfCurrentMonth}'` 86 | 87 | const data = dv.pages('#project') 88 | .groupBy(p => p.file.ctime.toFormat('yyyy-MM-dd')) 89 | .map(entry => { 90 | return { 91 | date: entry.key, 92 | value: entry.rows.length 93 | } 94 | }) 95 | 96 | const calendarData = { 97 | title: `${from} to ${to}`, 98 | data: data, 99 | fromDate: from, 100 | toDate: to 101 | } 102 | renderContributionGraph(this.container, calendarData) 103 | ``` 104 | 105 | ### week track graph for current week 106 | 107 | ![Alt text](attachment/advance-4.png) 108 | 109 | 110 | ```js 111 | function formatDateString(date) { 112 | var year = date.getFullYear(); 113 | var month = String(date.getMonth() + 1).padStart(2, '0'); 114 | var day = String(date.getDate()).padStart(2, '0'); 115 | return year + '-' + month + '-' + day; 116 | } 117 | 118 | function getStartAndEndOfWeek() { 119 | var currentDate = new Date(); 120 | var currentDayOfWeek = currentDate.getDay(); 121 | var diffToStartOfWeek = currentDayOfWeek === 0 ? 6 : currentDayOfWeek - 1; 122 | var startOfWeek = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() - diffToStartOfWeek); 123 | var endOfWeek = new Date(startOfWeek.getFullYear(), startOfWeek.getMonth(), startOfWeek.getDate() + 6); 124 | 125 | var formattedStart = formatDateString(startOfWeek); 126 | var formattedEnd = formatDateString(endOfWeek); 127 | 128 | return { 129 | start: formattedStart, 130 | end: formattedEnd 131 | }; 132 | } 133 | 134 | const data = [] // your data here 135 | const weekDate = getStartAndEndOfWeek() 136 | const from = weekDate.start 137 | const to = weekDate.end 138 | 139 | const calendarData = { 140 | title: `${from} to ${to}`, 141 | data: data, 142 | fromDate: from, 143 | toDate: to 144 | } 145 | renderContributionGraph(this.container, calendarData) 146 | 147 | ``` 148 | 149 | ## Create a week track graph at relative time periods 150 | 151 | fixed dates, you can also use the days attribute to generate a chart of relative dates 152 | 153 | ### Week track graph in the lastest 365 days 154 | 155 | ![Alt text](attachment/advance-5.png) 156 | 157 | ```js 158 | const data = dv.pages('#project') 159 | .groupBy(p => p.file.ctime.toFormat('yyyy-MM-dd')) 160 | .map(entry =>{ 161 | return { 162 | date: entry.key, 163 | value: entry.rows.length 164 | } 165 | }) 166 | const calendarData = { 167 | days: 365, 168 | title: 'Contributions in the last 365 days ', 169 | data: data 170 | } 171 | renderContributionGraph(this.container, calendarData) 172 | ``` 173 | ### Settings for the week track grapg 174 | #### Begin with Monday 175 | 176 | By default, the first row represents Sunday, you can change it by configuring `startOfWeek`, the allowable values is 0~6 177 | 178 | ![Alt text](attachment/advance-6.png) 179 | 180 | ```js 181 | const currentYear = new Date().getFullYear() 182 | const from = currentYear + '-01-01' 183 | const to = currentYear + '-12-31' 184 | const data = dv.pages('""') 185 | .groupBy(p => p.file.ctime.toFormat('yyyy-MM-dd')) 186 | .map(p => { 187 | return { 188 | date: p.key, 189 | value: p.rows.length 190 | } 191 | }) 192 | 193 | const calendarData = { 194 | title: `${from} to ${to}`, 195 | data: data, 196 | fromDate: from, 197 | toDate: to, 198 | startOfWeek: 1 // set to 1 means start with monday 199 | } 200 | renderContributionGraph(this.container, calendarData) 201 | ``` 202 | 203 | #### Customize cell click event 204 | 205 | By configuring the oncellclick attribute, you can set the cell click behavior you want. 206 | 207 | The following shows an example of automatically performing a keyword search after clicking on a cell. 208 | 209 | ![Alt text](attachment/advance-7.png) 210 | 211 | ```js 212 | const data = dv.pages('#project') 213 | .groupBy(p => p.file.ctime.toFormat('yyyy-MM-dd')) 214 | .map(entry =>{ 215 | return { 216 | date: entry.key, 217 | value: entry.rows.length 218 | } 219 | }) 220 | const calendarData = { 221 | days: 365, 222 | title: 'Contributions in the last 365 days ', 223 | data: data, 224 | onCellClick: (item) => { 225 | // generate search key 226 | const key = `["tags":project] ["createTime":${item.date}]` 227 | // use global-search plugin to search data 228 | app.internalPlugins.plugins['global-search'].instance.openGlobalSearch(key) 229 | }, 230 | } 231 | renderContributionGraph(this.container, calendarData) 232 | ``` 233 | #### Customize Cells 234 | 235 | By configuring the cellStyleRules attribute, you can customize the cell's background color or inner text 236 | 237 | if the number of contributions at a specified date is larger or equal to `min`, less than `max`, then the `rule` will be matched 238 | 239 | > min <= {contributions} < max 240 | 241 | | name | type | description | 242 | | ----- | ------ | ----------- | 243 | | color | string | hex color | 244 | | min | number | the min contribution | 245 | | max | number | the max contribution | 246 | 247 | - customize background color 248 | 249 | ![Alt text](attachment/advance-8.png) 250 | 251 | ```js 252 | const data = dv.pages('#project') 253 | .filter(p => p.createTime) 254 | .groupBy(p => p.createTime.toFormat('yyyy-MM-dd')) // your shold hava createTime field 255 | .map(entry =>{ 256 | return { 257 | date: entry.key, 258 | value: entry.rows.length 259 | } 260 | }) 261 | const calendarData = { 262 | days: 365, 263 | title: 'Contributions in the last 365 days ', 264 | data: data, 265 | onCellClick: (item) => { 266 | const key = `["tags":project] ["createTime":${item.date}]` 267 | app.internalPlugins.plugins['global-search'].instance.openGlobalSearch(key) 268 | }, 269 | cellStyleRules: [ 270 | { 271 | color: "#f1d0b4", 272 | min: 1, 273 | max: 2, 274 | }, 275 | { 276 | color: "#e6a875", 277 | min: 2, 278 | max: 3, 279 | }, 280 | { 281 | color: "#d97d31", 282 | min: 3, 283 | max: 4, 284 | }, 285 | { 286 | color: "#b75d13", 287 | min: 4, 288 | max: 999, 289 | }, 290 | ] 291 | } 292 | renderContributionGraph(this.container, calendarData) 293 | ``` 294 | 295 | #### Customize inner text 296 | 297 | ![Alt text](attachment/advance-9.png) 298 | 299 | ```js 300 | const data = dv.pages('#project') 301 | .groupBy(p => p.file.ctime.toFormat('yyyy-MM-dd')) 302 | .map(entry =>{ 303 | return { 304 | date: entry.key, 305 | value: entry.rows.length 306 | } 307 | }) 308 | const calendarData = { 309 | days: 365, 310 | title: 'Contributions in the last 365 days ', 311 | data: data, 312 | onCellClick: (item) => { 313 | const key = `["tags":project] ["createTime":${item.date}]` 314 | app.internalPlugins.plugins['global-search'].instance.openGlobalSearch(key) 315 | }, 316 | cellStyleRules: [ 317 | { 318 | min: 1, 319 | max: 2, 320 | text: '🌲' 321 | }, 322 | { 323 | min: 2, 324 | max: 3, 325 | text: '😥' 326 | }, 327 | { 328 | min: 3, 329 | max: 4, 330 | text: '✈' 331 | }, 332 | { 333 | min: 4, 334 | max: 99, 335 | text: '✈' 336 | } 337 | ] 338 | } 339 | renderContributionGraph(this.container, calendarData) 340 | ``` 341 | 342 | ## Use Month Track Graph 343 | 344 | In addition to the weekly tracking chart (the default), you can also generate a monthly tracking chart. 345 | 346 | In a monthly tracking chart, each row represents the date of an entire month, like this 347 | 348 | ![Alt text](attachment/advance-10.png) 349 | 350 | Configuration is very simple, just set the graphType to month-track and you're good to go! 351 | 352 | ```js 353 | const from = '2022-01-01' 354 | const to = '2022-12-31' 355 | const data = dv.pages('#project') 356 | .groupBy(p => p.file.ctime.toFormat('yyyy-MM-dd')) 357 | .map(entry => { 358 | return { date: entry.key, value: entry.rows.length } 359 | }) 360 | 361 | const options = { 362 | title: `Contributions from ${from} to ${to}`, 363 | data: data, 364 | days: 365, 365 | fromDate: from, 366 | toDate: to, 367 | graphType: "month-track" // set this field value as 'month-track' 368 | } 369 | renderContributionGraph(this.container, options) 370 | ``` 371 | 372 | ## Use Calendar Graph 373 | 374 | Same as the previous example, it's only need to set the graphType to calendar and you will get a calendar graph 375 | 376 | ![Alt text](attachment/advance-11.png) 377 | 378 | ## Full Render Configuration 379 | 380 | ```js 381 | 382 | export class ContributionGraphConfig { 383 | /** 384 | * the title of the graph 385 | */ 386 | title = "Contribution Graph"; 387 | 388 | /** 389 | * the style of the titleo 390 | */ 391 | titleStyle: Partial = {}; 392 | 393 | /** 394 | * recent days to show 395 | */ 396 | days?: number | undefined; 397 | 398 | /** 399 | * the start date of the graph,if `days` is set, this value will be ignored 400 | */ 401 | fromDate?: Date | string | undefined; 402 | 403 | /** 404 | * the end date of the graph,if `days` is set, this value will be ignored 405 | */ 406 | toDate?: Date | string | undefined; 407 | 408 | /** 409 | * the data to show at cell 410 | */ 411 | data: Contribution[]; 412 | 413 | /** 414 | * the rules to style the cell 415 | */ 416 | cellStyleRules: CellStyleRule[] = DEFAULT_RULES; 417 | 418 | /** 419 | * set false to hide rule indicators 420 | */ 421 | showCellRuleIndicators = true; 422 | 423 | /** 424 | * `default`: every column is a week day from top to bottom 425 | * `month-track`: every row is a month from left to right 426 | * 427 | * default value: `default` 428 | */ 429 | graphType: "default" | "month-track" | "calendar" = "default"; 430 | 431 | /** 432 | * value range: 0->Sunday, 1->Monday, 2->Tuesday, 3->Wednesday, 4->Thursday, 5->Friday, 6->Saturday 433 | * default value: 0 434 | * notice: it's not work when `graphType` is `month-track` 435 | */ 436 | startOfWeek: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0; 437 | 438 | /** 439 | * callback when cell is clicked 440 | */ 441 | onCellClick?: ( 442 | cellData: ContributionCellData, 443 | event: MouseEvent | undefined 444 | ) => void | undefined; 445 | } 446 | 447 | export interface Contribution { 448 | /** 449 | * the date of the contribution, format: yyyy-MM-dd 450 | */ 451 | date: string; 452 | /** 453 | * the value of the contribution 454 | */ 455 | value: number; 456 | /** 457 | * the summary of the contribution, will be shown when hover on the cell 458 | */ 459 | summary: string | undefined; 460 | } 461 | 462 | export interface CellStyleRule { 463 | // the background color for the cell 464 | color: string; 465 | // the text in the cell 466 | text?: string | undefined; 467 | // the inlusive min value 468 | min: number; 469 | // the exclusive max value 470 | max: number; 471 | } 472 | 473 | ``` -------------------------------------------------------------------------------- /README_BASIC.md: -------------------------------------------------------------------------------- 1 | ## Showcase 2 | 3 | > If you are interested in dataviewJS integration, see [here](README_ADVANCED.md) 4 | 5 | Please note two points 6 | 7 | - All configuration is placed within the `contributionGraph` **codeblock** 8 | - Configuration is essentially using the [yaml](https://yaml.org/) format 9 | 10 | ![](attachment/75cbb797dc58593b204e3e1b47d7146e.png) 11 | 12 | ### Simple Usage 13 | 14 | Generate a chart based on files containing the `project` tag created in the last 365 days 15 | 16 | ![Alt text](attachment/simple-usage.png) 17 | 18 | ```yaml 19 | title: 'Contributions' # the title of the contribution 20 | days: 365 21 | query: '#project' # list all fils which contains `project` tag 22 | ``` 23 | 24 | Basic **Query** Examples 25 | 26 | - `'archive'`: all files in `archive` folder 27 | - `'#food and !#fastfood'`: pages that contain `#food` but does not contain `#fastfood`. 28 | - `'#tag and "folder"'`: pages in `folder` and with `#tag`. 29 | 30 | see [dataview documentation](https://blacksmithgu.github.io/obsidian-dataview/reference/sources/#combining-sources) to learn more. 31 | 32 | ## Fixed date range 33 | 34 | You can generate fixed date range charts by configuring `fromDate` and `toDate`, 35 | 36 | ![Alt text](attachment/fixed-date-range.png) 37 | 38 | 39 | ```yaml 40 | title: 'Contributions' # the title of the contribution 41 | fromDate: '2023-01-01' # date format must be yyyy-MM-dd 42 | toDate: '2023-12-01' 43 | query: '#project' # list all fils which contains `project` tag 44 | ``` 45 | 46 | ### Customize Date Field 47 | 48 | By default, contribution charts are generated based on the creation time of the file (`file.ctime`). 49 | 50 | If you want you to generate charts based on the custom date attributes of the file, such as `createTime` or `doneTime` in the **frontmatter**, just set the dateField to the field you want, see example below. 51 | 52 | ![Alt text](attachment/customized-date-field.png) 53 | 54 | ```yaml 55 | title: 'Contributions' # the title of the contribution 56 | fromDate: '2023-01-01' # date format must be yyyy-MM-dd 57 | toDate: '2023-12-01' 58 | dateField: 'createTime' # use customized field to genrate charts 59 | query: '#project' # list all fils which contains `project` tag 60 | ``` 61 | ### Start Of Week 62 | 63 | As default, charts start with sunday as the first day of the week. You can change this by changing `startOfWeek`(only work in week-track and calendar view). 64 | 65 | Supported values are 0~6, representing Sunday, Monday to Saturday respectively. 66 | 67 | ![Alt text](attachment/start-of-week.png) 68 | 69 | 70 | ```yaml 71 | title: 'Contributions' # the title of the contribution 72 | fromDate: '2023-01-01' # date format must be yyyy-MM-dd 73 | toDate: '2023-12-01' 74 | startOfWeek: 1 # start with monday 75 | dateField: 'createTime' # use customized field to genrate charts 76 | query: '#project' # list all fils which contains `project` tag 77 | ``` 78 | 79 | ### Month Track View and Calendar View 80 | 81 | Default view type is week-track, github style charts. You can change the graph type by changing `graphType` 82 | 83 | - **month-track** 84 | 85 | ![Alt text](attachment/month-track.png) 86 | 87 | ```yaml 88 | title: 'Contributions' # the title of the contribution 89 | fromDate: '2023-01-01' # date format must be yyyy-MM-dd 90 | toDate: '2023-12-01' 91 | startOfWeek: 1 # start with monday 92 | dateField: 'createTime' # use customized field to genrate charts 93 | query: '#project' # list all fils which contains `project` tag 94 | graphType: 'month-track' 95 | ``` 96 | 97 | - **calendar** 98 | 99 | ![Alt text](attachment/calendar.png) 100 | 101 | 102 | ```yaml 103 | title: 'Contributions' # the title of the contribution 104 | fromDate: '2023-01-01' # date format must be yyyy-MM-dd 105 | toDate: '2023-12-01' 106 | startOfWeek: 1 # start with monday 107 | dateField: 'createTime' # use customized field to genrate charts 108 | query: '#project' # list all fils which contains `project` tag 109 | graphType: 'calendar' 110 | ``` 111 | ### Customize Cell Color 112 | 113 | You can customize your contribution graph by setting cellStyleRules, like this 114 | 115 | ![Alt text](attachment/personized-cell-color.png) 116 | 117 | ```yaml 118 | title: 'Contributions' # the title of the contribution 119 | fromDate: '2023-01-01' # date format must be yyyy-MM-dd 120 | toDate: '2023-12-01' 121 | startOfWeek: 1 # start with monday 122 | dateField: 'createTime' # use customized field to genrate charts 123 | query: '#project' # list all fils which contains `project` tag 124 | graphType: 'calendar' 125 | cellStyleRules: # personized your graph style 126 | - color: '#f1d0b4' 127 | min: 1 128 | max: 2 129 | - color: '#e6a875' 130 | min: 2 131 | max: 3 132 | - color: '#d97d31' 133 | min: 3 134 | max: 4 135 | - color: '#b75d13' 136 | min: 4 137 | max: 999 138 | ``` 139 | 140 | ### Customize Cell Text 141 | 142 | In addition to colors, you can also personalize your charts using emoji or text 143 | 144 | ![Alt text](attachment/personizezd-cell-text.png) 145 | 146 | ```yaml 147 | title: 'Contributions' # the title of the contribution 148 | fromDate: '2023-01-01' # date format must be yyyy-MM-dd 149 | toDate: '2023-12-01' 150 | startOfWeek: 1 # start with monday 151 | dateField: 'createTime' # use customized field to genrate charts 152 | query: '#project' # list all fils which contains `project` tag 153 | graphType: 'default' 154 | cellStyleRules: # personized your graph style 155 | - text: '✅' 156 | min: 1 157 | max: 2 158 | - text: '🌳' 159 | min: 2 160 | max: 3 161 | - text: '🚩' 162 | min: 3 163 | max: 4 164 | - text: '🚀' 165 | min: 4 166 | max: 999 167 | ``` 168 | 169 | ### Hide Cell Rule Indicators 170 | 171 | If you don't like the cell indicators in the lower right corner, you can change `showCellRuleIndicators` to false to hide it. 172 | 173 | ![Alt text](attachment/hide-rule-indicators.png) 174 | 175 | ```yaml 176 | title: 'Contributions' # the title of the contribution 177 | fromDate: '2023-01-01' # date format must be yyyy-MM-dd 178 | toDate: '2023-12-01' 179 | startOfWeek: 1 # start with monday 180 | dateField: 'createTime' # use customized field to genrate charts 181 | query: '#project' # list all fils which contains `project` tag 182 | graphType: 'default' 183 | showCellRuleIndicators: false 184 | ``` 185 | 186 | ### Change Title Style 187 | 188 | You can customize your favorite title style by changing `titleStyle` field, it's support almost all css properties, such as 189 | 190 | - textAlign 191 | - backgroundColor 192 | - color 193 | - fontSize 194 | 195 | ![Alt text](attachment/title-style.png) 196 | 197 | ```yaml 198 | title: 'Customized Title Style' # the title of the contribution 199 | titleStyle: 200 | textAlign: 'center' # change to center, you can set left, right 201 | backgroundColor: 'transparent' 202 | fontSize: '24px' 203 | color: 'Green' # font color 204 | fromDate: '2023-01-01' # date format must be yyyy-MM-dd 205 | toDate: '2023-12-01' 206 | startOfWeek: 1 # start with monday 207 | dateField: 'createTime' # use customized field to genrate charts 208 | query: '#project' # list all fils which contains `project` tag 209 | graphType: 'default' 210 | showCellRuleIndicators: false 211 | ``` 212 | 213 | 214 | ## Full Codeblock Configuration 215 | 216 | | name | description | type | default | sample | required | 217 | | ---------------------- | --------------------------------------------------------------------- | ----------------------- | ---------- | ---------- | ---------------------------------------- | 218 | | title | The title of the graph | string | '' | | false | 219 | | titleStyle | The style of the title | object | | | false | 220 | | days | Maximum number of days for the chart to display (starting from today) | number | | 365 | true if miss **fromDate** and **toDate** | 221 | | fromDate | The start date of the chart | date, format yyyy-MM-dd | | 2023-01-01 | true if miss **days** | 222 | | toDate | The end date of the chart | date, format yyyy-MM-dd | | 2023-12-31 | true if miss **days** | 223 | | query | Dataview query syntax, contribution graph will use it to count files | string | | | true | 224 | | dateField | Date attributes of files used for data distribution | string | file.ctime | createTime | false | 225 | | startOfWeek | Start of week | number | 0 | | false | 226 | | showCellRuleIndicators | Control the display and hiding of cell rule indicator elements | boolean | true | | false | 227 | | cellStyleRules | Cell style rule | array | | | false | 228 | 229 | 230 | ## Integrate with Dataview 231 | 232 | If you are familiar with javascript, you can use the contribution Graph API through dataviewJS to access more advanced features. 233 | 234 | contribution Graph Exposed a global function named `renderContributionGraph`, It is defined as follows. 235 | 236 | 237 | ```js 238 | function renderContributionGraph(container: HTMLElement, config: ContributionGraphConfig): void 239 | ``` 240 | 241 | If you want to see more api's, use cases, see [README_ADVANCED.md](README_ADVANCED.md). -------------------------------------------------------------------------------- /attachment/74103317de5336b5283338c56171f268.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/74103317de5336b5283338c56171f268.png -------------------------------------------------------------------------------- /attachment/75cbb797dc58593b204e3e1b47d7146e.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/75cbb797dc58593b204e3e1b47d7146e.png -------------------------------------------------------------------------------- /attachment/advance-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-1.png -------------------------------------------------------------------------------- /attachment/advance-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-10.png -------------------------------------------------------------------------------- /attachment/advance-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-11.png -------------------------------------------------------------------------------- /attachment/advance-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-2.png -------------------------------------------------------------------------------- /attachment/advance-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-3.png -------------------------------------------------------------------------------- /attachment/advance-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-4.png -------------------------------------------------------------------------------- /attachment/advance-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-5.png -------------------------------------------------------------------------------- /attachment/advance-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-6.png -------------------------------------------------------------------------------- /attachment/advance-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-7.png -------------------------------------------------------------------------------- /attachment/advance-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-8.png -------------------------------------------------------------------------------- /attachment/advance-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/advance-9.png -------------------------------------------------------------------------------- /attachment/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/calendar.png -------------------------------------------------------------------------------- /attachment/contribution-graph-create.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/contribution-graph-create.gif -------------------------------------------------------------------------------- /attachment/contribution-graph-edit.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/contribution-graph-edit.gif -------------------------------------------------------------------------------- /attachment/customized-date-field.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/customized-date-field.png -------------------------------------------------------------------------------- /attachment/d20ba90e31c16a3c4d79cba9298577de.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/d20ba90e31c16a3c4d79cba9298577de.png -------------------------------------------------------------------------------- /attachment/fixed-date-range.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/fixed-date-range.png -------------------------------------------------------------------------------- /attachment/hide-rule-indicators.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/hide-rule-indicators.png -------------------------------------------------------------------------------- /attachment/image-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/image-1.png -------------------------------------------------------------------------------- /attachment/image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/image-2.png -------------------------------------------------------------------------------- /attachment/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/image.png -------------------------------------------------------------------------------- /attachment/month-track.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/month-track.png -------------------------------------------------------------------------------- /attachment/personized-cell-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/personized-cell-color.png -------------------------------------------------------------------------------- /attachment/personizezd-cell-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/personizezd-cell-text.png -------------------------------------------------------------------------------- /attachment/quick-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/quick-start.png -------------------------------------------------------------------------------- /attachment/simple-usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/simple-usage.png -------------------------------------------------------------------------------- /attachment/start-of-week.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/start-of-week.png -------------------------------------------------------------------------------- /attachment/title-style.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vran-dev/obsidian-contribution-graph/8a4a85cc369849be8c29052e2d977ef7d4679960/attachment/title-style.png -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import fs from "fs"; 5 | import path from "path"; 6 | 7 | const banner = `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | `; 12 | 13 | const prod = process.argv[2] === "production"; 14 | 15 | const renamePlugin = () => ({ 16 | name: "rename-plugin", 17 | setup(build) { 18 | build.onEnd(async (result) => { 19 | const file = build.initialOptions.outfile; 20 | const parent = path.dirname(file); 21 | const cssFileName = parent + "/main.css"; 22 | const newCssFileName = parent + "/styles.css"; 23 | try { 24 | fs.renameSync(cssFileName, newCssFileName); 25 | } catch (e) { 26 | console.error("Failed to rename file:", e); 27 | } 28 | }); 29 | }, 30 | }); 31 | 32 | const context = await esbuild.context({ 33 | banner: { 34 | js: banner, 35 | }, 36 | entryPoints: ["src/main.ts"], 37 | bundle: true, 38 | plugins: [renamePlugin()], 39 | external: [ 40 | "obsidian", 41 | "electron", 42 | "@codemirror/autocomplete", 43 | "@codemirror/collab", 44 | "@codemirror/commands", 45 | "@codemirror/language", 46 | "@codemirror/lint", 47 | "@codemirror/search", 48 | "@codemirror/state", 49 | "@codemirror/view", 50 | "@lezer/common", 51 | "@lezer/highlight", 52 | "@lezer/lr", 53 | ...builtins, 54 | ], 55 | format: "cjs", 56 | target: "es2018", 57 | logLevel: "info", 58 | sourcemap: prod ? false : "inline", 59 | treeShaking: true, 60 | outfile: "main.js", 61 | minify: prod, 62 | }); 63 | 64 | if (prod) { 65 | await context.rebuild(); 66 | process.exit(0); 67 | } else { 68 | await context.watch(); 69 | } 70 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "contribution-graph", 3 | "name": "Contribution Graph", 4 | "version": "0.10.0", 5 | "minAppVersion": "1.3.0", 6 | "description": "Generate a interactive heatmap graph to visualize and track your productivity", 7 | "author": "vran", 8 | "authorUrl": "https://github.com/vran-dev", 9 | "isDesktopOnly": false, 10 | "fundingUrl": "https://www.buymeacoffee.com/vran" 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-contribution-graph", 3 | "version": "0.1.0", 4 | "description": "generate gitxxx style contribution graph for obsidian, use it to track your goals, habits, or anything else you want to track.", 5 | "main": "main.ts", 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/luxon": "^3.3.7", 16 | "@types/node": "^16.11.6", 17 | "@types/react": "^18.2.45", 18 | "@types/react-dom": "^18.2.18", 19 | "@typescript-eslint/eslint-plugin": "5.29.0", 20 | "@typescript-eslint/parser": "5.29.0", 21 | "builtin-modules": "3.3.0", 22 | "esbuild": "0.17.3", 23 | "obsidian": "latest", 24 | "obsidian-dataview": "^0.5.64", 25 | "tslib": "2.4.0", 26 | "typescript": "4.7.4" 27 | }, 28 | "dependencies": { 29 | "@floating-ui/react": "^0.26.4", 30 | "@uiw/react-color": "^2.0.6", 31 | "luxon": "^3.4.4", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { CellStyleRule } from "./types"; 2 | 3 | export const DEFAULT_RULES: CellStyleRule[] = [ 4 | { 5 | id: "default_b", 6 | color: "#9be9a8", 7 | min: 1, 8 | max: 2, 9 | }, 10 | { 11 | id: "default_c", 12 | color: "#40c463", 13 | min: 2, 14 | max: 5, 15 | }, 16 | { 17 | id: "default_d", 18 | color: "#30a14e", 19 | min: 5, 20 | max: 10, 21 | }, 22 | { 23 | id: "default_e", 24 | color: "#216e39", 25 | min: 10, 26 | max: 999, 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /src/i18/en.ts: -------------------------------------------------------------------------------- 1 | import { Local } from "./types"; 2 | 3 | export class En implements Local { 4 | default = "default"; 5 | click_to_reset = "click to reset"; 6 | 7 | /** 8 | * context menu 9 | */ 10 | context_menu_create = "Add Heatmap"; 11 | 12 | /** 13 | * form 14 | */ 15 | form_basic_settings = "Basic Settings"; 16 | form_style_settings = "Style Settings"; 17 | form_about = "About"; 18 | form_contact_me = "Contact me"; 19 | form_project_url = "Project"; 20 | form_sponsor = "Sponsor"; 21 | form_title = "Title"; 22 | form_title_placeholder = "Input title"; 23 | form_graph_type = "Graph Type"; 24 | form_graph_type_git = "Git Style"; 25 | form_graph_type_month_track = "Month Track"; 26 | form_graph_type_calendar = "Calendar"; 27 | form_date_range = "Date Range"; 28 | form_date_range_latest_days = "Latest Days"; 29 | form_date_range_latest_month = "Latest Whole Month"; 30 | form_date_range_latest_year = "Latest Whole Year"; 31 | form_date_range_input_placeholder = "Input number here"; 32 | form_date_range_fixed_date = "Fixed Date"; 33 | form_date_range_start_date = "Start Date"; 34 | 35 | form_start_of_week = "Start of Week"; 36 | form_data_source_value = "Source"; 37 | form_data_source_filter_label = "Filter"; 38 | 39 | form_datasource_filter_type_none = "None"; 40 | form_datasource_filter_type_status_is = "Status Is"; 41 | form_datasource_filter_type_contains_any_tag = "Contains Any Tag"; 42 | form_datasource_filter_type_status_in = "Status In"; 43 | 44 | form_datasource_filter_task_none = "None"; 45 | form_datasource_filter_task_status_completed = "Completed"; 46 | form_datasource_filter_task_status_fully_completed = "Fully completed"; 47 | form_datasource_filter_task_status_any = "Any Status"; 48 | form_datasource_filter_task_status_incomplete = "Incomplete"; 49 | form_datasource_filter_task_status_canceled = "Canceled"; 50 | form_datasource_filter_contains_tag = "Contains Any Tag"; 51 | form_datasource_filter_contains_tag_input_placeholder = "Please input tag, such as #todo"; 52 | form_datasource_filter_customize = "Customize"; 53 | 54 | form_query_placeholder = ' such as #tag or "folder"'; 55 | 56 | form_date_field = "Date Field"; 57 | form_date_field_type_file_name = "File Name"; 58 | form_date_field_type_file_ctime = "File Create Time"; 59 | form_date_field_type_file_mtime = "File Modify Time"; 60 | form_date_field_type_file_specific_page_property = "Specific Page Property"; 61 | form_date_field_type_file_specific_task_property = "Specific Task Property"; 62 | 63 | form_date_field_placeholder = "default is file's create time"; 64 | 65 | form_date_field_format = "Date Field Format"; 66 | form_date_field_format_sample = "Sample"; 67 | form_date_field_format_description = 68 | "If your date property value is not a standard format, you need to specify this field so that the system knows how to recognize your date format"; 69 | form_date_field_format_placeholder = "such as yyyy-MM-dd HH:mm:ss"; 70 | 71 | form_date_field_format_type_smart = "Auto Detect"; 72 | 73 | form_date_field_format_type_manual = "Specify Format"; 74 | 75 | form_count_field_count_field_label = "Count Field"; 76 | 77 | form_count_field_count_field_input_placeholder = "Please input property name"; 78 | 79 | form_count_field_count_field_type_default = "Default"; 80 | 81 | form_count_field_count_field_type_page_prop = "Page Property"; 82 | 83 | form_count_field_count_field_type_task_prop = "Task Property"; 84 | form_title_font_size_label = "Title font Size"; 85 | form_number_input_min_warning = "allow min value is {value}"; 86 | form_number_input_max_warning = "allow max value is {value}"; 87 | form_fill_the_screen_label = "Fill The Screen"; 88 | form_main_container_bg_color = "Background Color"; 89 | form_enable_main_container_shadow = "Enable Shadow"; 90 | form_show_cell_indicators = "Show Cell Indicators"; 91 | form_cell_shape = "Cell Shape"; 92 | form_cell_shape_circle = "Circle"; 93 | form_cell_shape_square = "Square"; 94 | form_cell_shape_rounded = "Rounded"; 95 | form_cell_min_height = "Min Height"; 96 | form_cell_min_width = "Min Width"; 97 | 98 | form_datasource_type_page = "Page"; 99 | form_datasource_type_all_task = "All Task"; 100 | form_datasource_type_task_in_specific_page = "Task in Specific Page"; 101 | 102 | form_theme = "Theme"; 103 | form_theme_placeholder = "Select theme or customize style"; 104 | form_cell_style_rules = "Cell Style Rules"; 105 | 106 | form_button_preview = "Preview"; 107 | form_button_save = "Save"; 108 | 109 | /** 110 | * weekday 111 | */ 112 | weekday_sunday = "Sunday"; 113 | weekday_monday = "Monday"; 114 | weekday_tuesday = "Tuesday"; 115 | weekday_wednesday = "Wednesday"; 116 | weekday_thursday = "Thursday"; 117 | weekday_friday = "Friday"; 118 | weekday_saturday = "Saturday"; 119 | 120 | /** 121 | * graph text 122 | */ 123 | you_have_no_contributions_on = "No contributions on {date}"; 124 | you_have_contributed_to = "{value} contributions on {date}"; 125 | click_to_load_more = "Click to load more..."; 126 | } -------------------------------------------------------------------------------- /src/i18/messages.ts: -------------------------------------------------------------------------------- 1 | import { En } from "./en"; 2 | import { Local } from "./types"; 3 | import { Zh } from "./zh"; 4 | 5 | export class Locals { 6 | 7 | static get(): Local { 8 | const lang = window.localStorage.getItem("language"); 9 | if (lang === "zh") { 10 | return new Zh(); 11 | } 12 | return new En(); 13 | } 14 | } 15 | 16 | export function isZh(): boolean { 17 | const lang = window.localStorage.getItem("language"); 18 | return lang === "zh"; 19 | } 20 | 21 | export const weekDayMapping = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 22 | 23 | export const cnWeekDayMapping = ["日", "一", "二", "三", "四", "五", "六"]; 24 | 25 | export const monthMapping = [ 26 | "Jan", 27 | "Feb", 28 | "Mar", 29 | "Apr", 30 | "May", 31 | "Jun", 32 | "Jul", 33 | "Aug", 34 | "Sep", 35 | "Oct", 36 | "Nov", 37 | "Dec", 38 | ]; 39 | 40 | export function localizedMonthMapping(month: number) { 41 | const lang = window.localStorage.getItem("language"); 42 | if (lang === "zh") { 43 | return `${month + 1}月`; 44 | } 45 | return monthMapping[month]; 46 | } 47 | 48 | export function localizedWeekDayMapping(weekday: number, maxLength?: number) { 49 | const lang = window.localStorage.getItem("language"); 50 | let localizedWeekday; 51 | if (lang === "zh") { 52 | localizedWeekday = cnWeekDayMapping[weekday]; 53 | } else { 54 | localizedWeekday = weekDayMapping[weekday]; 55 | } 56 | 57 | if (maxLength) { 58 | return localizedWeekday.substring(0, maxLength); 59 | } else { 60 | return localizedWeekday; 61 | } 62 | } 63 | 64 | export function localizedYearMonthMapping(year: number, month: number) { 65 | const lang = window.localStorage.getItem("language"); 66 | if (lang === "zh") { 67 | return `${year}年${month + 1}月`; 68 | } 69 | return `${monthMapping[month]} ${year}`; 70 | } 71 | -------------------------------------------------------------------------------- /src/i18/types.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface Local { 3 | 4 | default: string; 5 | click_to_reset: string; 6 | /** 7 | * context menu 8 | */ 9 | context_menu_create: string; 10 | 11 | /** 12 | * form 13 | */ 14 | form_basic_settings: string; 15 | form_style_settings: string; 16 | form_about: string; 17 | form_contact_me: string; 18 | form_project_url: string; 19 | form_sponsor: string; 20 | form_title: string; 21 | form_title_placeholder: string; 22 | form_graph_type: string; 23 | form_graph_type_git: string; 24 | form_graph_type_month_track: string; 25 | form_graph_type_calendar: string; 26 | form_date_range: string; 27 | form_date_range_latest_days: string; 28 | form_date_range_latest_month: string; 29 | form_date_range_latest_year: string; 30 | form_date_range_input_placeholder: string; 31 | form_date_range_fixed_date: string; 32 | form_date_range_start_date: string; 33 | 34 | form_start_of_week: string; 35 | form_data_source_value: string; 36 | form_data_source_filter_label: string; 37 | 38 | form_datasource_filter_type_none: string; 39 | form_datasource_filter_type_status_is: string; 40 | form_datasource_filter_type_contains_any_tag: string; 41 | form_datasource_filter_type_status_in: string; 42 | 43 | form_datasource_filter_task_none: string; 44 | form_datasource_filter_task_status_completed: string; 45 | form_datasource_filter_task_status_fully_completed: string; 46 | form_datasource_filter_task_status_any: string; 47 | form_datasource_filter_task_status_incomplete: string; 48 | form_datasource_filter_task_status_canceled: string; 49 | form_datasource_filter_contains_tag: string; 50 | form_datasource_filter_contains_tag_input_placeholder: string; 51 | 52 | form_datasource_filter_customize: string; 53 | 54 | form_query_placeholder: string; 55 | 56 | form_date_field: string; 57 | form_date_field_type_file_name: string; 58 | form_date_field_type_file_ctime: string; 59 | form_date_field_type_file_mtime: string; 60 | form_date_field_type_file_specific_page_property: string; 61 | form_date_field_type_file_specific_task_property: string; 62 | 63 | form_date_field_placeholder: string; 64 | form_date_field_format: string; 65 | form_date_field_format_sample: string; 66 | form_date_field_format_description: string; 67 | form_date_field_format_placeholder: string; 68 | 69 | form_date_field_format_type_smart: string; 70 | 71 | form_date_field_format_type_manual: string; 72 | form_count_field_count_field_label: string; 73 | 74 | form_count_field_count_field_input_placeholder: string; 75 | 76 | form_count_field_count_field_type_default: string; 77 | 78 | form_count_field_count_field_type_page_prop: string; 79 | 80 | form_count_field_count_field_type_task_prop: string; 81 | form_title_font_size_label: string; 82 | form_number_input_min_warning: string; 83 | form_number_input_max_warning: string; 84 | form_fill_the_screen_label: string; 85 | form_main_container_bg_color: string; 86 | form_enable_main_container_shadow: string; 87 | form_show_cell_indicators: string; 88 | form_cell_shape: string; 89 | form_cell_shape_circle: string; 90 | form_cell_shape_square: string; 91 | form_cell_shape_rounded: string; 92 | form_cell_min_height: string; 93 | form_cell_min_width: string; 94 | 95 | form_datasource_type_page: string; 96 | form_datasource_type_all_task: string; 97 | form_datasource_type_task_in_specific_page: string; 98 | 99 | form_theme: string; 100 | form_theme_placeholder: string; 101 | form_cell_style_rules: string; 102 | 103 | form_button_preview: string; 104 | form_button_save: string; 105 | 106 | /** 107 | * weekday 108 | */ 109 | weekday_sunday: string; 110 | weekday_monday: string; 111 | weekday_tuesday: string; 112 | weekday_wednesday: string; 113 | weekday_thursday: string; 114 | weekday_friday: string; 115 | weekday_saturday: string; 116 | 117 | /** 118 | * graph text 119 | */ 120 | you_have_no_contributions_on: string; 121 | you_have_contributed_to: string; 122 | click_to_load_more: string; 123 | } 124 | -------------------------------------------------------------------------------- /src/i18/zh.ts: -------------------------------------------------------------------------------- 1 | import { Local } from "./types"; 2 | 3 | export class Zh implements Local { 4 | default = "默认"; 5 | click_to_reset = "点击重置"; 6 | /** 7 | * context menu 8 | */ 9 | context_menu_create = "新建热力图"; 10 | 11 | /** 12 | * form 13 | */ 14 | form_basic_settings = "基础设置"; 15 | form_style_settings = "样式设置"; 16 | form_about = "关于"; 17 | form_contact_me = "联系我"; 18 | form_project_url = "项目地址"; 19 | form_sponsor = "赞助"; 20 | form_title = "标题"; 21 | form_title_placeholder = "输入标题"; 22 | form_graph_type = "图表类型"; 23 | form_graph_type_git = "Git 视图"; 24 | form_graph_type_month_track = "月追踪视图"; 25 | form_graph_type_calendar = "日历视图"; 26 | form_date_range = "日期范围"; 27 | form_date_range_latest_days = "最近几天"; 28 | form_date_range_latest_month = "最近几个整月"; 29 | form_date_range_latest_year = "最近几个整年"; 30 | form_date_range_input_placeholder = "在这里输入数值"; 31 | form_date_range_fixed_date = "固定日期"; 32 | form_date_range_start_date = "开始日期"; 33 | 34 | form_start_of_week = "每周开始于"; 35 | form_data_source_value = "来源"; 36 | form_data_source_filter_label = "筛选"; 37 | 38 | form_datasource_filter_type_none = "无"; 39 | form_datasource_filter_type_status_is = "状态等于"; 40 | form_datasource_filter_type_contains_any_tag = "包含任意标签"; 41 | form_datasource_filter_type_status_in = "包含任意一个状态"; 42 | 43 | form_datasource_filter_task_none = "无"; 44 | form_datasource_filter_task_status_completed = "已完成(不包含子任务)"; 45 | form_datasource_filter_task_status_fully_completed = "已完成(包含子任务)"; 46 | form_datasource_filter_task_status_canceled = "已取消"; 47 | form_datasource_filter_task_status_any = "任意状态"; 48 | form_datasource_filter_task_status_incomplete = "未完成"; 49 | form_datasource_filter_contains_tag = "包含任意一个标签"; 50 | form_datasource_filter_contains_tag_input_placeholder = "请输入标签,比如 #todo"; 51 | form_datasource_filter_customize = "自定义"; 52 | 53 | form_query_placeholder = '比如 #tag 或 "folder"'; 54 | 55 | form_date_field = "日期字段"; 56 | form_date_field_type_file_name = "文件名称"; 57 | form_date_field_type_file_ctime = "文件创建日期"; 58 | form_date_field_type_file_mtime = "文件修改日期"; 59 | form_date_field_type_file_specific_page_property = "指定文档属性"; 60 | form_date_field_type_file_specific_task_property = "指定任务属性"; 61 | 62 | form_date_field_placeholder = "默认为文件的创建日期"; 63 | 64 | form_date_field_format = "日期格式"; 65 | form_date_field_format_sample = "示例值"; 66 | form_date_field_format_description = "如果你的日期属性值不是标准的格式,需要指定该字段让系统知道如何识别你的日期格式"; 67 | form_date_field_format_placeholder = "比如 yyyy-MM-dd HH:mm:ss"; 68 | 69 | form_date_field_format_type_smart = "自动识别"; 70 | 71 | form_date_field_format_type_manual = "指定格式"; 72 | 73 | form_count_field_count_field_label = "打分属性"; 74 | 75 | form_count_field_count_field_input_placeholder = "请输入属性名称"; 76 | 77 | form_count_field_count_field_type_default = "默认"; 78 | 79 | form_count_field_count_field_type_page_prop = "文档属性"; 80 | 81 | form_count_field_count_field_type_task_prop = "任务属性"; 82 | form_title_font_size_label = "标题字体大小"; 83 | form_number_input_min_warning = "允许的最小值为 {value}"; 84 | form_number_input_max_warning = "允许的最大值为 {value}"; 85 | form_fill_the_screen_label = "充满屏幕"; 86 | form_main_container_bg_color = "背景颜色"; 87 | form_enable_main_container_shadow = "启用阴影"; 88 | form_show_cell_indicators = "显示单元格指示器"; 89 | form_cell_shape = "单元格形状"; 90 | form_cell_shape_circle = "圆形"; 91 | form_cell_shape_square = "方块"; 92 | form_cell_shape_rounded = "圆角"; 93 | form_cell_min_height = "单元格最小高度"; 94 | form_cell_min_width = "单元格最小宽度"; 95 | 96 | form_datasource_type_page = "文档"; 97 | form_datasource_type_all_task = "所有任务"; 98 | form_datasource_type_task_in_specific_page = "指定文档中的任务"; 99 | 100 | form_theme = "主题"; 101 | form_theme_placeholder = "选择主题或自定义样式"; 102 | form_cell_style_rules = "单元格样式规则"; 103 | 104 | form_button_preview = "预览"; 105 | form_button_save = "保存"; 106 | 107 | /** 108 | * weekday 109 | */ 110 | weekday_sunday = "周日"; 111 | weekday_monday = "周一"; 112 | weekday_tuesday = "周二"; 113 | weekday_wednesday = "周三"; 114 | weekday_thursday = "周四"; 115 | weekday_friday = "周五"; 116 | weekday_saturday = "周六"; 117 | 118 | /** 119 | * graph text 120 | */ 121 | you_have_no_contributions_on = "你在 {date} 没有任何贡献"; 122 | you_have_contributed_to = "你在 {date} 有 {value} 次贡献"; 123 | click_to_load_more = "点击加载更多......"; 124 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Editor, MarkdownFileInfo, MarkdownView, Plugin } from "obsidian"; 2 | import { ContributionGraphConfig } from "./types"; 3 | import { Renders } from "./render/renders"; 4 | import { CodeBlockProcessor } from "./processor/codeBlockProcessor"; 5 | import { ContributionGraphCreateModal } from "./view/form/GraphFormModal"; 6 | import { mountEditButtonToCodeblock } from "./view/codeblock/CodeblockEditButtonMount"; 7 | import { Locals } from "./i18/messages"; 8 | import "../style/styles.css"; 9 | 10 | export default class ContributionGraph extends Plugin { 11 | async onload() { 12 | this.registerGlobalRenderApi(); 13 | this.registerCodeblockProcessor(); 14 | this.registerContributionGraphCreateCommand(); 15 | this.registerContextMenu(); 16 | } 17 | 18 | onunload() { 19 | // @ts-ignore 20 | window.renderContributionGraph = undefined; 21 | } 22 | 23 | registerContextMenu() { 24 | this.registerEvent( 25 | this.app.workspace.on("editor-menu", (menu, editor, info) => { 26 | menu.addItem((item) => { 27 | item.setTitle(Locals.get().context_menu_create); 28 | item.setIcon("gantt-chart"); 29 | item.onClick(() => { 30 | new ContributionGraphCreateModal(this.app).open(); 31 | }); 32 | }); 33 | }) 34 | ); 35 | } 36 | 37 | registerGlobalRenderApi() { 38 | //@ts-ignore 39 | window.renderContributionGraph = ( 40 | container: HTMLElement, 41 | graphConfig: ContributionGraphConfig 42 | ): void => { 43 | Renders.render(container, graphConfig); 44 | }; 45 | } 46 | 47 | registerCodeblockProcessor() { 48 | this.registerMarkdownCodeBlockProcessor( 49 | "contributionGraph", 50 | (code, el, ctx) => { 51 | const processor = new CodeBlockProcessor(); 52 | processor.renderFromCodeBlock(code, el, ctx, this.app); 53 | if (el.parentElement) { 54 | mountEditButtonToCodeblock( 55 | this.app, 56 | code, 57 | el.parentElement 58 | ); 59 | } 60 | } 61 | ); 62 | } 63 | 64 | registerContributionGraphCreateCommand() { 65 | this.addCommand({ 66 | id: "create-graph", 67 | name: Locals.get().context_menu_create, 68 | editorCallback: ( 69 | editor: Editor, 70 | ctx: MarkdownView | MarkdownFileInfo 71 | ) => { 72 | new ContributionGraphCreateModal(this.app).open(); 73 | }, 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/processor/bizErrors.ts: -------------------------------------------------------------------------------- 1 | function errorTipsHtmlTemplate(title: string, recommends: string[]) { 2 | return { 3 | summary: title, 4 | recommends: recommends, 5 | }; 6 | } 7 | 8 | export const MISS_CONFIG = (invalidValue?: string | number | boolean) => 9 | errorTipsHtmlTemplate("Empty Graph, please add config, for example", [ 10 | `title: 'Contributions' 11 | days: 365 12 | dataSource: '#tag' # means all notes with tag 'tag' 13 | type: "page" # or "task" 14 | value: '""' # means all notes in folder`, 15 | ]); 16 | 17 | export const MISS_DATASOURCE_OR_DATA = (invalidValue?: string | number | boolean) => 18 | errorTipsHtmlTemplate( 19 | "please set dataSource or data property, for example", 20 | [ 21 | `dataSource: '#tag' # means all notes with tag 'tag' 22 | type: "page" # or "task" 23 | value: '""' # means all notes 24 | days: 365`, 25 | 26 | `dataSource: '#tag and "folder"' # means all notes with tag 'tag' and in folder 'folder', folder should surrounded by quotes 27 | type: "page" # or "task" 28 | value: '""' # means all notes 29 | fromDate: '2023-01-01' 30 | toDate: '2023-12-31' `, 31 | ] 32 | ); 33 | 34 | export const INVALID_GRAPH_TYPE = (invalidValue?: string | number | boolean) => 35 | errorTipsHtmlTemplate( 36 | `graphType "${invalidValue}" is invalid, value must be one of [default, month-track, calendar], for example`, 37 | [ 38 | `graphType: 'default' 39 | days: 365 40 | dataSource: '#tag' # means all notes with tag 'tag' 41 | type: "page" # or "task" 42 | value: '""' # means all notes in folde `, 43 | ] 44 | ); 45 | 46 | export const MISS_DAYS_OR_RANGE_DATE = ( 47 | invalidValue?: string | number | boolean 48 | ) => 49 | errorTipsHtmlTemplate( 50 | "please set dateRangeValue or fromDate and toDate property, for example", 51 | [ 52 | `dateRangeType: LATEST_DAYS 53 | dateRangeValue: 365 54 | dataSource: '#tag' # means all notes with tag 'tag' 55 | type: "page" # or "task" 56 | value: '""' # means all notes in folde `, 57 | 58 | `dateRangeType: FIXED_DATE_RANGE 59 | fromDate: '2023-01-01' 60 | toDate: '2023-12-31' 61 | dataSource: '#tag' # means all notes with tag 'tag' 62 | type: "page" # or "task" 63 | value: '""' # means all notes in folde`, 64 | ] 65 | ); 66 | 67 | export const INVALID_DATE_FORMAT = (invalidValue?: string | number | boolean) => 68 | errorTipsHtmlTemplate( 69 | `"${invalidValue}" is invalid, fromDate and toDate must be yyyy-MM-dd, for example`, 70 | [ 71 | `fromDate: '2023-01-01' 72 | toDate: '2023-12-31' 73 | data: []`, 74 | ] 75 | ); 76 | 77 | export const INVALID_START_OF_WEEK = ( 78 | invalidValue?: string | number | boolean 79 | ) => 80 | errorTipsHtmlTemplate( 81 | `startOfWeek value ${invalidValue} is invalid, should be 0~6, 0=Sunday, 1=Monday, 2=Thursday and etc. for example`, 82 | [ 83 | `fromDate: '2023-01-01' 84 | toDate: '2023-12-31' 85 | data: [] 86 | startOfWeek: 1`, 87 | ] 88 | ); 89 | -------------------------------------------------------------------------------- /src/processor/codeBlockProcessor.ts: -------------------------------------------------------------------------------- 1 | import { App, MarkdownPostProcessorContext, parseYaml } from "obsidian"; 2 | import { Renders } from "src/render/renders"; 3 | 4 | import { MISS_CONFIG } from "./bizErrors"; 5 | import { GraphProcessError } from "./graphProcessError"; 6 | import { CompositeDataSourceQuery } from "src/query/compositeDataSourceQuery"; 7 | import { YamlGraphConfig } from "./types"; 8 | import { YamlConfigReconciler } from "./yamlConfigReconciler"; 9 | import { getAPI } from "obsidian-dataview"; 10 | 11 | export class CodeBlockProcessor { 12 | dataSourceQuery: CompositeDataSourceQuery = new CompositeDataSourceQuery(); 13 | 14 | async renderFromCodeBlock( 15 | code: string, 16 | el: HTMLElement, 17 | ctx: MarkdownPostProcessorContext, 18 | app: App 19 | ) { 20 | try { 21 | const graphConfig: YamlGraphConfig = this.loadYamlConfig(el, code); 22 | await this.renderFromYaml(graphConfig, el, app); 23 | } catch (e) { 24 | if (e instanceof GraphProcessError) { 25 | Renders.renderErrorTips(el, e.summary, e.recommends); 26 | } else { 27 | console.error(e); 28 | const notice = "unexpected error: " + e.message; 29 | Renders.renderErrorTips(el, notice); 30 | } 31 | } 32 | } 33 | 34 | async renderFromYaml(graphConfig: YamlGraphConfig, el: HTMLElement, app: App) { 35 | const renderCallback = () => { 36 | try { 37 | // validate 38 | YamlGraphConfig.validate(graphConfig); 39 | const data = this.dataSourceQuery.query( 40 | graphConfig.dataSource, 41 | app 42 | ); 43 | 44 | const aggregatedData = []; 45 | if (graphConfig.data) { 46 | aggregatedData.push(...graphConfig.data); 47 | } 48 | aggregatedData.push(...data); 49 | graphConfig.data = aggregatedData; 50 | 51 | // render 52 | Renders.render( 53 | el, 54 | YamlGraphConfig.toContributionGraphConfig(graphConfig) 55 | ); 56 | } catch (e) { 57 | if (e instanceof GraphProcessError) { 58 | Renders.renderErrorTips(el, e.summary, e.recommends); 59 | } else { 60 | console.error(e); 61 | const notice = "unexpected error: " + e.message; 62 | Renders.renderErrorTips(el, notice); 63 | } 64 | } 65 | } 66 | 67 | const dv = getAPI(app); 68 | if (!dv) { 69 | throw new GraphProcessError({ 70 | summary: "Initialize Dataview failed", 71 | recommends: ["Please install Dataview plugin"], 72 | }); 73 | } 74 | if (dv.index.initialized) { 75 | renderCallback(); 76 | } else { 77 | // @ts-ignore 78 | app.metadataCache.on("dataview:index-ready", () => { 79 | renderCallback(); 80 | }) 81 | } 82 | } 83 | 84 | loadYamlConfig(el: HTMLElement, code: string): YamlGraphConfig { 85 | if (code == null || code.trim() == "") { 86 | throw new GraphProcessError(MISS_CONFIG()); 87 | } 88 | 89 | try { 90 | // @ts-ignore 91 | const yamlConfig: YamlGraphConfig = parseYaml(code); 92 | return YamlConfigReconciler.reconcile(yamlConfig); 93 | } catch (e) { 94 | if (e.mark?.line) { 95 | throw new GraphProcessError({ 96 | summary: 97 | "yaml parse error at line " + 98 | (e.mark.line + 1) + 99 | ", please check the format", 100 | }); 101 | } else { 102 | throw new GraphProcessError({ 103 | summary: 104 | "content parse error, please check the format(such as blank, indent)", 105 | }); 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/processor/graphProcessError.ts: -------------------------------------------------------------------------------- 1 | export class GraphProcessError { 2 | summary: string; 3 | recommends?: string[]; 4 | 5 | constructor({ summary, recommends }: { summary: string, recommends?: string[] }) { 6 | this.summary = summary; 7 | this.recommends = recommends || []; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/processor/types.ts: -------------------------------------------------------------------------------- 1 | import { isZh } from "src/i18/messages"; 2 | import { DataSource } from "src/query/types"; 3 | import { 4 | Contribution, 5 | CellStyleRule, 6 | ContributionGraphConfig, 7 | } from "src/types"; 8 | import { 9 | MISS_CONFIG, 10 | MISS_DATASOURCE_OR_DATA, 11 | INVALID_GRAPH_TYPE, 12 | MISS_DAYS_OR_RANGE_DATE, 13 | INVALID_DATE_FORMAT, 14 | INVALID_START_OF_WEEK, 15 | } from "./bizErrors"; 16 | import { GraphProcessError } from "./graphProcessError"; 17 | import { 18 | getLatestMonthAbsoluteFromAndEnd, 19 | getLatestYearAbsoluteFromAndEnd, 20 | toFormattedDate, 21 | } from "src/util/dateUtils"; 22 | 23 | export class YamlGraphConfig { 24 | /** 25 | * basic settings 26 | */ 27 | title?: string; 28 | graphType: string; 29 | dataSource: DataSource; 30 | dateRangeValue?: number; 31 | dateRangeType?: DateRangeType; 32 | fromDate?: string; 33 | toDate?: string; 34 | data: Contribution[]; 35 | 36 | /** 37 | * style settings 38 | */ 39 | titleStyle: Partial; 40 | fillTheScreen: boolean; 41 | startOfWeek: number; 42 | enableMainContainerShadow?: boolean; 43 | showCellRuleIndicators: boolean; 44 | mainContainerStyle?: Partial; 45 | cellStyle?: Partial; 46 | cellStyleRules?: CellStyleRule[]; 47 | 48 | // deprecated 49 | days?: number; 50 | query?: string; 51 | dateField?: string; 52 | dateFieldFormat?: string; 53 | 54 | constructor() { 55 | this.title = "Contributions"; 56 | this.graphType = "default"; 57 | this.dateRangeValue = 180; 58 | this.dateRangeType = "LATEST_DAYS"; 59 | this.startOfWeek = isZh() ? 1 : 0; 60 | this.showCellRuleIndicators = true; 61 | this.titleStyle = { 62 | textAlign: "left", 63 | fontSize: "1.5em", 64 | fontWeight: "normal", 65 | }; 66 | this.dataSource = { 67 | type: "PAGE", 68 | value: "", 69 | dateField: {}, 70 | } as DataSource; 71 | this.fillTheScreen = false; 72 | this.enableMainContainerShadow = false; 73 | 74 | // deprecated 75 | this.query = undefined; 76 | this.dateFieldFormat = undefined; 77 | this.dateField = undefined; 78 | this.days = undefined; 79 | } 80 | 81 | static toContributionGraphConfig( 82 | config: YamlGraphConfig 83 | ): ContributionGraphConfig { 84 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 85 | const { query, dateField, ...rest } = config; 86 | 87 | if (config.dateRangeType != "FIXED_DATE_RANGE") { 88 | if (config.dateRangeType == "LATEST_DAYS") { 89 | return { 90 | days: config.dateRangeValue, 91 | ...rest, 92 | } as ContributionGraphConfig; 93 | } 94 | 95 | if (config.dateRangeType == "LATEST_MONTH") { 96 | const { start, end } = getLatestMonthAbsoluteFromAndEnd( 97 | config.dateRangeValue || 0 98 | ); 99 | return { 100 | ...rest, 101 | days: undefined, 102 | fromDate: toFormattedDate(start), 103 | toDate: toFormattedDate(end), 104 | } as ContributionGraphConfig; 105 | } 106 | 107 | if (config.dateRangeType == "LATEST_YEAR") { 108 | const { start, end } = getLatestYearAbsoluteFromAndEnd( 109 | config.dateRangeValue || 0 110 | ); 111 | return { 112 | ...rest, 113 | days: undefined, 114 | fromDate: toFormattedDate(start), 115 | toDate: toFormattedDate(end), 116 | } as ContributionGraphConfig; 117 | } 118 | } 119 | return rest as ContributionGraphConfig; 120 | } 121 | 122 | static validate(config: YamlGraphConfig): void { 123 | if (!config) { 124 | throw new GraphProcessError(MISS_CONFIG()); 125 | } 126 | if (!config.dataSource && !config.data) { 127 | throw new GraphProcessError(MISS_DATASOURCE_OR_DATA()); 128 | } 129 | 130 | if (config.graphType) { 131 | const graphTypes = ["default", "month-track", "calendar"]; 132 | if (!graphTypes.includes(config.graphType)) { 133 | throw new GraphProcessError( 134 | INVALID_GRAPH_TYPE(config.graphType) 135 | ); 136 | } 137 | } 138 | 139 | if (!config.dateRangeValue) { 140 | if (!config.fromDate || !config.toDate) { 141 | throw new GraphProcessError(MISS_DAYS_OR_RANGE_DATE()); 142 | } 143 | } 144 | 145 | if (config.fromDate || config.toDate) { 146 | // yyyy-MM-dd 147 | const dateReg = /^\d{4}-\d{2}-\d{2}$/; 148 | if (config.fromDate && !dateReg.test(config.fromDate)) { 149 | throw new GraphProcessError( 150 | INVALID_DATE_FORMAT(config.fromDate) 151 | ); 152 | } 153 | 154 | if (config.toDate && !dateReg.test(config.toDate)) { 155 | throw new GraphProcessError(INVALID_DATE_FORMAT(config.toDate)); 156 | } 157 | } 158 | 159 | if (config.startOfWeek) { 160 | const statOfWeeks = [0, 1, 2, 3, 4, 5, 6]; 161 | if (typeof config.startOfWeek !== "number") { 162 | try { 163 | config.startOfWeek = parseInt(config.startOfWeek); 164 | } catch (e) { 165 | throw new GraphProcessError( 166 | INVALID_START_OF_WEEK(config.startOfWeek) 167 | ); 168 | } 169 | } 170 | if (!statOfWeeks.includes(config.startOfWeek)) { 171 | throw new GraphProcessError( 172 | INVALID_START_OF_WEEK(config.startOfWeek) 173 | ); 174 | } 175 | } 176 | } 177 | } 178 | 179 | export class ValidationResult { 180 | valid: boolean; 181 | message?: string; 182 | } 183 | 184 | export type DateRangeType = 185 | | "LATEST_DAYS" 186 | | "LATEST_MONTH" 187 | | "LATEST_YEAR" 188 | | "FIXED_DATE_RANGE"; 189 | -------------------------------------------------------------------------------- /src/processor/yamlConfigReconciler.ts: -------------------------------------------------------------------------------- 1 | import { DateRangeType, YamlGraphConfig } from "./types"; 2 | 3 | export class YamlConfigReconciler { 4 | constructor() {} 5 | 6 | static reconcile(yamlConfig: YamlGraphConfig): YamlGraphConfig { 7 | return YamlConfigReconciler.reconcile_from_0_4_0(yamlConfig); 8 | } 9 | 10 | static reconcile_from_0_4_0(yamlConfig: YamlGraphConfig): YamlGraphConfig { 11 | if (!yamlConfig.dataSource) { 12 | yamlConfig.dataSource = { 13 | type: "PAGE", 14 | value: yamlConfig.query || '""', 15 | filters: [], 16 | dateField: { 17 | type: "PAGE_PROPERTY", 18 | value: yamlConfig.dateField, 19 | format: yamlConfig.dateFieldFormat, 20 | }, 21 | countField: { 22 | type: "DEFAULT", 23 | }, 24 | }; 25 | } 26 | 27 | if (!yamlConfig.dateRangeType) { 28 | const hasLatestDays = yamlConfig.days !== undefined; 29 | const dateTypeValue: DateRangeType = hasLatestDays 30 | ? "LATEST_DAYS" 31 | : "FIXED_DATE_RANGE"; 32 | yamlConfig.dateRangeType = dateTypeValue; 33 | } 34 | 35 | if (!yamlConfig.dateRangeValue) { 36 | yamlConfig.dateRangeValue = yamlConfig.days; 37 | } 38 | 39 | yamlConfig.query = undefined; 40 | yamlConfig.dateField = undefined; 41 | yamlConfig.dateFieldFormat = undefined; 42 | return yamlConfig; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/query/baseDataviewSourceQuery.ts: -------------------------------------------------------------------------------- 1 | import { App, Platform } from "obsidian"; 2 | import { DataviewApi, getAPI, DataArray, Literal } from "obsidian-dataview"; 3 | import { GraphProcessError } from "src/processor/graphProcessError"; 4 | import { 5 | CountFieldType, 6 | Data, 7 | DataSource, 8 | DateFieldType, 9 | PropertySource, 10 | } from "./types"; 11 | import { DateTime } from "luxon"; 12 | import { Contribution, ContributionItem } from "src/types"; 13 | import { isLuxonDateTime } from "src/util/dateTimeUtils"; 14 | import { parseNumberOption } from "src/util/utils"; 15 | import { dataviewDataFilterChain } from "./filter/dataviewDataFilter"; 16 | 17 | export abstract class BaseDataviewDataSourceQuery { 18 | abstract accept(source: DataSource): boolean; 19 | 20 | query(source: DataSource, app: App): Contribution[] { 21 | this.reconcileSourceValueIfNotExists(source); 22 | const dv = this.checkAndGetApi(app); 23 | const data = this.doQuery(dv, source); 24 | const queryData = this.mapToQueryData(data, source); 25 | const unsatisfiedData = queryData.filter((item) => !item.date); 26 | if (unsatisfiedData.length > 0) { 27 | console.warn( 28 | unsatisfiedData.length + 29 | " data can't be converted to date, please check the date field format", 30 | unsatisfiedData 31 | ); 32 | } 33 | const result = queryData 34 | .filter((d) => d.date != undefined) 35 | .groupBy((d) => d.date?.toFormat("yyyy-MM-dd")) 36 | .map((entry) => { 37 | // performance optimization 38 | const value = this.countSumValueByCustomizeProperty( 39 | entry.rows, 40 | source.countField?.type, 41 | source.countField?.value 42 | ); 43 | const items = entry.rows 44 | .map((item) => { 45 | let label; 46 | if (source.type == "PAGE") { 47 | // @ts-ignore 48 | label = item.raw.file.name; 49 | } else { 50 | label = item.raw.text; 51 | } 52 | 53 | const value = 54 | this.getAndConvertValueByCustomizeProperty( 55 | item, 56 | source.countField?.type, 57 | source.countField?.value 58 | ); 59 | 60 | if (source.countField?.type == "PAGE_PROPERTY") { 61 | label += ` [${source.countField?.value}:${value}]`; 62 | } 63 | 64 | return { 65 | label: label, 66 | value: value, 67 | link: { 68 | // @ts-ignore 69 | href: item.raw.file.path, 70 | className: "internal-link", 71 | rel: 'noopener' 72 | }, 73 | open: (e) => jump(e, source, item, app), 74 | } as ContributionItem; 75 | }) 76 | .array(); 77 | return { 78 | date: entry.key, 79 | value: value, 80 | items: items, 81 | } as Contribution; 82 | }) 83 | .array(); 84 | return result; 85 | } 86 | 87 | reconcileSourceValueIfNotExists(source: DataSource) { 88 | if (!source.value) { 89 | source.value = '""'; 90 | } 91 | } 92 | 93 | checkAndGetApi(app: App): DataviewApi { 94 | const dv = getAPI(app); 95 | if (!dv) { 96 | throw new GraphProcessError({ 97 | summary: "Initialize Dataview failed", 98 | recommends: ["Please install Dataview plugin"], 99 | }); 100 | } 101 | return dv; 102 | } 103 | 104 | abstract doQuery( 105 | dv: DataviewApi, 106 | source: DataSource 107 | ): DataArray>; 108 | 109 | mapToQueryData( 110 | data: DataArray>, 111 | source: DataSource 112 | ): DataArray>> { 113 | if (source.dateField && source.dateField.type) { 114 | const dateFieldName = source.dateField.value; 115 | const dateFieldType = source.dateField.type; 116 | const dateFieldFormat = source.dateField.format; 117 | return data 118 | .filter((item) => { 119 | return dataviewDataFilterChain.every((filter) => 120 | filter.filter(source, item, this) 121 | ); 122 | }) 123 | .map((item) => { 124 | // @ts-ignore 125 | const fileName = item.file.name; 126 | if (dateFieldType == "FILE_CTIME") { 127 | // @ts-ignore 128 | return new Data(item, item.file.ctime); 129 | } else if (dateFieldType == "FILE_MTIME") { 130 | // @ts-ignore 131 | return new Data(item, item.file.mtime); 132 | } else if (dateFieldType == "FILE_NAME") { 133 | const dateTime = this.toDateTime( 134 | fileName, 135 | fileName, 136 | dateFieldFormat 137 | ); 138 | if (dateTime) { 139 | return new Data(item, dateTime); 140 | } else { 141 | return new Data(item); 142 | } 143 | } else { 144 | const propertySource = 145 | this.getPropertySourceByDateFieldType( 146 | source.dateField?.type 147 | ); 148 | const fieldValue = this.getValueByCustomizeProperty( 149 | item, 150 | propertySource, 151 | dateFieldName || "" 152 | ); 153 | if (isLuxonDateTime(fieldValue)) { 154 | return new Data(item, fieldValue as DateTime); 155 | } else { 156 | const dateTime = this.toDateTime( 157 | fileName, 158 | fieldValue as string, 159 | dateFieldFormat 160 | ); 161 | return new Data(item, dateTime); 162 | } 163 | } 164 | }); 165 | } else { 166 | return data.map((item) => { 167 | // @ts-ignore 168 | return new Data(item, item.file.ctime); 169 | }); 170 | } 171 | } 172 | 173 | toDateTime( 174 | page: string, 175 | date: string, 176 | dateFieldFormat?: string 177 | ): DateTime | undefined { 178 | if (typeof date !== "string") { 179 | console.warn( 180 | "can't parse date, it's a valid format? " + 181 | date + 182 | " in page " + 183 | page 184 | ); 185 | return undefined; 186 | } 187 | try { 188 | let dateTime = null; 189 | if (dateFieldFormat) { 190 | dateTime = DateTime.fromFormat(date, dateFieldFormat); 191 | if (dateTime.isValid) { 192 | return dateTime; 193 | } 194 | } 195 | 196 | dateTime = DateTime.fromISO(date); 197 | if (dateTime.isValid) { 198 | return dateTime; 199 | } 200 | dateTime = DateTime.fromRFC2822(date); 201 | if (dateTime.isValid) { 202 | return dateTime; 203 | } 204 | dateTime = DateTime.fromHTTP(date); 205 | if (dateTime.isValid) { 206 | return dateTime; 207 | } 208 | dateTime = DateTime.fromSQL(date); 209 | if (dateTime.isValid) { 210 | return dateTime; 211 | } 212 | dateTime = DateTime.fromFormat(date, "yyyy-MM-dd HH:mm"); 213 | if (dateTime.isValid) { 214 | return dateTime; 215 | } 216 | dateTime = DateTime.fromFormat(date, "yyyy-MM-dd'T'HH:mm"); 217 | if (dateTime.isValid) { 218 | return dateTime; 219 | } 220 | } catch (e) { 221 | console.warn( 222 | "can't parse date, it's a valid format? " + 223 | date + 224 | " in page " + 225 | page 226 | ); 227 | } 228 | return undefined; 229 | } 230 | 231 | countSumValueByCustomizeProperty( 232 | groupData: DataArray>>, 233 | propertyType?: CountFieldType, 234 | propertyName?: string 235 | ): number { 236 | if (!propertyType || propertyType == "DEFAULT") { 237 | return groupData.length; 238 | } 239 | 240 | if (propertyName) { 241 | return groupData 242 | .map((item) => { 243 | return this.getAndConvertValueByCustomizeProperty( 244 | item, 245 | propertyType, 246 | propertyName 247 | ); 248 | }) 249 | .array() 250 | .reduce((a, b) => a + b, 0); 251 | } 252 | return groupData.length; 253 | } 254 | 255 | getAndConvertValueByCustomizeProperty( 256 | item: Data>, 257 | propertyType?: CountFieldType, 258 | propertyName?: string 259 | ): number { 260 | if (propertyName) { 261 | let propertySource: PropertySource; 262 | switch (propertyType) { 263 | case "PAGE_PROPERTY": 264 | propertySource = "PAGE"; 265 | break; 266 | case "TASK_PROPERTY": 267 | propertySource = "TASK"; 268 | break; 269 | default: 270 | propertySource = "UNKNOWN"; 271 | break; 272 | } 273 | 274 | const r = this.getValueByCustomizeProperty( 275 | item.raw, 276 | propertySource, 277 | propertyName 278 | ); 279 | if (r == undefined || r == null) { 280 | return 0; 281 | } 282 | 283 | if (r instanceof Array) { 284 | return r.length; 285 | } 286 | 287 | if (typeof r === "number" || r instanceof Number) { 288 | return r as number; 289 | } 290 | 291 | if (typeof r === "string" || r instanceof String) { 292 | const n = parseNumberOption(r as string); 293 | if (n != null) { 294 | return n; 295 | } 296 | return r.trim() === "" ? 0 : 1; 297 | } 298 | 299 | if (typeof r === "boolean" || r instanceof Boolean) { 300 | return r ? 1 : 0; 301 | } 302 | return 1; 303 | } else { 304 | return 1; 305 | } 306 | } 307 | 308 | abstract getValueByCustomizeProperty( 309 | data: Record, 310 | propertyType: PropertySource, 311 | propertyName: string 312 | ): any; 313 | 314 | getPropertySourceByCountFieldType(type: CountFieldType): PropertySource { 315 | switch (type) { 316 | case "PAGE_PROPERTY": 317 | return "PAGE"; 318 | case "TASK_PROPERTY": 319 | return "TASK"; 320 | default: 321 | return "UNKNOWN"; 322 | } 323 | } 324 | 325 | getPropertySourceByDateFieldType(type?: DateFieldType): PropertySource { 326 | switch (type) { 327 | case "FILE_CTIME": 328 | case "FILE_MTIME": 329 | case "FILE_NAME": 330 | case "PAGE_PROPERTY": 331 | return "PAGE"; 332 | case "TASK_PROPERTY": 333 | return "TASK"; 334 | default: 335 | return "UNKNOWN"; 336 | } 337 | } 338 | } 339 | 340 | function jump( 341 | evt: MouseEvent, 342 | source: DataSource, 343 | item: Data>, 344 | app: App 345 | ) { 346 | if (source.type != "PAGE") { 347 | const selectionState = { 348 | eState: { 349 | cursor: { 350 | from: { 351 | line: item.raw.line, 352 | // @ts-ignore 353 | ch: item.raw.position.start.col, 354 | }, 355 | to: { 356 | // @ts-ignore 357 | line: item.raw.line + item.raw.lineCount - 1, 358 | // @ts-ignore 359 | ch: item.raw.position.end.col, 360 | }, 361 | }, 362 | line: item.raw.line, 363 | }, 364 | }; 365 | // MacOS interprets the Command key as Meta. 366 | app.workspace.openLinkText( 367 | // @ts-ignore 368 | item.raw.link.toFile().obsidianLink(), 369 | // @ts-ignore 370 | item.raw.path, 371 | evt.ctrlKey || (evt.metaKey && Platform.isMacOS), 372 | selectionState as any 373 | ); 374 | } else { 375 | app.workspace.openLinkText( 376 | // @ts-ignore 377 | item.raw.file?.path, 378 | "", 379 | evt.ctrlKey || (evt.metaKey && Platform.isMacOS) 380 | ); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/query/compositeDataSourceQuery.ts: -------------------------------------------------------------------------------- 1 | import { GraphProcessError } from "src/processor/graphProcessError"; 2 | import { BaseDataviewDataSourceQuery } from "./baseDataviewSourceQuery"; 3 | import { DataSource } from "./types"; 4 | import { App } from "obsidian"; 5 | import { DataviewPageDataSourceQuery } from "./dataviewPageDataSourceQuery"; 6 | import { DataviewTaskDataSourceQuery } from "./dataviewTaskDataSourceQuery"; 7 | 8 | export class CompositeDataSourceQuery { 9 | 10 | private dataSourceQueries: BaseDataviewDataSourceQuery[] = [ 11 | new DataviewPageDataSourceQuery(), 12 | new DataviewTaskDataSourceQuery(), 13 | ]; 14 | 15 | query(source: DataSource, app: App) { 16 | const dataSourceQuery = this.dataSourceQueries.find(query => query.accept(source)); 17 | if (!dataSourceQuery) { 18 | throw new GraphProcessError({ 19 | summary: "Unsupported data source", 20 | recommends: [ 21 | "Please use supported data source", 22 | ] 23 | }); 24 | } 25 | return dataSourceQuery.query(source, app); 26 | } 27 | } -------------------------------------------------------------------------------- /src/query/dataviewPageDataSourceQuery.ts: -------------------------------------------------------------------------------- 1 | import { DataArray, DataviewApi, Literal } from "obsidian-dataview"; 2 | import { BaseDataviewDataSourceQuery } from "./baseDataviewSourceQuery"; 3 | import { DataSource, PropertySource } from "./types"; 4 | 5 | export class DataviewPageDataSourceQuery extends BaseDataviewDataSourceQuery { 6 | accept(source: DataSource): boolean { 7 | return source.type === "PAGE"; 8 | } 9 | 10 | doQuery( 11 | dv: DataviewApi, 12 | source: DataSource 13 | ): DataArray> { 14 | return dv.pages(source.value); 15 | } 16 | 17 | getValueByCustomizeProperty( 18 | data: Record, 19 | propertyType: PropertySource, 20 | propertyName: string 21 | ): any { 22 | if (propertyType === "PAGE") { 23 | return data[propertyName]; 24 | } 25 | return undefined; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/query/dataviewTaskDataSourceQuery.ts: -------------------------------------------------------------------------------- 1 | import { DataArray, DataviewApi, Literal } from "obsidian-dataview"; 2 | import { BaseDataviewDataSourceQuery } from "./baseDataviewSourceQuery"; 3 | import { DataFilter, DataSource, PropertySource } from "./types"; 4 | 5 | export class DataviewTaskDataSourceQuery extends BaseDataviewDataSourceQuery { 6 | accept(source: DataSource): boolean { 7 | return ( 8 | source.type === "ALL_TASK" || 9 | source.type === "TASK_IN_SPECIFIC_PAGE" 10 | ); 11 | } 12 | 13 | doQuery( 14 | dv: DataviewApi, 15 | source: DataSource 16 | ): DataArray> { 17 | let pageData; 18 | if (source.type === "ALL_TASK") { 19 | pageData = dv.pages('""'); 20 | } else { 21 | pageData = dv.pages(source.value); 22 | } 23 | 24 | const taskData = pageData 25 | // @ts-ignore 26 | .filter((p) => p.file.tasks.length > 0) 27 | .flatMap((p) => { 28 | const fileTasks: DataArray> = 29 | // @ts-ignore 30 | p.file.tasks.map((task) => { 31 | return { 32 | ...task, 33 | file: p.file, 34 | }; 35 | }); 36 | return fileTasks.array(); 37 | }); 38 | 39 | if (!source.filters || source.filters.length === 0) { 40 | return taskData; 41 | } 42 | return taskData.filter((task) => { 43 | if (!source.filters) { 44 | return true; 45 | } 46 | return source.filters.every((filter) => { 47 | switch (filter.type) { 48 | case "NONE": 49 | return true; 50 | case "STATUS_IS": 51 | return this.filterByStatusIs(filter, task); 52 | case "STATUS_IN": 53 | return this.filterByStatusIn(filter, task); 54 | case "CONTAINS_ANY_TAG": 55 | return this.filterByContainsAnyTag(filter, task); 56 | default: 57 | return true; 58 | } 59 | }); 60 | }); 61 | } 62 | 63 | filterByStatusIn( 64 | filter: DataFilter, 65 | data: Record 66 | ): boolean { 67 | const statusArr = filter.value as string[]; 68 | return statusArr.some((value) => { 69 | if (value == "COMPLETED") { 70 | return data.completed as boolean; 71 | } else if (value == "INCOMPLETE") { 72 | return data.status == " "; 73 | } else if (value == "CANCELED") { 74 | return data.status == "-"; 75 | } else if (value == "ANY") { 76 | return true; 77 | } else if (value == "FULLY_COMPLETED") { 78 | return data.fullyCompleted as boolean; 79 | } else { 80 | return data.status === value; 81 | } 82 | }); 83 | } 84 | 85 | filterByStatusIs( 86 | filter: DataFilter, 87 | data: Record 88 | ): boolean { 89 | if (filter.value == "COMPLETED") { 90 | return data.completed as boolean; 91 | } else if (filter.value == "INCOMPLETE") { 92 | return data.status == " "; 93 | } else if (filter.value == "CANCELED") { 94 | return data.status == "-"; 95 | } else if (filter.value == "ANY") { 96 | return true; 97 | } else if (filter.value == "FULLY_COMPLETED") { 98 | return data.fullyCompleted as boolean; 99 | } else { 100 | return data.status === filter.value; 101 | } 102 | } 103 | 104 | filterByContainsAnyTag( 105 | filter: DataFilter, 106 | task: Record 107 | ): boolean { 108 | if (filter?.value instanceof Array) { 109 | const values: string[] = filter?.value; 110 | if (values.length === 0) { 111 | return true; 112 | } else { 113 | // @ts-ignore 114 | return task.tags.some( 115 | // @ts-ignore 116 | (tag) => 117 | values.find( 118 | (value) => value.toLowerCase() === tag.toLowerCase() 119 | ) !== undefined 120 | ); 121 | } 122 | } else { 123 | return true; 124 | } 125 | } 126 | 127 | getValueByCustomizeProperty( 128 | data: Record, 129 | propertyType: PropertySource, 130 | propertyName: string 131 | ): any { 132 | if (propertyType === "PAGE") { 133 | if (data.file) { 134 | // @ts-ignore 135 | return data.file[propertyName]; 136 | } 137 | } 138 | 139 | if (propertyType === "TASK") { 140 | return data[propertyName]; 141 | } 142 | return undefined; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/query/filter/dataviewDataFilter.ts: -------------------------------------------------------------------------------- 1 | import { Literal } from "obsidian-dataview"; 2 | import { 3 | CountFieldType, 4 | DataSource, 5 | DateFieldType, 6 | PropertySource, 7 | } from "../types"; 8 | import { BaseDataviewDataSourceQuery } from "../baseDataviewSourceQuery"; 9 | 10 | export interface DataViewDataFilter { 11 | filter( 12 | source: DataSource, 13 | item: Record, 14 | query: BaseDataviewDataSourceQuery 15 | ): boolean; 16 | } 17 | 18 | export class CountFieldDataViewDataFilter implements DataViewDataFilter { 19 | filter( 20 | source: DataSource, 21 | item: Record, 22 | query: BaseDataviewDataSourceQuery 23 | ): boolean { 24 | if (!source.countField) { 25 | return true; 26 | } 27 | if (source.countField.type == 'DEFAULT') { 28 | return true; 29 | } 30 | const propertyType = getPropertySourceByCountFieldType( 31 | source.countField.type 32 | ); 33 | const fieldValue = query.getValueByCustomizeProperty( 34 | item, 35 | propertyType, 36 | source.countField.value || "" 37 | ); 38 | if (fieldValue == undefined || fieldValue == null) { 39 | return false; 40 | } 41 | return true; 42 | } 43 | } 44 | 45 | export class DateFieldDataViewDataFilter implements DataViewDataFilter { 46 | filter( 47 | source: DataSource, 48 | item: Record, 49 | query: BaseDataviewDataSourceQuery 50 | ): boolean { 51 | if (source.dateField && source.dateField.value) { 52 | const dateFieldName = source.dateField.value; 53 | const dateFieldType = source.dateField.type; 54 | if ( 55 | dateFieldType == "FILE_CTIME" || 56 | dateFieldType == "FILE_MTIME" || 57 | dateFieldType == "FILE_NAME" 58 | ) { 59 | return true; 60 | } 61 | 62 | const propertySource = getPropertySourceByDateFieldType( 63 | source.dateField?.type 64 | ); 65 | const fieldValue = query.getValueByCustomizeProperty( 66 | item, 67 | propertySource, 68 | dateFieldName 69 | ); 70 | if (!fieldValue) { 71 | return false; 72 | } 73 | } 74 | return true; 75 | } 76 | } 77 | 78 | export const dataviewDataFilterChain: DataViewDataFilter[] = [ 79 | new CountFieldDataViewDataFilter(), 80 | new DateFieldDataViewDataFilter(), 81 | ]; 82 | 83 | function getPropertySourceByCountFieldType( 84 | type: CountFieldType 85 | ): PropertySource { 86 | switch (type) { 87 | case "PAGE_PROPERTY": 88 | return "PAGE"; 89 | case "TASK_PROPERTY": 90 | return "TASK"; 91 | default: 92 | return "UNKNOWN"; 93 | } 94 | } 95 | 96 | function getPropertySourceByDateFieldType( 97 | type?: DateFieldType 98 | ): PropertySource { 99 | switch (type) { 100 | case "FILE_CTIME": 101 | case "FILE_MTIME": 102 | case "FILE_NAME": 103 | case "PAGE_PROPERTY": 104 | return "PAGE"; 105 | case "TASK_PROPERTY": 106 | return "TASK"; 107 | default: 108 | return "UNKNOWN"; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/query/types.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | export type DataSourceType = "PAGE" | "ALL_TASK" | "TASK_IN_SPECIFIC_PAGE"; 4 | 5 | export type DataSourceFilterType = 6 | | "NONE" 7 | | "STATUS_IS" 8 | | "CONTAINS_ANY_TAG" 9 | | "STATUS_IN"; 10 | 11 | export class DataSource { 12 | type: DataSourceType; 13 | value: string; 14 | filters?: DataFilter[]; 15 | dateField?: DateField; 16 | countField?: CountField; 17 | } 18 | 19 | export class DataFilter { 20 | id: string; 21 | type: DataSourceFilterType; 22 | value?: string | string[]; 23 | } 24 | 25 | export class DateField { 26 | type: DateFieldType; 27 | value?: string; 28 | format?: string; 29 | } 30 | 31 | export class CountField { 32 | type: CountFieldType; 33 | value?: string; 34 | } 35 | 36 | export type DateFieldType = 37 | | "FILE_CTIME" 38 | | "FILE_MTIME" 39 | | "FILE_NAME" 40 | | "PAGE_PROPERTY" 41 | | "TASK_PROPERTY"; 42 | 43 | export const FILE_CTIME_FIELD = "file.ctime"; 44 | 45 | export const FILE_MTIME_FIELD = "file.mtime"; 46 | 47 | export const FILE_NAME = "file.name"; 48 | 49 | export type CountFieldType = "DEFAULT" | "PAGE_PROPERTY" | "TASK_PROPERTY"; 50 | 51 | export type PropertySource = "UNKNOWN" | "PAGE" | "TASK"; 52 | 53 | export type TaskStatus = 54 | | "COMPLETED" 55 | | "INCOMPLETE" 56 | | "FULLY_COMPLETED" 57 | | "CANCELED" 58 | | "ANY"; 59 | 60 | export class ConvertFailData { 61 | source: string; 62 | summary: string; 63 | } 64 | 65 | export class Data { 66 | raw: T; 67 | date?: DateTime; 68 | 69 | constructor(raw: T, date?: DateTime) { 70 | this.date = date; 71 | this.raw = raw; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/render/calendarGraphRender.ts: -------------------------------------------------------------------------------- 1 | import { ContributionGraphConfig } from "src/types"; 2 | import { 3 | distanceBeforeTheStartOfWeek, 4 | distanceBeforeTheEndOfWeek, 5 | } from "src/util/dateUtils"; 6 | import { mapBy } from "src/util/utils"; 7 | import { BaseGraphRender } from "./graphRender"; 8 | import { 9 | localizedMonthMapping, 10 | localizedWeekDayMapping, 11 | localizedYearMonthMapping, 12 | } from "src/i18/messages"; 13 | import { DateTime } from "luxon"; 14 | 15 | export class CalendarGraphRender extends BaseGraphRender { 16 | constructor() { 17 | super(); 18 | } 19 | 20 | graphType(): string { 21 | return "calendar"; 22 | } 23 | 24 | render(root: HTMLElement, graphConfig: ContributionGraphConfig): void { 25 | const graphEl = this.createGraphEl(root) 26 | 27 | // main 28 | const main = this.createMainEl(graphEl, graphConfig) 29 | 30 | // title 31 | if (graphConfig.title && graphConfig.title.trim() != "") { 32 | this.renderTitle(graphConfig, main); 33 | } 34 | 35 | // main -> charts 36 | const chartsEl = createDiv({ 37 | cls: ["charts", "calendar"], 38 | parent: main, 39 | }); 40 | 41 | this.renderCellRuleIndicator(graphConfig, main); 42 | 43 | const activityContainer = this.renderActivityContainer(graphConfig, main); 44 | 45 | const contributionData = this.generateContributionData( 46 | graphConfig 47 | ).filter((item) => item.date != "$HOLE$"); 48 | 49 | // fill first month distance 50 | if (contributionData.length > 0) { 51 | const first = contributionData[0]; 52 | const firstDateTime = DateTime.fromISO(first.date); 53 | const startOfMonth = firstDateTime.startOf("month"); 54 | for (let i = firstDateTime.day - 1; i >= startOfMonth.day; i--) { 55 | const current = startOfMonth.plus({ days: i - startOfMonth.day }); 56 | contributionData.unshift({ 57 | date: "$HOLE$", 58 | weekDay: current.weekday == 7 ? 0 : current.weekday, 59 | month: current.month - 1, 60 | monthDate: current.day, 61 | year: current.year, 62 | value: 0, 63 | }); 64 | } 65 | } 66 | 67 | // fill last month distance 68 | if (contributionData.length > 0) { 69 | const last = contributionData[contributionData.length - 1]; 70 | const lastDateTime = DateTime.fromISO(last.date); 71 | const endOfMonth = lastDateTime.endOf("month"); 72 | 73 | for (let i = lastDateTime.day + 1; i <= endOfMonth.day; i++) { 74 | const current = lastDateTime.plus({ days: i - lastDateTime.day }); 75 | contributionData.push({ 76 | date: "$HOLE$", 77 | weekDay: current.weekday == 7 ? 0 : current.weekday, 78 | month: current.month - 1, 79 | monthDate: current.day, 80 | year: current.year, 81 | value: 0, 82 | }); 83 | } 84 | } 85 | 86 | const contributionMapByYearMonth = mapBy( 87 | contributionData, 88 | (item) => `${item.year}-${item.month + 1}`, 89 | (item) => item.value, 90 | (a, b) => a + b 91 | ); 92 | const cellRules = this.getCellRules(graphConfig); 93 | let currentYearMonth = ""; 94 | let monthContainer; 95 | let rowContainer = null; 96 | for (let i = 0; i < contributionData.length; i++) { 97 | const item = contributionData[i]; 98 | const yearMonth = `${item.year}-${item.month + 1}`; 99 | if (yearMonth != currentYearMonth) { 100 | currentYearMonth = yearMonth; 101 | 102 | monthContainer = document.createElement("div"); 103 | monthContainer.className = "month-container"; 104 | chartsEl.appendChild(monthContainer); 105 | 106 | const monthIndicator = document.createElement("div"); 107 | monthIndicator.className = "month-indicator"; 108 | if (item.month == 0) { 109 | monthIndicator.innerText = localizedYearMonthMapping( 110 | item.year, 111 | item.month 112 | ); 113 | } else { 114 | monthIndicator.innerText = localizedMonthMapping( 115 | item.month 116 | ); 117 | } 118 | monthContainer.appendChild(monthIndicator); 119 | this.bindMonthTips( 120 | monthIndicator, 121 | item, 122 | contributionMapByYearMonth 123 | ); 124 | 125 | const weekDateIndicators = createDiv({ 126 | cls: ["row", "week-indicator-container"], 127 | parent: monthContainer, 128 | }); 129 | for (let i = 0; i < 7; i++) { 130 | const dateIndicatorCell = document.createElement("div"); 131 | dateIndicatorCell.className = "cell week-indicator"; 132 | this.applyCellGlobalStylePartial(dateIndicatorCell, graphConfig, ['minWidth', 'minHeight']); 133 | const weekText = localizedWeekDayMapping( 134 | ((graphConfig.startOfWeek || 0) + 7 + i) % 7, 135 | 2 136 | ); 137 | dateIndicatorCell.innerText = weekText; 138 | weekDateIndicators.appendChild(dateIndicatorCell); 139 | } 140 | 141 | rowContainer = document.createElement("div"); 142 | rowContainer.className = "row"; 143 | monthContainer?.appendChild(rowContainer); 144 | 145 | // fill start month, if start month date is not 1 146 | const distance = distanceBeforeTheStartOfWeek( 147 | graphConfig.startOfWeek || 0, 148 | item.weekDay 149 | ); 150 | for (let j = 0; j < distance; j++) { 151 | const cellEl = document.createElement("div"); 152 | cellEl.className = "cell"; 153 | this.applyCellGlobalStylePartial(cellEl, graphConfig, ['minWidth', 'minHeight']); 154 | rowContainer?.appendChild(cellEl); 155 | } 156 | } 157 | 158 | if ( 159 | rowContainer == null || 160 | item.weekDay == (graphConfig.startOfWeek || 0) 161 | ) { 162 | rowContainer = document.createElement("div"); 163 | rowContainer.className = "row"; 164 | monthContainer?.appendChild(rowContainer); 165 | } 166 | 167 | // render cell 168 | const cellEl = document.createElement("div"); 169 | rowContainer?.appendChild(cellEl); 170 | cellEl.className = "cell"; 171 | if (item.date == "$HOLE$") { 172 | cellEl.innerText = "···"; 173 | cellEl.className = "cell"; 174 | this.applyCellGlobalStylePartial(cellEl, graphConfig, ['minWidth', 'minHeight']); 175 | } else if (item.value == 0) { 176 | cellEl.className = "cell empty"; 177 | this.applyCellGlobalStyle(cellEl, graphConfig); 178 | this.applyCellStyleRule(cellEl, item, cellRules); 179 | this.bindCellAttribute(cellEl, item); 180 | } else { 181 | cellEl.className = "cell"; 182 | this.applyCellGlobalStyle(cellEl, graphConfig); 183 | this.applyCellStyleRule(cellEl, item, cellRules, () => cellRules[0]); 184 | this.bindCellAttribute(cellEl, item); 185 | this.bindCellClickEvent(cellEl, item, graphConfig, activityContainer); 186 | this.bindCellTips(cellEl, item); 187 | } 188 | 189 | if (i + 1 < contributionData.length) { 190 | const next = contributionData[i + 1]; 191 | if (next.month != item.month) { 192 | const distance = distanceBeforeTheEndOfWeek( 193 | graphConfig.startOfWeek || 0, 194 | item.weekDay 195 | ); 196 | for (let j = 0; j < distance; j++) { 197 | const cellEl = document.createElement("div"); 198 | cellEl.className = "cell"; 199 | this.applyCellGlobalStylePartial(cellEl, graphConfig, ['minWidth', 'minHeight']); 200 | rowContainer?.appendChild(cellEl); 201 | } 202 | } 203 | } else if (i + 1 == contributionData.length) { 204 | const distance = distanceBeforeTheEndOfWeek( 205 | graphConfig.startOfWeek || 0, 206 | item.weekDay 207 | ); 208 | for (let j = 0; j < distance; j++) { 209 | const cellEl = document.createElement("div"); 210 | cellEl.className = "cell"; 211 | this.applyCellGlobalStylePartial(cellEl, graphConfig, ['minWidth', 'minHeight']); 212 | rowContainer?.appendChild(cellEl); 213 | } 214 | } 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/render/gitStyleTrackGraphRender.ts: -------------------------------------------------------------------------------- 1 | import { ContributionCellData, ContributionGraphConfig } from "src/types"; 2 | import { mapBy } from "src/util/utils"; 3 | import { BaseGraphRender } from "./graphRender"; 4 | import { distanceBeforeTheStartOfWeek } from "src/util/dateUtils"; 5 | import { 6 | localizedMonthMapping, 7 | localizedWeekDayMapping, 8 | } from "src/i18/messages"; 9 | 10 | export class GitStyleTrackGraphRender extends BaseGraphRender { 11 | constructor() { 12 | super(); 13 | } 14 | 15 | graphType(): string { 16 | return "default"; 17 | } 18 | 19 | render(root: HTMLElement, graphConfig: ContributionGraphConfig): void { 20 | const graphEl = this.createGraphEl(root) 21 | 22 | // main 23 | const main = this.createMainEl(graphEl, graphConfig) 24 | 25 | // title 26 | if (graphConfig.title && graphConfig.title.trim() != "") { 27 | this.renderTitle(graphConfig, main); 28 | } 29 | 30 | // main -> charts 31 | const chartsEl = createDiv({ 32 | cls: ["charts", "default"], 33 | parent: main, 34 | }); 35 | 36 | this.renderCellRuleIndicator(graphConfig, main); 37 | const activityContainer= this.renderActivityContainer(graphConfig, main); 38 | 39 | // main -> week day indicator(text cell) 40 | const weekTextColumns = createDiv({ 41 | cls: "column", 42 | parent: chartsEl, 43 | }); 44 | this.renderWeekIndicator(weekTextColumns, graphConfig); 45 | 46 | const contributionData: ContributionCellData[] = 47 | this.generateContributionData(graphConfig); 48 | 49 | // fill HOLE cell at the left most column if start date is not ${startOfWeek} 50 | if (contributionData.length > 0) { 51 | const from = new Date(contributionData[0].date); 52 | const weekDayOfFromDate = from.getDay(); 53 | const firstHoleCount = distanceBeforeTheStartOfWeek( 54 | graphConfig.startOfWeek || 0, 55 | weekDayOfFromDate 56 | ); 57 | for (let i = 0; i < firstHoleCount; i++) { 58 | contributionData.unshift({ 59 | date: "$HOLE$", 60 | weekDay: -1, 61 | month: -1, 62 | monthDate: -1, 63 | year: -1, 64 | value: 0, 65 | }); 66 | } 67 | } 68 | 69 | const contributionMapByYearMonth = mapBy( 70 | contributionData, 71 | (item) => `${item.year}-${item.month + 1}`, 72 | (item) => item.value, 73 | (a, b) => a + b 74 | ); 75 | 76 | // main -> charts contributionData 77 | const cellRules = this.getCellRules(graphConfig); 78 | 79 | let columnEl; 80 | for (let i = 0; i < contributionData.length; i++) { 81 | // i % 7 == 0 means new column 82 | if (i % 7 == 0) { 83 | columnEl = document.createElement("div"); 84 | columnEl.className = "column"; 85 | chartsEl.appendChild(columnEl); 86 | } 87 | 88 | const contributionItem = contributionData[i]; 89 | // main -> charts -> column -> month indicator 90 | if (contributionItem.monthDate == 1) { 91 | const monthCell = createDiv({ 92 | cls: "month-indicator", 93 | parent: columnEl, 94 | text: "", 95 | }); 96 | monthCell.innerText = localizedMonthMapping( 97 | contributionItem.month 98 | ); 99 | this.bindMonthTips( 100 | monthCell, 101 | contributionItem, 102 | contributionMapByYearMonth 103 | ); 104 | } 105 | 106 | // main -> charts -> column -> cell 107 | const cellEl = document.createElement("div"); 108 | columnEl?.appendChild(cellEl); 109 | 110 | if (contributionItem.value == 0) { 111 | if (contributionItem.date != "$HOLE$") { 112 | cellEl.className = "cell empty"; 113 | this.applyCellGlobalStyle(cellEl, graphConfig); 114 | this.applyCellStyleRule(cellEl, contributionItem, cellRules); 115 | this.bindCellAttribute(cellEl, contributionItem); 116 | } else { 117 | cellEl.className = "cell"; 118 | this.applyCellGlobalStylePartial(cellEl, graphConfig, ['minWidth', 'minHeight']); 119 | } 120 | } else { 121 | cellEl.className = "cell"; 122 | this.applyCellGlobalStyle(cellEl, graphConfig); 123 | this.applyCellStyleRule(cellEl, contributionItem, cellRules, () => cellRules[0]); 124 | this.bindCellAttribute(cellEl, contributionItem); 125 | this.bindCellClickEvent(cellEl, contributionItem, graphConfig, activityContainer); 126 | this.bindCellTips(cellEl, contributionItem); 127 | } 128 | } 129 | } 130 | 131 | renderWeekIndicator(weekdayContainer: HTMLDivElement,graphConfig: ContributionGraphConfig) { 132 | const startOfWeek = graphConfig.startOfWeek || 0; 133 | for (let i = 0; i < 7; i++) { 134 | const weekdayCell = document.createElement("div"); 135 | weekdayCell.className = "cell week-indicator"; 136 | this.applyCellGlobalStyle(weekdayCell, graphConfig); 137 | switch (i) { 138 | case 1: 139 | case 3: 140 | case 5: 141 | weekdayCell.innerText = localizedWeekDayMapping( 142 | (i + startOfWeek || 0) % 7 143 | ); 144 | break; 145 | default: 146 | break; 147 | } 148 | weekdayContainer.appendChild(weekdayCell); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/render/graphRender.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_RULES } from "src/constants"; 2 | import { 3 | ContributionGraphConfig, 4 | ContributionCellData, 5 | CellStyleRule, 6 | ContributionItem, 7 | } from "src/types"; 8 | import { parseDate } from "src/util/dateUtils"; 9 | import { 10 | generateByLatestDays, 11 | generateByFixedDate, 12 | generateByData, 13 | } from "./matrixDataGenerator"; 14 | import { matchCellStyleRule } from "src/util/utils"; 15 | import { Locals } from "src/i18/messages"; 16 | 17 | export interface GraphRender { 18 | render(container: HTMLElement, graphConfig: ContributionGraphConfig): void; 19 | 20 | graphType(): string; 21 | } 22 | 23 | export abstract class BaseGraphRender implements GraphRender { 24 | constructor() { } 25 | 26 | render(container: HTMLElement, graphConfig: ContributionGraphConfig): void { 27 | throw new Error("Method not implemented."); 28 | } 29 | 30 | abstract graphType(): string; 31 | 32 | createGraphEl(root: HTMLElement): HTMLDivElement { 33 | return createDiv({ 34 | cls: "contribution-graph", 35 | parent: root, 36 | }); 37 | } 38 | 39 | createMainEl( 40 | parent: HTMLElement, 41 | graphConfig: ContributionGraphConfig 42 | ): HTMLDivElement { 43 | let cls = "main"; 44 | if (graphConfig.fillTheScreen && this.graphType() != "calendar") { 45 | cls = `main ${graphConfig.fillTheScreen ? "fill-the-screen" : ""}`; 46 | } 47 | 48 | if (graphConfig.enableMainContainerShadow) { 49 | cls += " shadow"; 50 | } 51 | 52 | const main = createDiv({ 53 | cls: cls, 54 | parent: parent, 55 | }); 56 | if (graphConfig.mainContainerStyle) { 57 | Object.assign(main.style, graphConfig.mainContainerStyle); 58 | } 59 | return main; 60 | } 61 | 62 | renderTitle( 63 | graphConfig: ContributionGraphConfig, 64 | parent: HTMLElement 65 | ): HTMLElement { 66 | const titleEl = document.createElement("div"); 67 | titleEl.className = "title"; 68 | if (graphConfig.title) { 69 | titleEl.innerText = graphConfig.title; 70 | } 71 | if (graphConfig.titleStyle) { 72 | Object.assign(titleEl.style, graphConfig.titleStyle); 73 | } 74 | parent.appendChild(titleEl); 75 | return titleEl; 76 | } 77 | 78 | renderCellRuleIndicator( 79 | graphConfig: ContributionGraphConfig, 80 | parent: HTMLElement 81 | ) { 82 | if (graphConfig.showCellRuleIndicators === false) { 83 | return; 84 | } 85 | 86 | const cellRuleIndicatorContainer = createDiv({ 87 | cls: "cell-rule-indicator-container", 88 | parent: parent, 89 | }); 90 | const cellRules = this.getCellRules(graphConfig); 91 | createDiv({ 92 | cls: "cell text", 93 | text: "less", 94 | parent: cellRuleIndicatorContainer, 95 | }); 96 | cellRules 97 | .sort((a, b) => a.min - b.min) 98 | .forEach((rule) => { 99 | const cellEl = createDiv({ 100 | cls: ["cell"], 101 | parent: cellRuleIndicatorContainer, 102 | }); 103 | cellEl.className = "cell"; 104 | cellEl.style.backgroundColor = rule.color; 105 | cellEl.innerText = rule.text || ""; 106 | 107 | // bind tips event 108 | const summary = `${rule.min} ≤ contributions < ${rule.max}`; 109 | cellEl.ariaLabel = summary; 110 | }); 111 | createDiv({ 112 | cls: "cell text", 113 | text: "more", 114 | parent: cellRuleIndicatorContainer, 115 | }); 116 | } 117 | 118 | renderActivityContainer( 119 | graphConfig: ContributionGraphConfig, 120 | parent: HTMLElement 121 | ): HTMLElement { 122 | const activityContainer = createDiv({ 123 | cls: "activity-container", 124 | parent: parent, 125 | }); 126 | return activityContainer; 127 | } 128 | 129 | renderActivity( 130 | graphConfig: ContributionGraphConfig, 131 | cellData: ContributionCellData, 132 | contaienr: HTMLElement 133 | ) { 134 | contaienr.empty(); 135 | 136 | const closeButton = createEl("button", { 137 | cls: "close-button", 138 | text: "x", 139 | parent: contaienr, 140 | }); 141 | 142 | closeButton.onclick = () => { 143 | contaienr.empty(); 144 | }; 145 | 146 | let summary; 147 | if (cellData.value > 0) { 148 | summary = Locals.get() 149 | .you_have_contributed_to.replace("{date}", cellData.date) 150 | .replace("{value}", cellData.value.toString()); 151 | } else { 152 | summary = Locals.get() 153 | .you_have_no_contributions_on.replace("{date}", cellData.date) 154 | .replace("{value}", "0"); 155 | } 156 | createDiv({ 157 | cls: "activity-summary", 158 | parent: contaienr, 159 | text: summary, 160 | }); 161 | 162 | if ((cellData.items || []).length === 0) { 163 | return; 164 | } 165 | 166 | const content = createDiv({ 167 | cls: "activity-content", 168 | parent: contaienr, 169 | }); 170 | 171 | // list-main 172 | const list = createDiv({ 173 | cls: "activity-list", 174 | parent: content, 175 | }); 176 | 177 | // show top 10 items 178 | const size = 10; 179 | const items = cellData.items || []; 180 | renderActivityItem(items.slice(0, size), list); 181 | 182 | const navigation = createDiv({ 183 | cls: "activity-navigation", 184 | parent: content, 185 | }); 186 | 187 | let page = 1; 188 | if (items.length > size) { 189 | const loadMore = createEl("a", { 190 | text: Locals.get().click_to_load_more, 191 | href: "#", 192 | parent: navigation, 193 | }); 194 | loadMore.onclick = (event) => { 195 | event.preventDefault(); 196 | page++; 197 | renderActivityItem( 198 | items.slice((page - 1) * size, page * size), 199 | list 200 | ); 201 | if (page * size >= items.length) { 202 | loadMore.remove(); 203 | } 204 | }; 205 | } 206 | } 207 | 208 | generateContributionData(graphConfig: ContributionGraphConfig) { 209 | if (graphConfig.days) { 210 | return generateByLatestDays(graphConfig.days, graphConfig.data); 211 | } else if (graphConfig.fromDate && graphConfig.toDate) { 212 | const fromDate = parseDate(graphConfig.fromDate); 213 | const toDate = parseDate(graphConfig.toDate); 214 | return generateByFixedDate(fromDate, toDate, graphConfig.data); 215 | } else { 216 | return generateByData(graphConfig.data); 217 | } 218 | } 219 | 220 | getCellRules(graphConfig: ContributionGraphConfig) { 221 | return graphConfig.cellStyleRules && 222 | graphConfig.cellStyleRules.length > 0 223 | ? graphConfig.cellStyleRules 224 | : DEFAULT_RULES; 225 | } 226 | 227 | bindMonthTips( 228 | monthCell: HTMLElement, 229 | contributionItem: ContributionCellData, 230 | contributionMapByYearMonth: Map 231 | ) { 232 | const yearMonth = `${contributionItem.year}-${contributionItem.month + 1 233 | }`; 234 | const yearMonthValue = contributionMapByYearMonth.get(yearMonth) || 0; 235 | // tips event 236 | monthCell.ariaLabel = `${yearMonthValue} contributions on ${yearMonth}.`; 237 | } 238 | 239 | applyCellGlobalStyle( 240 | cellEl: HTMLElement, 241 | graphConfig: ContributionGraphConfig 242 | ) { 243 | if (graphConfig.cellStyle) { 244 | Object.assign(cellEl.style, graphConfig.cellStyle); 245 | } 246 | } 247 | 248 | applyCellGlobalStylePartial( 249 | cellEl: HTMLElement, 250 | graphConfig: ContributionGraphConfig, 251 | props: string[] 252 | ) { 253 | if (graphConfig.cellStyle) { 254 | const partialStyle = props.reduce((acc, cur) => { 255 | // @ts-ignore 256 | acc[cur] = graphConfig.cellStyle[cur]; 257 | return acc; 258 | }, {}); 259 | Object.assign(cellEl.style, partialStyle); 260 | } 261 | } 262 | 263 | applyCellStyleRule( 264 | cellEl: HTMLElement, 265 | contributionItem: ContributionCellData, 266 | cellRules: CellStyleRule[], 267 | defaultCellStyleRule?: () => CellStyleRule 268 | ) { 269 | const cellStyleRule = matchCellStyleRule( 270 | contributionItem.value, 271 | cellRules 272 | ); 273 | if (cellStyleRule != null) { 274 | cellEl.style.backgroundColor = cellStyleRule.color; 275 | cellEl.innerText = cellStyleRule.text || ""; 276 | return; 277 | } 278 | 279 | if (defaultCellStyleRule) { 280 | const defaultRule = defaultCellStyleRule(); 281 | cellEl.style.backgroundColor = defaultRule.color; 282 | cellEl.innerText = defaultRule.text || ""; 283 | } 284 | } 285 | 286 | bindCellAttribute( 287 | cellEl: HTMLElement, 288 | contributionItem: ContributionCellData 289 | ) { 290 | cellEl.setAttribute("data-year", contributionItem.year.toString()); 291 | cellEl.setAttribute("data-month", contributionItem.month.toString()); 292 | cellEl.setAttribute("data-date", contributionItem.date.toString()); 293 | } 294 | 295 | bindCellClickEvent( 296 | cellEl: HTMLElement, 297 | contributionItem: ContributionCellData, 298 | graphConfig: ContributionGraphConfig, 299 | activityContainer?: HTMLElement 300 | ) { 301 | cellEl.onclick = (event: MouseEvent) => { 302 | if (graphConfig.onCellClick) { 303 | graphConfig.onCellClick(contributionItem, event); 304 | } 305 | 306 | if (activityContainer) { 307 | this.renderActivity( 308 | graphConfig, 309 | contributionItem, 310 | activityContainer 311 | ); 312 | } 313 | }; 314 | } 315 | 316 | bindCellTips(cellEl: HTMLElement, contributionItem: ContributionCellData) { 317 | const summary = contributionItem.summary 318 | ? contributionItem.summary 319 | : `${contributionItem.value} contributions on ${contributionItem.date}.`; 320 | cellEl.ariaLabel = summary; 321 | } 322 | } 323 | 324 | function renderActivityItem(items: ContributionItem[], listMain: HTMLElement) { 325 | (items || []).slice(0, 10).forEach((item) => { 326 | const listItem = createDiv({ 327 | cls: "activity-item", 328 | parent: listMain, 329 | }); 330 | 331 | const linkEl = createEl("a", { 332 | text: item.label, 333 | parent: listItem, 334 | cls: `label ${item.link?.className || ""}`, 335 | }); 336 | linkEl.ariaLabel = item.label; 337 | if (item.link) { 338 | const link = item.link; 339 | linkEl.setAttribute("data-href", link.href || "#"); 340 | linkEl.setAttribute("href", link.href || "#"); 341 | linkEl.setAttribute("target", link.target || "_blank"); 342 | linkEl.setAttribute("rel", link.rel || "noopener"); 343 | } 344 | linkEl.onclick = (event) => { 345 | if (item.open) { 346 | event.preventDefault(); 347 | item.open(event); 348 | } 349 | }; 350 | }); 351 | } 352 | -------------------------------------------------------------------------------- /src/render/matrixDataGenerator.ts: -------------------------------------------------------------------------------- 1 | import { diffDays } from "../util/dateUtils"; 2 | import { Contribution, ContributionCellData } from "../types"; 3 | import { DateTime } from "luxon"; 4 | 5 | export function generateByData(data: Contribution[]) { 6 | if (!data || data.length === 0) { 7 | return []; 8 | } 9 | 10 | const dateData = data.map((item) => { 11 | if (item.date instanceof Date) { 12 | return { 13 | ...item, 14 | timestamp: item.date.getTime(), 15 | }; 16 | } else { 17 | return { 18 | ...item, 19 | date: new Date(item.date), 20 | timestamp: new Date(item.date).getTime(), 21 | }; 22 | } 23 | }); 24 | 25 | const sortedData = dateData.sort((a, b) => b.timestamp - a.timestamp); 26 | const min = sortedData[sortedData.length - 1].timestamp; 27 | const max = sortedData[0].timestamp; 28 | return generateByFixedDate(new Date(min), new Date(max), data); 29 | } 30 | 31 | export function generateByFixedDate( 32 | from: Date, 33 | to: Date, 34 | data: Contribution[] 35 | ) { 36 | const days = diffDays(from, to) + 1; 37 | // convert contributions to map: date(yyyy-MM-dd) -> value(sum) 38 | const contributionMapByDate = contributionToMap(data); 39 | 40 | const cellData: ContributionCellData[] = []; 41 | 42 | const toDateTime = DateTime.fromJSDate(to); 43 | // fill data 44 | for (let i = 0; i < days; i++) { 45 | const currentDateAtIndex = toDateTime.minus({ days: i }); 46 | const isoDate = currentDateAtIndex.toFormat('yyyy-MM-dd'); 47 | const contribution = contributionMapByDate.get(isoDate); 48 | 49 | cellData.unshift({ 50 | date: isoDate, 51 | weekDay: currentDateAtIndex.weekday == 7 ? 0 : currentDateAtIndex.weekday, 52 | month: currentDateAtIndex.month - 1, 53 | monthDate: currentDateAtIndex.day, 54 | year: currentDateAtIndex.year, 55 | value: contribution ? contribution.value : 0, 56 | summary: contribution ? contribution.summary : undefined, 57 | items: contribution ? contribution.items || [] : [], 58 | }); 59 | } 60 | 61 | return cellData; 62 | } 63 | 64 | /** 65 | * - generate two-dimensional matrix data 66 | * - every column is week, from Sunday to Saturday 67 | * - every cell is a day 68 | */ 69 | export function generateByLatestDays( 70 | days: number, 71 | data: Contribution[] = [] 72 | ): ContributionCellData[] { 73 | const fromDate = new Date(); 74 | fromDate.setDate(fromDate.getDate() - days + 1); 75 | return generateByFixedDate(fromDate, new Date(), data); 76 | } 77 | 78 | function contributionToMap(data: Contribution[]) { 79 | const map = new Map(); 80 | for (const item of data) { 81 | let key; 82 | if (typeof item.date === "string") { 83 | key = item.date; 84 | } else { 85 | key = DateTime.fromJSDate(item.date).toFormat('yyyy-MM-dd'); 86 | } 87 | if (map.has(key)) { 88 | const newItem = { 89 | ...item, 90 | // @ts-ignore 91 | value: map.get(key).value + item.value, 92 | }; 93 | map.set(key, newItem); 94 | } else { 95 | map.set(key, item); 96 | } 97 | } 98 | return map; 99 | } 100 | -------------------------------------------------------------------------------- /src/render/monthTrackGraphRender.ts: -------------------------------------------------------------------------------- 1 | import { ContributionGraphConfig } from "src/types"; 2 | import { mapBy } from "src/util/utils"; 3 | import { BaseGraphRender } from "./graphRender"; 4 | import { 5 | localizedMonthMapping, 6 | localizedYearMonthMapping, 7 | } from "src/i18/messages"; 8 | import { DateTime } from "luxon"; 9 | 10 | export class MonthTrackGraphRender extends BaseGraphRender { 11 | constructor() { 12 | super(); 13 | } 14 | 15 | graphType(): string { 16 | return "month-track"; 17 | } 18 | 19 | render(root: HTMLElement, graphConfig: ContributionGraphConfig): void { 20 | const graphEl = this.createGraphEl(root) 21 | 22 | // main 23 | const main = this.createMainEl(graphEl, graphConfig) 24 | 25 | // title 26 | if (graphConfig.title && graphConfig.title.trim() != "") { 27 | this.renderTitle(graphConfig, main); 28 | } 29 | 30 | // main -> charts 31 | const chartsEl = createDiv({ 32 | cls: ["charts", "month-track"], 33 | parent: main, 34 | }); 35 | 36 | this.renderCellRuleIndicator(graphConfig, main); 37 | 38 | // main -> month date indicator(text cell) 39 | const dateIndicatorRow = createDiv({ 40 | cls: "row", 41 | parent: chartsEl, 42 | }); 43 | 44 | dateIndicatorRow.appendChild( 45 | createDiv({ 46 | cls: "cell month-indicator", 47 | text: "", 48 | }) 49 | ); 50 | this.renderMonthDateIndicator(dateIndicatorRow, graphConfig); 51 | 52 | const activityContainer = this.renderActivityContainer(graphConfig, main); 53 | 54 | const contributionData = this.generateContributionData( 55 | graphConfig 56 | ).filter((item) => item.date != "$HOLE$"); 57 | 58 | const contributionMapByYearMonth = mapBy( 59 | contributionData, 60 | (item) => `${item.year}-${item.month + 1}`, 61 | (item) => item.value, 62 | (a, b) => a + b 63 | ); 64 | const cellRules = this.getCellRules(graphConfig); 65 | 66 | let monthDataRowEl; 67 | let currentYearMonth = ""; 68 | for (let i = 0; i < contributionData.length; i++) { 69 | const contributionItem = contributionData[i]; 70 | const yearMonth = `${contributionItem.year}-${contributionItem.month}`; 71 | if (yearMonth != currentYearMonth) { 72 | // if prev month's last date < 31, fill placeholder cell 73 | if (i > 0) { 74 | const prev = contributionData[i - 1]; 75 | const fillMax = 31 - prev.monthDate; 76 | for (let j = 0; j < fillMax; j++) { 77 | const cellEl = document.createElement("div"); 78 | cellEl.className = "cell"; 79 | this.applyCellGlobalStylePartial(cellEl, graphConfig, ['minWidth', 'minHeight']); 80 | monthDataRowEl?.appendChild(cellEl); 81 | } 82 | } 83 | 84 | // new month data row 85 | monthDataRowEl = document.createElement("div"); 86 | monthDataRowEl.className = "row"; 87 | chartsEl.appendChild(monthDataRowEl); 88 | currentYearMonth = yearMonth; 89 | 90 | // month indicator 91 | const monthIndicator = document.createElement("div"); 92 | monthIndicator.className = "cell month-indicator"; 93 | monthIndicator.innerText = 94 | contributionItem.month == 0 95 | ? localizedYearMonthMapping( 96 | contributionItem.year, 97 | contributionItem.month 98 | ) 99 | : localizedMonthMapping(contributionItem.month); 100 | 101 | this.bindMonthTips( 102 | monthIndicator, 103 | contributionItem, 104 | contributionMapByYearMonth 105 | ); 106 | monthDataRowEl.appendChild(monthIndicator); 107 | } 108 | 109 | // fill hole at start month, if start month date is not 1 110 | if (i == 0) { 111 | const startDate = new Date(contributionItem.date).getDate(); 112 | const fillMax = startDate - 1; 113 | for (let j = 0; j < fillMax; j++) { 114 | const cellEl = document.createElement("div"); 115 | cellEl.className = "cell"; 116 | cellEl.innerText = ""; 117 | this.applyCellGlobalStylePartial(cellEl, graphConfig, ['minWidth', 'minHeight']); 118 | monthDataRowEl?.appendChild(cellEl); 119 | } 120 | } 121 | 122 | // render cell 123 | const cellEl = document.createElement("div"); 124 | this.applyCellGlobalStyle(cellEl, graphConfig); 125 | monthDataRowEl?.appendChild(cellEl); 126 | if (contributionItem.value == 0) { 127 | cellEl.className = "cell empty"; 128 | this.applyCellStyleRule(cellEl, contributionItem, cellRules); 129 | this.bindCellAttribute(cellEl, contributionItem); 130 | } else { 131 | cellEl.className = "cell"; 132 | 133 | this.applyCellStyleRule(cellEl, contributionItem, cellRules, () => cellRules[0]); 134 | this.bindCellAttribute(cellEl, contributionItem); 135 | this.bindCellClickEvent(cellEl, contributionItem, graphConfig, activityContainer); 136 | this.bindCellTips(cellEl, contributionItem); 137 | } 138 | } 139 | 140 | // fill hole at last month, if last month date is not end of month 141 | if (contributionData.length > 0) { 142 | const last = contributionData[contributionData.length - 1]; 143 | const lastDateTime = DateTime.fromISO(last.date); 144 | const endOfMonthDay = 31 145 | for (let j = lastDateTime.day; j < endOfMonthDay; j++) { 146 | const cellEl = document.createElement("div"); 147 | cellEl.className = "cell"; 148 | this.applyCellGlobalStylePartial(cellEl, graphConfig, ['minWidth', 'minHeight']); 149 | monthDataRowEl?.appendChild(cellEl); 150 | } 151 | } 152 | } 153 | 154 | renderMonthDateIndicator(dateIndicatorRow: HTMLDivElement, graphConfig: ContributionGraphConfig) { 155 | for (let i = 0; i < 31; i++) { 156 | const dateIndicatorCell = document.createElement("div"); 157 | dateIndicatorCell.className = "cell date-indicator"; 158 | this.applyCellGlobalStylePartial(dateIndicatorCell, graphConfig, ['minWidth', 'minHeight']); 159 | dateIndicatorCell.innerText = `${i + 1}`; 160 | dateIndicatorRow.appendChild(dateIndicatorCell); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/render/renders.ts: -------------------------------------------------------------------------------- 1 | import { ContributionGraphConfig } from "src/types"; 2 | import { CalendarGraphRender } from "./calendarGraphRender"; 3 | import { MonthTrackGraphRender } from "./monthTrackGraphRender"; 4 | import { GitStyleTrackGraphRender } from "./gitStyleTrackGraphRender"; 5 | 6 | export class Renders { 7 | static renders = [ 8 | new CalendarGraphRender(), 9 | new MonthTrackGraphRender(), 10 | new GitStyleTrackGraphRender(), 11 | ]; 12 | 13 | static render( 14 | container: HTMLElement, 15 | graphConfig: ContributionGraphConfig 16 | ): void { 17 | if (graphConfig.graphType === undefined) { 18 | graphConfig.graphType = "default"; 19 | } 20 | const render = this.renders.find( 21 | (r) => r.graphType() === graphConfig.graphType 22 | ); 23 | if (render) { 24 | render.render(container, graphConfig); 25 | } else { 26 | this.renderErrorTips( 27 | container, 28 | `invalid graphType "${graphConfig.graphType}"`, 29 | [ 30 | `please set graphType to one of ${Renders.renders 31 | .map((r) => r.graphType()) 32 | .join(", ")}` 33 | ] 34 | ); 35 | } 36 | } 37 | 38 | static renderErrorTips(container: HTMLElement, summary: string, recommends?: string[]): void { 39 | container.empty(); 40 | const errDiv = createDiv({ 41 | cls: "contribution-graph-render-error-container", 42 | parent: container 43 | }); 44 | 45 | createEl("p", { 46 | text: summary, 47 | cls: "summary", 48 | parent: errDiv 49 | }) 50 | 51 | if (recommends) { 52 | recommends.forEach(r => { 53 | createEl("pre", { 54 | text: r, 55 | cls: "recommend", 56 | parent: errDiv 57 | }) 58 | }) 59 | } 60 | 61 | } 62 | 63 | static renderError(container: HTMLElement, { 64 | summary, 65 | recommends 66 | }: { 67 | summary: string, 68 | recommends?: string[] 69 | }): void { 70 | Renders.renderErrorTips(container, summary, recommends) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_RULES } from "./constants"; 2 | 3 | export class ContributionGraphConfig { 4 | /** 5 | * the title of the graph 6 | */ 7 | title = "Contribution Graph"; 8 | 9 | /** 10 | * the style of the title 11 | */ 12 | titleStyle: Partial = {}; 13 | 14 | /** 15 | * the style of the main container 16 | */ 17 | mainContainerStyle: Partial = {}; 18 | 19 | /** 20 | * recent days to show 21 | */ 22 | days?: number | undefined; 23 | 24 | /** 25 | * the start date of the graph,if `days` is set, this value will be ignored 26 | */ 27 | fromDate?: Date | string | undefined; 28 | 29 | /** 30 | * the end date of the graph,if `days` is set, this value will be ignored 31 | */ 32 | toDate?: Date | string | undefined; 33 | 34 | /** 35 | * the data to show at cell 36 | */ 37 | data: Contribution[]; 38 | 39 | /** 40 | * the global style of the cell, could be override by `cellStyleRules` 41 | */ 42 | cellStyle: Partial = {}; 43 | 44 | /** 45 | * the rules to style the cell 46 | */ 47 | cellStyleRules: CellStyleRule[] = DEFAULT_RULES; 48 | 49 | /** 50 | * set true to fill the screen, default value is false 51 | * 52 | * notice: it's not work when `graphType` is `calendar` 53 | */ 54 | fillTheScreen = false; 55 | 56 | /** 57 | * set true to add box-shadow to main container, default is false 58 | * 59 | * notice: this would be override if mainContainerStyle's boxShadow is set 60 | */ 61 | enableMainContainerShadow = false; 62 | 63 | /** 64 | * set false to hide rule indicators 65 | */ 66 | showCellRuleIndicators = true; 67 | 68 | /** 69 | * `default`: every column is a week day from top to bottom 70 | * `month-track`: every row is a month from left to right 71 | * 72 | * default value: `default` 73 | */ 74 | graphType: "default" | "month-track" | "calendar" = "default"; 75 | 76 | /** 77 | * value range: 0->Sunday, 1->Monday, 2->Tuesday, 3->Wednesday, 4->Thursday, 5->Friday, 6->Saturday 78 | * default value: 0 79 | * notice: it's not work when `graphType` is `month-track` 80 | */ 81 | startOfWeek: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0; 82 | 83 | /** 84 | * callback when cell is clicked 85 | */ 86 | onCellClick?: ( 87 | cellData: ContributionCellData, 88 | event: MouseEvent | undefined 89 | ) => void | undefined; 90 | } 91 | 92 | export interface Contribution { 93 | /** 94 | * the date of the contribution, format: yyyy-MM-dd 95 | */ 96 | date: string | Date; 97 | /** 98 | * the value of the contribution 99 | */ 100 | value: number; 101 | /** 102 | * the summary of the contribution, will be shown when hover on the cell 103 | */ 104 | summary: string | undefined; 105 | 106 | items?: ContributionItem[]; 107 | } 108 | 109 | export interface ContributionItem { 110 | label: string; 111 | value: number; 112 | link?: ContributionItemLink; 113 | open?: (e: MouseEvent) => void; 114 | } 115 | 116 | export interface ContributionItemLink { 117 | href: string; 118 | target?: string; 119 | className?: string; 120 | rel?: string; 121 | } 122 | 123 | export interface CellStyleRule { 124 | id: string | number; 125 | // the background color for the cell 126 | color: string; 127 | // the text in the cell 128 | text?: string | undefined; 129 | // the inlusive min value 130 | min: number; 131 | // the exclusive max value 132 | max: number; 133 | } 134 | 135 | export class ContributionCellData { 136 | date: string; // yyyy-MM-dd 137 | weekDay: number; // 0 - 6 138 | month: number; // 0 - 11 139 | monthDate: number; // 1 - 31 140 | year: number; // sample: 2020 141 | value: number; 142 | summary?: string; 143 | items?: ContributionItem[]; 144 | } 145 | -------------------------------------------------------------------------------- /src/util/dateTimeUtils.ts: -------------------------------------------------------------------------------- 1 | export function isLuxonDateTime(value: any): boolean { 2 | if (value == null || value == undefined) { 3 | return false; 4 | } 5 | if ( 6 | typeof value === "object" && 7 | "isLuxonDateTime" in value && 8 | value.isLuxonDateTime === true 9 | ) { 10 | return true; 11 | } 12 | return false; 13 | } 14 | -------------------------------------------------------------------------------- /src/util/dateUtils.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | export function parseDate(date: string | Date) { 4 | if (typeof date === "string") { 5 | return new Date(date); 6 | } else { 7 | return date; 8 | } 9 | } 10 | 11 | export function diffDays(date1: Date, date2: Date) { 12 | const from = DateTime.fromJSDate(date1); 13 | const to = DateTime.fromJSDate(date2); 14 | return to.diff(from, "days").days; 15 | } 16 | 17 | export function toFormattedDate(date: Date) { 18 | return `${date.getFullYear()}-${ 19 | date.getMonth() < 9 ? "0" + (date.getMonth() + 1) : date.getMonth() + 1 20 | }-${date.getDate() < 10 ? "0" + date.getDate() : date.getDate()}`; 21 | } 22 | 23 | export function toFormattedYearMonth(year: number, month: number) { 24 | return `${year}-${month < 10 ? "0" + month : month}`; 25 | } 26 | 27 | export function getLastDayOfMonth(year: number, month: number) { 28 | return new Date(year, month + 1, 0).getDate(); 29 | } 30 | 31 | export function distanceBeforeTheStartOfWeek( 32 | startOfWeek: number, 33 | weekDate: number 34 | ) { 35 | return (weekDate - startOfWeek + 7) % 7; 36 | } 37 | 38 | export function distanceBeforeTheEndOfWeek( 39 | startOfWeek: number, 40 | weekDate: number 41 | ) { 42 | return (startOfWeek - weekDate + 6) % 7; 43 | } 44 | 45 | export function isToday(date: Date) { 46 | const today = new Date(); 47 | return ( 48 | date.getDate() === today.getDate() && 49 | date.getMonth() === today.getMonth() && 50 | date.getFullYear() === today.getFullYear() 51 | ); 52 | } 53 | 54 | /** 55 | * if years <= 1, then return the first day of the current year and the last day of the current year 56 | * if years = 2, then return the first day of the last year and the last day of the current year 57 | */ 58 | 59 | export function getLatestYearAbsoluteFromAndEnd(years: number) { 60 | const today = new Date(); 61 | const normalizedYear = years <= 1 ? 1 : years; 62 | const start = new Date(today.getFullYear() - normalizedYear + 1, 0, 1); 63 | const end = new Date(today.getFullYear(), 12, 0); 64 | return { 65 | start, 66 | end, 67 | }; 68 | } 69 | 70 | /** 71 | * if months <= 1, then return the first day of the current month and the last day of the current month 72 | * if months = 2, then return the first day of the last month and the last day of the current month 73 | */ 74 | export function getLatestMonthAbsoluteFromAndEnd(months: number) { 75 | const today = new Date(); 76 | const normalizedMonth = months <= 1 ? 1 : months; 77 | const start = new Date( 78 | today.getFullYear(), 79 | today.getMonth() - normalizedMonth + 1, 80 | 1 81 | ); 82 | const end = new Date(today.getFullYear(), today.getMonth() + 1, 0); 83 | return { 84 | start, 85 | end, 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /src/util/page.ts: -------------------------------------------------------------------------------- 1 | import { App, TAbstractFile, TFile, getAllTags } from "obsidian"; 2 | import { SuggestItem } from "src/view/suggest/Suggest"; 3 | 4 | export interface Property { 5 | name: string; 6 | sampleValue: string; 7 | } 8 | 9 | export function getAllProperties(query: string, app: App): Property[] { 10 | const propertyMapByName: Map = new Map(); 11 | const queryKey = query?.toLowerCase(); 12 | app.vault.getAllLoadedFiles().forEach((file) => { 13 | if (file instanceof TFile) { 14 | const cache = app.metadataCache.getCache(file.path); 15 | if (cache) { 16 | for (const key in cache.frontmatter) { 17 | if (!propertyMapByName.has(key)) { 18 | if (!queryKey || key.toLowerCase().includes(queryKey)) { 19 | const value = cache.frontmatter[key]; 20 | propertyMapByName.set(key, { 21 | name: key, 22 | sampleValue: value, 23 | }); 24 | } 25 | } 26 | } 27 | } 28 | } 29 | }); 30 | return Array.from(propertyMapByName.values()); 31 | } 32 | 33 | export function getTagOptions(inputStr: string, app: App): SuggestItem[] { 34 | const abstractFiles = app.vault.getAllLoadedFiles(); 35 | const tags: string[] = []; 36 | const lowerCaseInputStr = inputStr.toLowerCase(); 37 | abstractFiles.forEach((file: TAbstractFile) => { 38 | if (file instanceof TFile) { 39 | const cache = this.app.metadataCache.getCache(file.path); 40 | if (cache) { 41 | const fileTags = getAllTags(cache); 42 | fileTags?.forEach((tag) => { 43 | if ( 44 | tag.toLowerCase().contains(lowerCaseInputStr) && 45 | !tags.includes(tag) 46 | ) { 47 | tags.push(tag); 48 | } 49 | }); 50 | } 51 | } 52 | }); 53 | return tags.map((tag) => { 54 | return { 55 | id: tag, 56 | label: tag, 57 | value: tag, 58 | }; 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /src/util/utils.ts: -------------------------------------------------------------------------------- 1 | import { CellStyleRule } from "../types"; 2 | 3 | export function mapBy( 4 | arr: T[], 5 | keyMapping: (item: T) => string, 6 | valueMapping: (item: T) => R, 7 | aggregator: (a: R, b: R) => R 8 | ) { 9 | const map = new Map(); 10 | for (const item of arr) { 11 | const key = keyMapping(item); 12 | if (map.has(key)) { 13 | //@ts-ignore 14 | map.set(key, aggregator(map.get(key), valueMapping(item))); 15 | } else { 16 | map.set(key, valueMapping(item)); 17 | } 18 | } 19 | return map; 20 | } 21 | 22 | export function matchCellStyleRule(value: number, rules: CellStyleRule[]): CellStyleRule | null { 23 | for (let i = 0; i < rules.length; i++) { 24 | if (value >= rules[i].min && value < rules[i].max) { 25 | return rules[i]; 26 | } 27 | } 28 | return null; 29 | } 30 | 31 | export function parseNumberOption(str: string): number | null { 32 | const trimmedStr = str.trim(); 33 | 34 | if (trimmedStr === "") { 35 | return null; 36 | } 37 | const num = Number(trimmedStr); 38 | 39 | if (Number.isNaN(num)) { 40 | return null; 41 | } 42 | return num; 43 | } -------------------------------------------------------------------------------- /src/view/about/index.css: -------------------------------------------------------------------------------- 1 | .about-container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: baseline; 5 | gap: 1rem; 6 | } 7 | 8 | .about-container .about-item { 9 | display: flex; 10 | flex-direction: row; 11 | gap: 0.8rem; 12 | width: 100%; 13 | } 14 | 15 | .about-container .about-item .label { 16 | min-width: 88px; 17 | } 18 | 19 | .about-container .about-item.center { 20 | justify-content: center; 21 | } 22 | 23 | .about-container .about-item img { 24 | max-width: 200px; 25 | width: 200px; 26 | } -------------------------------------------------------------------------------- /src/view/about/index.tsx: -------------------------------------------------------------------------------- 1 | import { Locals } from "src/i18/messages"; 2 | import "./index.css"; 3 | import { Tab } from "../tab/Tab"; 4 | 5 | export function About() { 6 | const local = Locals.get(); 7 | return ( 8 |
9 |
10 |
{local.form_contact_me}
11 | 12 | 微信公众号 13 | 14 | Github 15 |
16 | 17 |
18 |
{local.form_project_url}
19 | 24 |
25 | 26 |
27 |
{local.form_sponsor}
28 |
29 | 36 | 37 | 38 | ), 39 | }, 40 | { 41 | title: "Buy me a coffee", 42 | children: ( 43 | 44 | 45 | 46 | ), 47 | }, 48 | ]} 49 | /> 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/view/choose/Choose.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export function Choose(props: { 4 | options: ChooseOption[]; 5 | defaultValue: string; 6 | onChoose: (option: ChooseOption) => void; 7 | }): JSX.Element { 8 | const { onChoose } = props; 9 | const [data, setData] = useState(props.options); 10 | const [defaultValue, setDefaultValue] = useState(props.defaultValue); 11 | 12 | const choosed = (option: ChooseOption) => { 13 | setDefaultValue(option.value); 14 | onChoose(option); 15 | }; 16 | return ( 17 |
18 | {data.map((option) => ( 19 |
choosed(option)} 26 | > 27 |
{option.icon}
28 |
29 | ))} 30 |
31 | ); 32 | } 33 | 34 | export class ChooseOption { 35 | constructor( 36 | public tip: string, 37 | public icon: JSX.Element, 38 | public value: string 39 | ) {} 40 | } 41 | -------------------------------------------------------------------------------- /src/view/codeblock/CodeblockEditButtonMount.ts: -------------------------------------------------------------------------------- 1 | import { App, MarkdownView, Notice, getIcon } from "obsidian"; 2 | import { ContributionGraphCreateModal } from "../form/GraphFormModal"; 3 | 4 | export function mountEditButtonToCodeblock( 5 | app: App, 6 | code: string, 7 | codeblockDom: HTMLElement 8 | ) { 9 | const formEditButton = document.createElement("div"); 10 | formEditButton.className = "contribution-graph-codeblock-edit-button"; 11 | const iconEl = getIcon("gantt-chart"); 12 | if (iconEl) { 13 | formEditButton.appendChild(iconEl); 14 | } 15 | codeblockDom.addEventListener("mouseover", () => { 16 | const markdownView = app.workspace.getActiveViewOfType(MarkdownView); 17 | if (markdownView && markdownView.getMode() !== "preview") { 18 | formEditButton.style.opacity = "1"; 19 | justifyTop(codeblockDom, formEditButton); 20 | } 21 | }); 22 | codeblockDom.addEventListener("mouseout", () => { 23 | formEditButton.style.opacity = "0"; 24 | }); 25 | 26 | formEditButton.onclick = () => { 27 | new ContributionGraphCreateModal(this.app, code, (content) => { 28 | const markdownView = 29 | this.app.workspace.getActiveViewOfType(MarkdownView); 30 | if (!markdownView) { 31 | new Notice("No markdown view is active"); 32 | return; 33 | } 34 | const editor = markdownView.editor; 35 | // @ts-ignore 36 | const editorView = editor.cm as EditorView; 37 | const pos = editorView.posAtDOM(codeblockDom); 38 | const start = pos + "```contributionGraph\n".length; 39 | // set selection 40 | editorView.dispatch({ 41 | changes: { 42 | from: start, 43 | to: start + (code ? code.length : 0), 44 | insert: content, 45 | }, 46 | }); 47 | }).open(); 48 | }; 49 | codeblockDom.appendChild(formEditButton); 50 | return formEditButton; 51 | } 52 | 53 | function justifyTop(codeblockDom: HTMLElement, formEditButton: HTMLDivElement) { 54 | const obCodeblocButtonEls = 55 | codeblockDom.getElementsByClassName("edit-block-button"); 56 | let top: string | undefined; 57 | if (obCodeblocButtonEls.length > 0) { 58 | const obCodeblocButtonEl = obCodeblocButtonEls[0]; 59 | // @ts-ignore 60 | top = obCodeblocButtonEl.computedStyleMap().get("top")?.toString(); 61 | } 62 | 63 | if (top) { 64 | formEditButton.style.top = top; 65 | } else { 66 | formEditButton.style.top = "0"; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/view/divider/Divider.tsx: -------------------------------------------------------------------------------- 1 | export function Divider(props: { text?: string }) { 2 | return ( 3 |
4 |
5 | {props.text && ( 6 | <> 7 | {props.text} 8 |
9 | 10 | )} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/view/form/CellRuleFormItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useFloating, 3 | offset, 4 | flip, 5 | shift, 6 | inline, 7 | autoUpdate, 8 | useDismiss, 9 | useRole, 10 | useInteractions, 11 | } from "@floating-ui/react"; 12 | import { Chrome, ColorResult } from "@uiw/react-color"; 13 | import { useState } from "react"; 14 | import { CellStyleRule } from "src/types"; 15 | 16 | export function CellRuleItem(props: { 17 | rule: CellStyleRule; 18 | onChange: (rule: CellStyleRule) => void; 19 | onRemove: (id: string) => void; 20 | }): JSX.Element { 21 | const [rule, setRule] = useState(props.rule); 22 | const [showColorPicker, setShowColorPicker] = useState(false); 23 | const changeRule = (name: string, value: any) => { 24 | const newRule = { ...rule, [name]: value }; 25 | setRule(newRule); 26 | props.onChange(newRule); 27 | }; 28 | 29 | const { refs, floatingStyles, context } = useFloating({ 30 | open: showColorPicker, 31 | onOpenChange: (open) => setShowColorPicker(open), 32 | middleware: [offset(6), flip(), shift(), inline()], 33 | whileElementsMounted: autoUpdate, 34 | }); 35 | 36 | const dismiss = useDismiss(context); 37 | const { getFloatingProps } = useInteractions([dismiss]); 38 | 39 | return ( 40 |
41 |
42 | 49 | changeRule("min", e.target.value)} 55 | /> 56 | 57 | contributions 58 | 59 | changeRule("max", e.target.value)} 65 | /> 66 | = 67 | setShowColorPicker(!showColorPicker)} 73 | > 74 | {showColorPicker ? ( 75 |
82 | { 85 | changeRule("color", color.hexa); 86 | }} 87 | /> 88 |
89 | ) : null} 90 | changeRule("text", e.target.value)} 96 | /> 97 |
98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/view/form/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useFloating, 3 | offset, 4 | flip, 5 | shift, 6 | inline, 7 | autoUpdate, 8 | useDismiss, 9 | useInteractions, 10 | } from "@floating-ui/react"; 11 | import { Chrome, ColorResult } from "@uiw/react-color"; 12 | import { useState } from "react"; 13 | 14 | export function ColorPicker(props: { 15 | color?: string; 16 | defaultColor?: string; 17 | onChange: (color?: string) => void; 18 | onReset?: (color?: string) => void; 19 | }): JSX.Element { 20 | const [showColorPicker, setShowColorPicker] = useState(false); 21 | const { refs, floatingStyles, context } = useFloating({ 22 | open: showColorPicker, 23 | onOpenChange: (open) => setShowColorPicker(open), 24 | middleware: [offset(6), flip(), shift(), inline()], 25 | whileElementsMounted: autoUpdate, 26 | }); 27 | 28 | const dismiss = useDismiss(context); 29 | const { getFloatingProps } = useInteractions([dismiss]); 30 | 31 | return ( 32 | <> 33 | setShowColorPicker(!showColorPicker)} 39 | > 40 | {props.color && ( 41 |
42 | {props.color ?? ""} 43 | { 46 | if (props.onReset) { 47 | props.onReset(props.defaultColor); 48 | } else { 49 | props.onChange(props.defaultColor); 50 | } 51 | }} 52 | > 53 | x 54 | 55 |
56 | )} 57 | {showColorPicker ? ( 58 |
65 | { 68 | props.onChange(color.hexa); 69 | }} 70 | /> 71 |
72 | ) : null} 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/view/form/DataSourceFormItem.tsx: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import { Fragment, useState } from "react"; 3 | import { Locals } from "src/i18/messages"; 4 | import { DataFilter, DataSource, DataSourceType } from "src/query/types"; 5 | import { getAllProperties, getTagOptions } from "src/util/page"; 6 | import { Icons } from "../icon/Icons"; 7 | import { SuggestInput } from "../suggest/SuggestInput"; 8 | import { 9 | countFieldTypes, 10 | dataSourceTypes, 11 | dateFieldTypes, 12 | getDataSourceFilterOptions, 13 | taskStatusOptions, 14 | } from "./options"; 15 | import { App } from "obsidian"; 16 | import { InputTags, TagOption } from "../suggest/SuggestTagInput"; 17 | 18 | export function DataSourceFormItem(props: { 19 | dataSource: DataSource; 20 | onChange: (dataSource: DataSource) => void; 21 | app: App; 22 | }): JSX.Element { 23 | const { dataSource } = props; 24 | const [dateFormatType, setDateFormatType] = useState( 25 | dataSource.dateField?.format ? "manual" : "smart_detect" 26 | ); 27 | const [dataSourceType, setDataSourceType] = useState( 28 | dataSource.type || "PAGE" 29 | ); 30 | 31 | const changeDataSource = (name: string, value: any) => { 32 | const newDataSource = { ...dataSource, [name]: value }; 33 | props.onChange(newDataSource); 34 | }; 35 | 36 | const changeDateField = (name: string, value: any) => { 37 | const newDateField = { ...dataSource.dateField, [name]: value }; 38 | changeDataSource("dateField", newDateField); 39 | }; 40 | 41 | const changeCountField = (name: string, value: any) => { 42 | const newCountField = { ...dataSource.countField, [name]: value }; 43 | changeDataSource("countField", newCountField); 44 | }; 45 | 46 | const changeFilter = (id: string, name: string, value: any) => { 47 | const newFilters = dataSource.filters?.map((f) => { 48 | if (f.id == id) { 49 | if (name == "type" && value == "STATUS_IS") { 50 | return { ...f, [name]: value, value: "COMPLETED" }; 51 | } 52 | return { ...f, [name]: value }; 53 | } 54 | return f; 55 | }); 56 | changeDataSource("filters", newFilters); 57 | }; 58 | 59 | const addFilter = (id: string) => { 60 | const newFilters = dataSource.filters || []; 61 | newFilters.push({ 62 | id: id, 63 | type: "NONE", 64 | }); 65 | changeDataSource("filters", newFilters); 66 | }; 67 | 68 | const removeFilter = (id: string) => { 69 | const newFilters = dataSource.filters?.filter((f) => f.id != id); 70 | changeDataSource("filters", newFilters || []); 71 | }; 72 | 73 | const onDataSourceTypeChange = (newSource: DataSourceType) => { 74 | const newDataSource = { ...dataSource, type: newSource }; 75 | if (newSource === "PAGE") { 76 | newDataSource.filters = []; 77 | 78 | if (newDataSource.dateField?.type === "TASK_PROPERTY") { 79 | newDataSource.dateField = { 80 | type: "FILE_CTIME", 81 | }; 82 | } 83 | 84 | if (dataSource.countField?.type === "TASK_PROPERTY") { 85 | changeCountField("type", "DEFAULT"); 86 | changeCountField("value", undefined); 87 | newDataSource.countField = { 88 | type: "DEFAULT", 89 | }; 90 | } 91 | } 92 | props.onChange(newDataSource); 93 | }; 94 | 95 | const getTagsFromDataSource = (filter: DataFilter): TagOption[] => { 96 | if (filter.type != "CONTAINS_ANY_TAG" && filter.type != 'STATUS_IN') { 97 | return []; 98 | } 99 | if (!(filter.value instanceof Array)) { 100 | return []; 101 | } 102 | return filter.value.map((t) => { 103 | return { 104 | id: t, 105 | label: t, 106 | value: t, 107 | }; 108 | }); 109 | }; 110 | 111 | const taskDataSource: DataSourceType[] = [ 112 | "ALL_TASK", 113 | "TASK_IN_SPECIFIC_PAGE", 114 | ]; 115 | 116 | const local = Locals.get(); 117 | return ( 118 | 119 |
120 | {local.form_data_source_value} 121 |
122 | 139 | {dataSourceType != "ALL_TASK" && ( 140 | { 145 | changeDataSource("value", e.target.value); 146 | }} 147 | /> 148 | )} 149 |
150 |
151 | 152 | {taskDataSource.includes(dataSource.type) && ( 153 |
154 | 155 | {local.form_data_source_filter_label} 156 | 157 |
158 | {dataSource.filters?.map((filter, index) => { 159 | return ( 160 |
161 | 184 | 185 | {filter.type == "STATUS_IS" && ( 186 | 209 | )} 210 | 211 | {filter?.type == "CONTAINS_ANY_TAG" ? ( 212 | { 215 | changeFilter( 216 | filter.id, 217 | "value", 218 | tags.map((t) => { 219 | return t.value; 220 | }) 221 | ); 222 | }} 223 | onRemove={(tag) => { 224 | if ( 225 | filter?.value instanceof 226 | Array 227 | ) { 228 | changeFilter( 229 | filter.id, 230 | "value", 231 | filter?.value?.filter( 232 | (t) => { 233 | return ( 234 | t != 235 | tag.value 236 | ); 237 | } 238 | ) 239 | ); 240 | } 241 | }} 242 | getItems={(query) => { 243 | return getTagOptions( 244 | query, 245 | props.app 246 | ); 247 | }} 248 | inputPlaceholder={ 249 | local.form_datasource_filter_contains_tag_input_placeholder 250 | } 251 | /> 252 | ) : null} 253 | 254 | {filter?.type == "STATUS_IN" ? ( 255 | { 258 | changeFilter( 259 | filter.id, 260 | "value", 261 | values.map((t) => { 262 | return t.value; 263 | }) 264 | ); 265 | }} 266 | onRemove={(tag) => { 267 | if ( 268 | filter?.value instanceof 269 | Array 270 | ) { 271 | changeFilter( 272 | filter.id, 273 | "value", 274 | filter?.value?.filter( 275 | (t) => { 276 | return ( 277 | t != 278 | tag.value 279 | ); 280 | } 281 | ) 282 | ); 283 | } 284 | }} 285 | getItems={(query) => { 286 | return [ 287 | { 288 | id: "CANCELED", 289 | label: local.form_datasource_filter_task_status_canceled, 290 | value: "CANCELED", 291 | icon: Icons.CODE, 292 | }, 293 | { 294 | id: "COMPLETED", 295 | label: local.form_datasource_filter_task_status_completed, 296 | value: "COMPLETED", 297 | icon: Icons.CODE, 298 | }, 299 | { 300 | id: "INCOMPLETE", 301 | label: local.form_datasource_filter_task_status_incomplete, 302 | value: "INCOMPLETE", 303 | icon: Icons.CODE, 304 | }, 305 | { 306 | id: "ANY", 307 | label: local.form_datasource_filter_task_status_any, 308 | value: "ANY", 309 | icon: Icons.CODE, 310 | }, 311 | { 312 | id: "FULLY_COMPLETED", 313 | label: local.form_datasource_filter_task_status_fully_completed, 314 | value: "FULLY_COMPLETED", 315 | icon: Icons.CODE, 316 | }, 317 | ]; 318 | }} 319 | inputPlaceholder={ 320 | local.form_datasource_filter_contains_tag_input_placeholder 321 | } 322 | /> 323 | ) : null} 324 | 325 | 331 |
332 | ); 333 | })} 334 |
335 | 343 |
344 |
345 |
346 | )} 347 | 348 |
349 | {local.form_date_field} 350 |
351 | 367 | {dataSource.dateField?.type == "PAGE_PROPERTY" && ( 368 | { 371 | changeDateField("value", newValue); 372 | }} 373 | inputPlaceholder={local.form_date_field_placeholder} 374 | getItems={(query) => { 375 | return getAllProperties(query, props.app).map( 376 | (p, index) => { 377 | return { 378 | id: p.name, 379 | value: p.name, 380 | label: p.name, 381 | icon: Icons.CODE, 382 | description: p.sampleValue || "", 383 | }; 384 | } 385 | ); 386 | }} 387 | onSelected={(item) => { 388 | changeDateField("value", item.value); 389 | }} 390 | /> 391 | )} 392 | 393 | {dataSource.dateField?.type == "TASK_PROPERTY" && ( 394 | { 399 | changeDateField("value", e.target.value); 400 | }} 401 | /> 402 | )} 403 |
404 |
405 | 406 |
407 | {local.form_date_field_format} 408 |
409 | 425 | {dateFormatType == "manual" ? ( 426 | <> 427 | { 437 | changeDateField("format", e.target.value); 438 | }} 439 | /> 440 | 441 |
442 | 443 | Luxon Format 444 | 445 | {" " + local.form_date_field_format_sample}: 446 | {" " + 447 | DateTime.fromJSDate( 448 | new Date("2024-01-01 00:00:00") 449 | ).toFormat( 450 | dataSource.dateField?.format || 451 | "yyyy-MM-dd'T'HH:mm:ss" 452 | )} 453 |
454 | 455 | ) : null} 456 |
457 |
458 | 459 |
460 | 461 | {local.form_count_field_count_field_label} 462 | 463 |
464 | 478 | {dataSource.countField?.type == "PAGE_PROPERTY" || 479 | dataSource.countField?.type == "TASK_PROPERTY" ? ( 480 | { 485 | changeCountField("value", newValue); 486 | }} 487 | inputPlaceholder={ 488 | local.form_count_field_count_field_input_placeholder 489 | } 490 | getItems={(query) => { 491 | return getAllProperties(query, props.app).map( 492 | (p, index) => { 493 | return { 494 | id: p.name, 495 | value: p.name, 496 | label: p.name, 497 | icon: Icons.CODE, 498 | description: p.sampleValue || "", 499 | }; 500 | } 501 | ); 502 | }} 503 | onSelected={(item) => { 504 | changeCountField("value", item.value); 505 | }} 506 | /> 507 | ) : null} 508 |
509 |
510 |
511 | ); 512 | } 513 | -------------------------------------------------------------------------------- /src/view/form/GraphFormModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, App, MarkdownView, parseYaml, stringifyYaml } from "obsidian"; 2 | import { StrictMode } from "react"; 3 | import { Root, createRoot } from "react-dom/client"; 4 | import { GraphForm } from "./GraphForm"; 5 | import { YamlGraphConfig } from "src/processor/types"; 6 | import { YamlConfigReconciler } from "src/processor/yamlConfigReconciler"; 7 | 8 | export class ContributionGraphCreateModal extends Modal { 9 | root: Root | null = null; 10 | 11 | onSave?: (content: string) => void; 12 | 13 | originalConfigContent?: string; 14 | 15 | constructor( 16 | app: App, 17 | originalConfigContent?: string, 18 | onSave?: (content: string) => void 19 | ) { 20 | super(app); 21 | this.originalConfigContent = originalConfigContent; 22 | this.onSave = onSave; 23 | } 24 | 25 | async onOpen() { 26 | const { contentEl } = this; 27 | const rootContainer = createDiv({ 28 | parent: contentEl, 29 | }); 30 | 31 | let yamlConfig: YamlGraphConfig; 32 | let ignoreLanguagePrefix = false; 33 | if (this.originalConfigContent) { 34 | yamlConfig = this.parseFromOriginalConfig()!; 35 | } else { 36 | yamlConfig = this.parseFromSelecttion()!; 37 | if (yamlConfig) { 38 | ignoreLanguagePrefix = true; 39 | } 40 | } 41 | 42 | if (!yamlConfig) { 43 | yamlConfig = new YamlGraphConfig(); 44 | } 45 | 46 | let onSubmit: (yamlGraphConfig: YamlGraphConfig) => void; 47 | if (this.onSave) { 48 | // update existing Graph 49 | onSubmit = (yamlGraphConfig: YamlGraphConfig) => { 50 | this.close(); 51 | this.onSave!(stringifyYaml(yamlGraphConfig)); 52 | }; 53 | } else { 54 | // create new Graph 55 | onSubmit = (yamlGraphConfig: YamlGraphConfig) => { 56 | const markdownView = 57 | this.app.workspace.getActiveViewOfType(MarkdownView); 58 | if (!markdownView) { 59 | return; 60 | } 61 | const editor = markdownView.editor; 62 | this.close(); 63 | if (ignoreLanguagePrefix) { 64 | editor.replaceSelection(stringifyYaml(yamlGraphConfig)); 65 | } else { 66 | const codeblock = `\`\`\`contributionGraph\n${stringifyYaml( 67 | yamlGraphConfig 68 | )}\n\`\`\`\n`; 69 | editor.replaceSelection(codeblock); 70 | } 71 | }; 72 | } 73 | 74 | yamlConfig = YamlConfigReconciler.reconcile(yamlConfig); 75 | 76 | this.root = createRoot(rootContainer); 77 | this.root.render( 78 | 79 | 84 | 85 | ); 86 | } 87 | 88 | async onClose() { 89 | this.root?.unmount(); 90 | const { contentEl } = this; 91 | contentEl.empty(); 92 | } 93 | 94 | parseFromOriginalConfig(): YamlGraphConfig | null { 95 | if ( 96 | this.originalConfigContent && 97 | this.originalConfigContent.trim() != "" 98 | ) { 99 | try { 100 | return parseYaml(this.originalConfigContent) as YamlGraphConfig; 101 | } catch (e) { 102 | return null; 103 | } 104 | } else { 105 | return null; 106 | } 107 | } 108 | 109 | parseFromSelecttion(): YamlGraphConfig | null { 110 | const markdownView = 111 | this.app.workspace.getActiveViewOfType(MarkdownView); 112 | if (!markdownView) { 113 | return null; 114 | } 115 | const editor = markdownView.editor; 116 | const selection = editor.getSelection(); 117 | if (selection && selection.trim() != "") { 118 | try { 119 | return parseYaml(selection) as YamlGraphConfig; 120 | } catch (e) { 121 | return null; 122 | } 123 | } else { 124 | return null; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/view/form/GraphTheme.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_RULES } from "src/constants"; 2 | import { Locals } from "src/i18/messages"; 3 | import { CellStyleRule } from "src/types"; 4 | 5 | export interface Theme { 6 | name: string; 7 | description?: string; 8 | rules: CellStyleRule[]; 9 | } 10 | 11 | export const THEMES = [ 12 | { 13 | name: Locals.get().form_theme_placeholder, 14 | description: "", 15 | rules: [], 16 | }, 17 | { 18 | name: "default", 19 | description: "", 20 | rules: DEFAULT_RULES, 21 | }, 22 | { 23 | name: "Ocean", 24 | description: "", 25 | rules: buildPureColorTheme( 26 | "Ocean", 27 | "#8dd1e2", 28 | "#63a1be", 29 | "#376d93", 30 | "#012f60" 31 | ), 32 | }, 33 | { 34 | name: "Halloween", 35 | description: "", 36 | rules: buildPureColorTheme( 37 | "Halloween", 38 | "#fdd577", 39 | "#faaa53", 40 | "#f07c44", 41 | "#d94e49" 42 | ), 43 | }, 44 | { 45 | name: "Lovely", 46 | description: "", 47 | rules: buildPureColorTheme( 48 | "Lovely", 49 | "#fedcdc", 50 | "#fdb8bf", 51 | "#f892a9", 52 | "#ec6a97" 53 | ), 54 | }, 55 | { 56 | name: "Wine", 57 | description: "", 58 | rules: buildPureColorTheme( 59 | "Wine", 60 | "#d8b0b3", 61 | "#c78089", 62 | "#ac4c61", 63 | "#830738" 64 | ), 65 | }, 66 | ]; 67 | 68 | export function buildPureColorTheme( 69 | themeName: string, 70 | a: string, 71 | b: string, 72 | c: string, 73 | d: string 74 | ) { 75 | return [ 76 | { 77 | id: `${themeName}_a`, 78 | color: a, 79 | min: 1, 80 | max: 2, 81 | }, 82 | { 83 | id: `${themeName}_b`, 84 | color: b, 85 | min: 2, 86 | max: 3, 87 | }, 88 | { 89 | id: `${themeName}_c`, 90 | color: c, 91 | min: 3, 92 | max: 5, 93 | }, 94 | { 95 | id: `${themeName}_d`, 96 | color: d, 97 | min: 5, 98 | max: 9999, 99 | }, 100 | ]; 101 | } 102 | -------------------------------------------------------------------------------- /src/view/form/options.tsx: -------------------------------------------------------------------------------- 1 | import { Locals, isZh } from "src/i18/messages"; 2 | import { Icons } from "../icon/Icons"; 3 | import { ChooseOption } from "../choose/Choose"; 4 | import { SelectOption } from "./GraphForm"; 5 | import { 6 | CountFieldType, 7 | DataSourceFilterType, 8 | DataSourceType, 9 | DateFieldType, 10 | TaskStatus, 11 | } from "src/query/types"; 12 | import { DateRangeType } from "src/processor/types"; 13 | 14 | export const titleAlignChooseOptions: ChooseOption[] = [ 15 | { 16 | tip: "left", 17 | icon: Icons.ALIGN_LEFT, 18 | value: "left", 19 | }, 20 | { 21 | tip: "center", 22 | icon: Icons.ALIGN_CENTER, 23 | value: "center", 24 | }, 25 | { 26 | tip: "right", 27 | icon: Icons.ALIGN_RIGHT, 28 | value: "right", 29 | }, 30 | ]; 31 | 32 | export const graphOptions: SelectOption[] = [ 33 | { 34 | label: Locals.get().form_graph_type_git, 35 | value: "default", 36 | selected: true, 37 | }, 38 | { 39 | label: Locals.get().form_graph_type_month_track, 40 | value: "month-track", 41 | }, 42 | { 43 | label: Locals.get().form_graph_type_calendar, 44 | value: "calendar", 45 | }, 46 | ]; 47 | 48 | export const startOfWeekOptions: SelectOption[] = [ 49 | { 50 | label: Locals.get().weekday_sunday, 51 | value: "0", 52 | selected: !isZh(), 53 | }, 54 | { 55 | label: Locals.get().weekday_monday, 56 | value: "1", 57 | selected: isZh(), 58 | }, 59 | { 60 | label: Locals.get().weekday_tuesday, 61 | value: "2", 62 | }, 63 | { 64 | label: Locals.get().weekday_wednesday, 65 | value: "3", 66 | }, 67 | { 68 | label: Locals.get().weekday_thursday, 69 | value: "4", 70 | }, 71 | { 72 | label: Locals.get().weekday_friday, 73 | value: "5", 74 | }, 75 | { 76 | label: Locals.get().weekday_saturday, 77 | value: "6", 78 | }, 79 | ]; 80 | 81 | export const cellShapes: SelectOption[] = [ 82 | { 83 | label: Locals.get().form_cell_shape_rounded, 84 | value: "", 85 | selected: true, 86 | }, 87 | { 88 | label: Locals.get().form_cell_shape_square, 89 | value: "0%", 90 | }, 91 | { 92 | label: Locals.get().form_cell_shape_circle, 93 | value: "50%", 94 | }, 95 | ]; 96 | 97 | export const dataSourceTypes: SelectOption[] = [ 98 | { 99 | label: Locals.get().form_datasource_type_page, 100 | value: "PAGE", 101 | selected: true, 102 | }, 103 | { 104 | label: Locals.get().form_datasource_type_all_task, 105 | value: "ALL_TASK", 106 | }, 107 | { 108 | label: Locals.get().form_datasource_type_task_in_specific_page, 109 | value: "TASK_IN_SPECIFIC_PAGE", 110 | }, 111 | ]; 112 | 113 | export function getDataSourceFilterOptions( 114 | type: string 115 | ): SelectOption[] { 116 | if (type === "PAGE") { 117 | return []; 118 | } else { 119 | return [ 120 | { 121 | label: Locals.get().form_datasource_filter_type_none, 122 | value: "NONE", 123 | }, 124 | { 125 | label: Locals.get().form_datasource_filter_type_status_is, 126 | value: "STATUS_IS", 127 | }, 128 | { 129 | label: Locals.get().form_datasource_filter_type_status_in, 130 | value: "STATUS_IN", 131 | }, 132 | { 133 | label: Locals.get() 134 | .form_datasource_filter_type_contains_any_tag, 135 | value: "CONTAINS_ANY_TAG", 136 | }, 137 | ]; 138 | } 139 | } 140 | 141 | export const countFieldTypes = ( 142 | source: DataSourceType 143 | ): SelectOption[] => { 144 | const options: SelectOption[] = [ 145 | { 146 | label: Locals.get().form_count_field_count_field_type_default, 147 | value: "DEFAULT", 148 | }, 149 | { 150 | label: Locals.get().form_count_field_count_field_type_page_prop, 151 | value: "PAGE_PROPERTY", 152 | }, 153 | ]; 154 | 155 | if (source === "ALL_TASK" || source === "TASK_IN_SPECIFIC_PAGE") { 156 | options.push({ 157 | label: Locals.get().form_count_field_count_field_type_task_prop, 158 | value: "TASK_PROPERTY", 159 | }); 160 | } 161 | return options; 162 | }; 163 | 164 | export const dateFieldTypes = ( 165 | source: DataSourceType 166 | ): SelectOption[] => { 167 | const options: SelectOption[] = [ 168 | { 169 | label: Locals.get().form_date_field_type_file_ctime, 170 | value: "FILE_CTIME", 171 | }, 172 | { 173 | label: Locals.get().form_date_field_type_file_mtime, 174 | value: "FILE_MTIME", 175 | }, 176 | { 177 | label: Locals.get().form_date_field_type_file_name, 178 | value: "FILE_NAME", 179 | }, 180 | 181 | { 182 | label: Locals.get() 183 | .form_date_field_type_file_specific_page_property, 184 | value: "PAGE_PROPERTY", 185 | }, 186 | ]; 187 | 188 | if (source === "ALL_TASK" || source === "TASK_IN_SPECIFIC_PAGE") { 189 | options.push({ 190 | label: Locals.get() 191 | .form_date_field_type_file_specific_task_property, 192 | value: "TASK_PROPERTY", 193 | }); 194 | } 195 | 196 | return options; 197 | }; 198 | 199 | export const taskStatusOptions: SelectOption[] = [ 200 | { 201 | label: Locals.get().form_datasource_filter_task_status_completed, 202 | value: "COMPLETED", 203 | selected: true, 204 | }, 205 | { 206 | label: Locals.get().form_datasource_filter_task_status_fully_completed, 207 | value: "FULLY_COMPLETED", 208 | }, 209 | { 210 | label: Locals.get().form_datasource_filter_task_status_incomplete, 211 | value: "INCOMPLETE", 212 | }, 213 | { 214 | label: Locals.get().form_datasource_filter_task_status_canceled, 215 | value: "CANCELED", 216 | }, 217 | { 218 | label: Locals.get().form_datasource_filter_task_status_any, 219 | value: "ANY", 220 | }, 221 | ]; 222 | 223 | export const dateTypeOptions: SelectOption[] = [ 224 | { 225 | label: Locals.get().form_date_range_latest_days, 226 | value: "LATEST_DAYS", 227 | }, 228 | { 229 | label: Locals.get().form_date_range_fixed_date, 230 | value: "FIXED_DATE_RANGE", 231 | }, 232 | { 233 | label: Locals.get().form_date_range_latest_month, 234 | value: "LATEST_MONTH", 235 | }, 236 | { 237 | label: Locals.get().form_date_range_latest_year, 238 | value: "LATEST_YEAR", 239 | }, 240 | ]; 241 | -------------------------------------------------------------------------------- /src/view/icon/Icons.tsx: -------------------------------------------------------------------------------- 1 | export const Icons = { 2 | CODE: ( 3 | 15 | 16 | 17 | 18 | 19 | ), 20 | 21 | ALIGN_LEFT: ( 22 | 34 | 35 | 36 | 37 | 38 | ), 39 | 40 | ALIGN_CENTER: ( 41 | 53 | 54 | 55 | 56 | 57 | ), 58 | 59 | ALIGN_RIGHT: ( 60 | 72 | 73 | 74 | 75 | 76 | ), 77 | }; 78 | -------------------------------------------------------------------------------- /src/view/number-input/index.tsx: -------------------------------------------------------------------------------- 1 | import { Notice } from "obsidian"; 2 | import { useEffect, useState } from "react"; 3 | import { Locals } from "src/i18/messages"; 4 | 5 | const NumberInput = (props: { 6 | defaultValue: number; 7 | onChange: (value: number) => void; 8 | placeholder?: string; 9 | min?: number; 10 | max?: number; 11 | }) => { 12 | const [value, setValue] = useState(props.defaultValue.toString()); 13 | const local = Locals.get(); 14 | 15 | const handleChange = (e: React.ChangeEvent) => { 16 | const inputValue = e.target.value; 17 | const regex = /^-?\d*$/; 18 | 19 | if (inputValue === "") { 20 | setValue(""); 21 | return; 22 | } 23 | 24 | if (regex.test(inputValue)) { 25 | if (props.min !== undefined && Number(inputValue) < props.min) { 26 | new Notice( 27 | local.form_number_input_min_warning.replace( 28 | "{value}", 29 | props.min.toString() 30 | ) 31 | ); 32 | setValue(props.min.toString()); 33 | return; 34 | } 35 | 36 | if (props.max !== undefined && Number(inputValue) > props.max) { 37 | new Notice( 38 | local.form_number_input_max_warning.replace( 39 | "{value}", 40 | props.max.toString() 41 | ) 42 | ); 43 | setValue(props.max.toString()); 44 | return; 45 | } 46 | 47 | setValue(inputValue); 48 | } 49 | }; 50 | 51 | useEffect(() => { 52 | if (value == "") { 53 | props.onChange(props.defaultValue); 54 | } else { 55 | props.onChange(parseInt(value)); 56 | } 57 | }, [value]); 58 | 59 | return ( 60 | 66 | ); 67 | }; 68 | 69 | export default NumberInput; 70 | -------------------------------------------------------------------------------- /src/view/suggest/Suggest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useFloating, 3 | offset, 4 | flip, 5 | shift, 6 | inline, 7 | autoUpdate, 8 | useDismiss, 9 | useRole, 10 | useInteractions, 11 | hide, 12 | } from "@floating-ui/react"; 13 | import { debounce } from "obsidian"; 14 | import * as React from "react"; 15 | import { CSSProperties, useEffect, useLayoutEffect } from "react"; 16 | 17 | export function Suggest(props: { 18 | query: string; 19 | showSuggest: boolean; 20 | onSelected: (item: SuggestItem, index: number) => void; 21 | getItems: (query: string) => SuggestItem[]; 22 | onOpenChange?: (open: boolean) => void; 23 | anchorElement: Element; 24 | onSelectChange?: (item: SuggestItem | null, index: number) => void; 25 | style?: CSSProperties; 26 | }): JSX.Element { 27 | const { query, onOpenChange, anchorElement, showSuggest } = props; 28 | const [activeIndex, setActiveIndex] = React.useState(-1); 29 | const [items, setItems] = React.useState([]); 30 | 31 | useEffect(() => { 32 | if (query == undefined || query == null) { 33 | return; 34 | } 35 | const queryItems = debounce( 36 | (query: string) => { 37 | setItems(props.getItems(query)); 38 | }, 39 | 300, 40 | true 41 | ); 42 | queryItems(query); 43 | return () => { 44 | queryItems.cancel(); 45 | }; 46 | }, [query, props.getItems]); 47 | 48 | useEffect(() => { 49 | setActiveIndex(-1); 50 | }, [query]); 51 | 52 | useEffect(() => { 53 | if (props.onSelectChange) { 54 | if (activeIndex == undefined) { 55 | props.onSelectChange(null, -1); 56 | } else { 57 | props.onSelectChange(items[activeIndex], activeIndex); 58 | } 59 | } 60 | }, [activeIndex, items]); 61 | 62 | const { refs, floatingStyles, context } = useFloating({ 63 | open: showSuggest, 64 | onOpenChange: onOpenChange, 65 | middleware: [offset(6), flip(), shift(), inline()], 66 | whileElementsMounted: autoUpdate, 67 | elements: { 68 | reference: anchorElement, 69 | }, 70 | }); 71 | 72 | const dismiss = useDismiss(context); 73 | const role = useRole(context, { role: "tooltip" }); 74 | const { getFloatingProps } = useInteractions([dismiss, role]); 75 | 76 | // Handle keyboard events 77 | useEffect(() => { 78 | if (!showSuggest) { 79 | return; 80 | } 81 | function handleKeyDown(event: KeyboardEvent) { 82 | if (event.key === "ArrowDown") { 83 | event.preventDefault(); 84 | setActiveIndex((index) => { 85 | return (index + 1) % items.length; 86 | }); 87 | } else if (event.key === "ArrowUp") { 88 | event.preventDefault(); 89 | setActiveIndex((selected) => { 90 | return (selected - 1 + items.length) % items.length; 91 | }); 92 | } else if (event.key === "Enter") { 93 | if (activeIndex < 0 || activeIndex >= items.length) { 94 | return; 95 | } 96 | if (items.length > 0) { 97 | event.preventDefault(); 98 | props.onSelected(items[activeIndex], activeIndex); 99 | } 100 | } else if (event.key === "Escape") { 101 | event.preventDefault(); 102 | hide(); 103 | } 104 | } 105 | window.addEventListener("keydown", handleKeyDown); 106 | return () => { 107 | window.removeEventListener("keydown", handleKeyDown); 108 | }; 109 | }, [activeIndex, items, showSuggest]); 110 | 111 | useLayoutEffect(() => { 112 | if (!showSuggest || activeIndex == undefined) { 113 | return; 114 | } 115 | const selectContainer = refs.floating?.current; 116 | const item = selectContainer?.children[activeIndex] as HTMLElement; 117 | if (item && selectContainer) { 118 | updateScrollView(selectContainer, item); 119 | } 120 | }, [activeIndex, showSuggest]); 121 | 122 | return ( 123 | <> 124 | {showSuggest && items.length > 0 && ( 125 |
133 | {items.map((item, index) => { 134 | return ( 135 |
{ 141 | e.preventDefault(); 142 | props.onSelected(item, index); 143 | setActiveIndex(index); 144 | }} 145 | > 146 | {item.icon &&
{item.icon}
} 147 |
148 |
149 | {item.label} 150 |
151 |
152 | {item.description} 153 |
154 |
155 |
156 | ); 157 | })} 158 |
159 | )} 160 | 161 | ); 162 | } 163 | 164 | export const updateScrollView = ( 165 | container: HTMLElement, 166 | item: HTMLElement 167 | ): void => { 168 | const containerHeight = container.offsetHeight; 169 | const itemHeight = item ? item.offsetHeight : 0; 170 | 171 | const top = item.offsetTop; 172 | const bottom = top + itemHeight; 173 | 174 | if (top < container.scrollTop) { 175 | container.scrollTop -= container.scrollTop - top + 5; 176 | } else if (bottom > containerHeight + container.scrollTop) { 177 | container.scrollTop += 178 | bottom - containerHeight - container.scrollTop + 5; 179 | } 180 | }; 181 | 182 | export interface SuggestItem { 183 | id: string; 184 | label: string; 185 | value: string; 186 | icon?: React.ReactNode; 187 | description?: string; 188 | } 189 | -------------------------------------------------------------------------------- /src/view/suggest/SuggestInput.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CSSProperties } from "react"; 3 | import { Suggest, SuggestItem } from "./Suggest"; 4 | 5 | export function SuggestInput(props: { 6 | inputPlaceholder?: string; 7 | onInputChange: (value: string) => void; 8 | onSelected: (item: SuggestItem) => void; 9 | getItems: (query: string) => SuggestItem[]; 10 | defaultInputValue?: string; 11 | style?: CSSProperties; 12 | }): JSX.Element { 13 | const { inputPlaceholder } = props; 14 | const [value, setValue] = React.useState(props.defaultInputValue); 15 | const [showSuggest, setShowSuggest] = React.useState(false); 16 | const inputRef = React.useRef(null); 17 | return ( 18 | <> 19 | { 24 | props.onInputChange(e.target.value); 25 | setValue(e.target.value); 26 | setShowSuggest(true); 27 | }} 28 | value={value} 29 | /> 30 | {inputRef.current && ( 31 | { 36 | props.onSelected(item); 37 | setValue(item.value); 38 | setShowSuggest(false); 39 | }} 40 | anchorElement={inputRef.current} 41 | onOpenChange={(show) => setShowSuggest(show)} 42 | /> 43 | )} 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/view/suggest/SuggestTagInput.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useRef, useState } from "react"; 2 | import { SuggestItem, Suggest } from "./Suggest"; 3 | 4 | export class TagOption { 5 | id: string; 6 | value: string; 7 | icon?: ReactNode; 8 | } 9 | 10 | export function InputTags(props: { 11 | tags: TagOption[]; 12 | onChange: (tags: TagOption[]) => void; 13 | onRemove: (tag: TagOption) => void; 14 | getItems?: (query: string) => SuggestItem[]; 15 | inputPlaceholder?: string; 16 | excludeTriggerKeys?: string[]; 17 | }): JSX.Element { 18 | const [value, setValue] = useState(""); 19 | const [hasSelected, setHasSelected] = useState(false); 20 | const [showSuggest, setShowSuggest] = useState(false); 21 | const { tags } = props; 22 | const inputRef = useRef(null); 23 | 24 | const onTagRemove = (id: string) => { 25 | const newTags = tags.filter((tag) => tag.id !== id); 26 | props.onChange(newTags); 27 | }; 28 | 29 | const appendTag = (tagStr: string) => { 30 | const id = new Date().getTime().toString() + "_" + tagStr; 31 | const newTags = [...tags, { id: id, value: tagStr }]; 32 | props.onChange(newTags); 33 | }; 34 | 35 | // listen tab\enter\space\comma 36 | const handleInputKeyDown = (e: React.KeyboardEvent) => { 37 | if (hasSelected) { 38 | return; 39 | } 40 | const { key } = e; 41 | if (props.excludeTriggerKeys?.includes(key)) { 42 | return; 43 | } 44 | if (key === "Tab" || key === "Enter" || key === " ") { 45 | e.preventDefault(); 46 | setShowSuggest(false); 47 | const value = inputRef.current?.value; 48 | if (value) { 49 | appendTag(value); 50 | inputRef.current!.value = ""; 51 | } 52 | } 53 | }; 54 | 55 | return ( 56 | <> 57 |
58 |
59 | {tags?.map((tag, index) => { 60 | return ( 61 |
62 | {tag.icon} 63 | {tag.value} 64 | onTagRemove(tag.id)} 67 | > 68 | x 69 | 70 |
71 | ); 72 | })} 73 |
74 | setShowSuggest(true)} 79 | onKeyDown={(e) => handleInputKeyDown(e)} 80 | onChange={(e) => { 81 | setValue(e.target.value); 82 | if (!showSuggest) { 83 | setShowSuggest(true); 84 | } 85 | }} 86 | /> 87 |
88 | {inputRef.current && ( 89 | { 93 | if (props.getItems) { 94 | return props.getItems(value); 95 | } 96 | return []; 97 | }} 98 | onSelected={(item, index) => { 99 | if (index >= 0) { 100 | appendTag(item.value); 101 | inputRef.current!.value = ""; 102 | } 103 | if (showSuggest) { 104 | setShowSuggest(false); 105 | } 106 | }} 107 | onSelectChange={(item, index) => { 108 | if (index >= 0) { 109 | setHasSelected(true); 110 | } else { 111 | setHasSelected(false); 112 | } 113 | }} 114 | anchorElement={inputRef.current} 115 | onOpenChange={(show) => setShowSuggest(show)} 116 | /> 117 | )} 118 | 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /src/view/tab/Tab.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react"; 2 | import { Divider } from "../divider/Divider"; 3 | 4 | export function Tab(props: { 5 | tabs: TabItemOption[]; 6 | activeIndex?: number; 7 | }): JSX.Element { 8 | const [activeIndx, setActiveIndx] = useState(props.activeIndex || 0); 9 | return ( 10 |
11 |
12 | {props.tabs.map((tab, index) => { 13 | return ( 14 | { 19 | setActiveIndx(index); 20 | tab.onClick?.(); 21 | }} 22 | /> 23 | ); 24 | })} 25 |
26 | 27 |
28 | {props.tabs.map((tab, index) => { 29 | return ( 30 | { 36 | setActiveIndx(index); 37 | tab.onClick?.(); 38 | }} 39 | > 40 | {tab.children} 41 | 42 | ); 43 | })} 44 |
45 |
46 | ); 47 | } 48 | 49 | export function TabTitle(props: { 50 | title: string; 51 | icon?: ReactNode; 52 | children?: ReactNode; 53 | active?: boolean; 54 | onClick?: () => void; 55 | }): JSX.Element { 56 | const { title, icon, active } = props; 57 | return ( 58 |
props.onClick?.()} 61 | > 62 | {icon && {icon}} 63 | {title} 64 |
65 | ); 66 | } 67 | 68 | export function TabItem(props: { 69 | title: string; 70 | icon?: ReactNode; 71 | children?: ReactNode; 72 | active?: boolean; 73 | onClick?: () => void; 74 | }): JSX.Element { 75 | const { children, active } = props; 76 | return ( 77 |
78 |
{children}
79 |
80 | ); 81 | } 82 | 83 | export interface TabItemOption { 84 | title: string; 85 | icon?: ReactNode; 86 | children?: ReactNode; 87 | onClick?: () => void; 88 | } 89 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .contribution-graph{position:relative;width:100%;padding:var(--size-4-1)}.contribution-graph .center{justify-content:center;text-align:center}.contribution-graph .main{line-height:normal;display:grid;justify-content:center;border-radius:var(--radius-s);padding:var(--size-4-2) var(--size-4-1);cursor:auto}.contribution-graph .main.shadow{box-shadow:#00000029 0 1px 4px}.theme-dark .contribution-graph .main.shadow{box-shadow:inset 0 0 .5px 1px #ffffff13,0 0 0 1px #0000000d,0 .3px .4px #00000005,0 .9px 1.5px #0000000b,0 3.5px 6px #00000017}.contribution-graph .main.fill-the-screen{justify-content:unset}.contribution-graph .main .title{font-size:14px;margin-bottom:36px;justify-content:flex-start}.contribution-graph .main .charts{width:100%;display:flex;overflow-x:hidden}.contribution-graph .main .charts:hover{overflow-x:auto}.contribution-graph .main .charts .column{position:relative;display:flex;flex-direction:column;flex-grow:1}.contribution-graph .main .charts .row{position:relative;display:flex;flex-direction:row;flex-grow:1;align-items:flex-start;justify-content:space-between;margin-bottom:6px;width:100%}.contribution-graph .main .charts.default{flex-direction:row;align-items:flex-start;flex-wrap:nowrap;justify-content:flex-start;padding-left:var(--size-4-2);padding-right:var(--size-4-2)}.contribution-graph .main .charts.default .column{margin-top:1.6rem;flex-grow:0}.contribution-graph .fill-the-screen.main .charts.default .column{margin-top:1.6rem;flex-grow:1}.contribution-graph .default .week-indicator{background-color:transparent;font-size:10px;min-width:18px;text-wrap:nowrap;height:8px;margin-right:8px;margin-top:1.5px;text-align:center;cursor:pointer}.contribution-graph .default .month-indicator{position:absolute;top:-24px;text-wrap:nowrap;font-size:10px;min-width:20px;cursor:pointer}.contribution-graph .main .charts.month-track{flex-direction:column;align-items:flex-start;padding-right:12px;width:100%}.contribution-graph .month-track .cell.date-indicator{font-size:8px;width:auto;border-radius:2px;margin-right:2px;flex-grow:1}.contribution-graph .main .charts.month-track .row{width:auto;align-items:flex-start;margin-bottom:8px;gap:1px}.contribution-graph .main.fill-the-screen .charts.month-track .row{width:100%;align-items:flex-start;margin-bottom:8px;gap:1px}.contribution-graph .month-track .cell{min-height:13px;min-width:13px;font-size:10px;border-radius:3px}@media (max-width: 720px){.contribution-graph .month-track .cell{height:10px;min-height:10px;min-width:10px;font-size:10px;border-radius:3px}}.contribution-graph .month-track .month-indicator{background-color:transparent;font-size:12px;height:22px;min-width:77px;margin-right:12px;text-align:right;cursor:pointer;top:auto;position:relative;color:var(--text-normal)}.contribution-graph .main .charts.calendar{display:flex;flex-direction:row;flex-wrap:wrap;padding:.5rem 1rem;gap:2rem;overflow-x:auto;max-width:1000px}.contribution-graph .calendar .month-container{min-width:calc(25% - 2rem);padding:.2rem .33rem;position:relative;border-width:1px;border-style:solid;border-color:transparent;border-radius:5px;justify-content:space-between}.contribution-graph .calendar .month-container:hover{border:1px solid var(--background-modifier-border-hover);border-color:transparent;background-color:#f4f4f499}.theme-dark .contribution-graph .calendar .month-container:hover{border:1px solid var(--background-modifier-border-hover);background-color:#2523234d}.contribution-graph .calendar .month-container .month-indicator{width:100%;text-align:center;cursor:pointer;color:var(--text-normal);font-size:.8rem}.contribution-graph .calendar .month-container .month-indicator:hover{opacity:.6}.contribution-graph .calendar .month-container .week-indicator-container{margin-top:12px}.contribution-graph .main .calendar .month-container .row{gap:.6rem;justify-content:center}.contribution-graph .calendar .month-container .week-indicator-container .cell.week-indicator{font-size:.6rem;line-height:1.5;text-align:center;color:var(--text-muted)}.contribution-graph .main .calendar .month-container .cell{min-width:8px;min-height:8px;width:8px;height:8px;font-size:8px;border-radius:2px;margin-top:0;margin-right:0;flex-grow:0;display:flex;align-items:center}.contribution-graph .main.fill-the-screen .calendar .month-container .cell{flex-grow:1}.contribution-graph .cell{min-width:8.8px;min-height:8.8px;height:8.8px;font-size:8px;border-radius:2px;background-color:transparent;margin-top:2px;margin-right:2px;flex-grow:1;cursor:pointer;position:relative;display:flex;justify-content:center;align-items:center}.contribution-graph .cell:hover{opacity:.6}.contribution-graph .cell.empty{background-color:#ebedf0d9}.theme-dark .contribution-graph .cell.empty{background:rgba(71,71,71,.6)}.color-indicator{height:18px;width:18px;border-radius:50%;border-width:1px;border-color:var(--background-modifier-border);border-style:solid}.color-indicator:hover{border-color:var(--interactive-accent);opacity:.6}.color-label{display:flex;gap:6px;background-color:hsl(var(--interactive-accent-hsl),.2);color:hsl(var(--interactive-accent-hsl),.8);align-items:center;border-radius:6px;padding:4px 8px}.color-label:hover{background-color:hsl(var(--interactive-accent-hsl),.8);color:var(--text-on-accent)}.color-reset-button:hover{color:var(--text-normal)}.contribution-graph .cell-rule-indicator-container{position:relative;width:calc(100% - 12px);display:flex;flex-direction:row;justify-content:flex-end;align-items:center;margin-top:12px;margin-bottom:6px}.contribution-graph .cell-rule-indicator-container .cell{max-width:12px;width:10px;height:10px;text-align:center}.contribution-graph .cell-rule-indicator-container .cell.text{height:8px;width:auto;min-width:20px;margin-left:6px;margin-right:6px;color:var(--text-muted)}.contribution-graph-render-error-container{background-color:var(--background-secondary);padding:6px;min-height:12px;border-radius:6px}.contribution-graph-render-error-container .summary{text-align:left;color:var(--text-error);font-size:var(--font-ui-larger)}.contribution-graph-render-error-container .recommend{text-align:left}.contribution-graph-modal{display:flex;flex-direction:column}.contribution-graph-modal-form{display:flex;flex-direction:column;flex-grow:1}.contribution-graph-modal-form .form-group{display:flex;flex-direction:column;flex-grow:1;margin-bottom:12px;gap:3px}.contribution-graph-modal-form .form-item{display:flex;flex-direction:row;flex-wrap:wrap;align-items:baseline;justify-content:space-between;margin-top:12px;gap:6px}.contribution-graph-modal-form .form-item .label{display:inline-flex;justify-content:flex-end;flex:0 0 auto;line-height:32px;font-size:14px;padding:0 12px 0 0;box-sizing:border-box;width:150px}.contribution-graph-modal-form .form-item .form-description{line-height:32px;padding:0 12px 0 0;font-size:var(--font-ui-smaller);color:var(--text-faint)}@media screen and (max-width: 768px){.contribution-graph-modal-form .form-item .label{width:100%;justify-content:flex-start}}.contribution-graph-modal-form .form-item .form-content{display:flex;flex-wrap:wrap;align-items:center;gap:6px;flex:1}.contribution-graph-modal-form .form-item .form-vertical-content{display:flex;flex-direction:column;flex-wrap:wrap;align-items:flex-start;flex:1;gap:8px}.contribution-graph-modal-form .form-item .form-vertical-content>input[type=text]{flex-grow:1;border-width:0px 0px 1px 0px;padding:3px;border-color:var(--background-modifier-border);color:var(--text-normal);width:100%}.contribution-graph-modal-form .form-item .form-content input{flex-grow:1;border-width:0px 0px 1px 0px;border-color:var(--background-modifier-border);color:var(--text-normal)}.contribution-graph-modal-form .form-item .form-content input[type=text]{background:var(--background-modifier-form-field)}.contribution-graph-modal-form .form-item .form-content .checkbox{border:1px solid var(--background-modifier-border);padding:0;flex-grow:0}.contribution-graph-modal-form .form-item .form-content .color-picker{height:32px;width:32px;clip-path:circle(50%);inline-size:32px;block-size:0px;flex-grow:0;border-width:0px;padding:0}.contribution-graph-modal-form .form-item .form-content .color-picker:hover{opacity:.5}.contribution-graph-modal-form .form-item .form-content .number-input{text-align:center;box-shadow:none;border-width:0px 0px 1px 0px;border-radius:0}.contribution-graph-modal-form .form-item .form-content .number-input:focus{box-shadow:none}.contribution-graph-modal-form .form-item .form-content .button{flex-grow:1;margin-right:12px;background-color:var(--interactive-normal)}.contribution-graph-modal-form .form-item .form-content .button:hover{background-color:var(--interactive-accent)}.contribution-graph-modal-form .form-item .cell-rule-value{text-align:center;width:38px;border-radius:0}.contribution-graph-modal-form .form-item .cell-rule-color{width:16px;height:16px;border-radius:0;inline-size:inherit;block-size:inherit}.contribution-graph-modal-form .form-item .cell-rule-text{width:48px;background-color:transparent;border:none}.contribution-graph-modal-form .preview-content{display:grid;overflow-x:scroll;max-width:80vw}.contribution-graph-modal-form .form-item .list-remove-button{border:none;border-width:0px;box-shadow:none;background-color:transparent}.contribution-graph-modal-form .form-item .list-remove-button:hover{background-color:var(--interactive-accent-hover);color:var(--text-on-accent)}.contribution-graph-modal-form .form-item .list-add-button{border:none;border-width:0px;box-shadow:none;background-color:transparent}.contribution-graph-modal-form .form-item .list-add-button:hover{background-color:var(--interactive-accent-hover);color:var(--text-on-accent)}.contribution-graph-codeblock-edit-button{padding:var(--size-2-2) var(--size-2-3);position:absolute;top:var(--size-2-2);right:calc(var(--size-2-2) + 40px);display:flex;opacity:0;color:var(--text-muted);border-radius:var(--radius-s);cursor:var(--cursor)}.contribution-graph-codeblock-edit-button:hover{background-color:var(--background-modifier-hover)}.contribution-graph-choose{display:flex;flex-direction:row;margin-left:12px;border-radius:6px;border:1px solid var(--background-modifier-border)}.contribution-graph-choose:first-child{margin-left:2px}.contribution-graph-choose .item{display:flex;flex-direction:row;border-radius:4px;margin-right:2px}.contribution-graph-choose .item:hover{background-color:var(--interactive-accent-hover);color:var(--text-on-accent)}.contribution-graph-choose .item.choosed{background-color:var(--interactive-accent);color:var(--text-on-accent)}.contribution-graph-choose .item .icon{display:flex;align-items:center;justify-content:center;text-align:center}.contribution-graph-divider{display:flex;flex-direction:row;align-items:center;margin-top:12px;margin-bottom:12px;gap:8px}.contribution-graph-divider div{border-width:0px 0px 1px 0px;border-radius:0;border-style:solid;border-color:var(--background-modifier-border);flex-grow:1}.contribution-graph-divider span{color:var(--text-muted);font-size:12px}.suggest-container{display:flex;flex-direction:column;position:relative;gap:.8rem;max-height:20rem;min-width:200px;box-shadow:0 0 .5rem #0003;border-radius:.5rem;background-color:var(--modal-background);border-radius:var(--modal-radius);border:var(--modal-border-width) solid var(--modal-border-color);padding:var(--size-4-4);overflow:auto;max-width:480px;z-index:99}.suggest-container .suggest-item{display:flex;flex-direction:row;gap:.8rem;align-items:center;padding:.3rem .4rem;cursor:pointer;border-radius:var(--radius-m);min-width:200px}.suggest-container .suggest-item.selected,.suggest-container .suggest-item:hover{background-color:var(--background-modifier-active-hover)}.suggest-container .suggest-item .suggest-icon{min-width:32px;min-height:32px;max-height:32px;width:32px;height:32px;border-color:var(--background-modifier-border);border-width:1px;border-radius:var(--radius-s);border-style:solid;display:flex;flex-direction:row;align-items:center;justify-content:center}.suggest-container .suggest-item .suggest-icon .lucide{width:16px;height:16px;color:var(--text-normal)}.suggest-container .suggest-item .suggest-content{display:flex;flex-direction:column;gap:.3rem}.suggest-container .suggest-item .suggest-content .suggest-label{font-weight:500;color:var(--text-muted);font-size:var(--font-text-size)}.suggest-container .suggest-item .suggest-content .suggest-description{color:var(--text-muted);font-size:var(--font-smaller);overflow-wrap:break-word;word-break:break-all}.suggest-input-tags{display:flex;flex-direction:column;gap:12px;align-items:baseline;justify-content:flex-start;width:auto;margin-top:10px}.suggest-input-tags .tags{display:flex;flex-direction:row;gap:8px;flex-wrap:wrap}.suggest-input-tags input.input{width:100%;flex-grow:1}.suggest-input-tags input::placeholder{color:var(--text-faint);font-size:var(--font-ui-smaller)}.suggest-input-tags .tags .tag{border-radius:var(--tag-radius);padding-top:var(--tag-padding-y);padding-bottom:var(--tag-padding-y);padding-right:var(--tag-padding-x);padding-left:var(--tag-padding-x);font-size:var(--font-ui-smaller);color:var(--tag-color);background-color:var(--tag-background);border:var(--tag-border-width) solid var(--tag-border-color);display:flex;gap:6px;align-items:center}.suggest-input-tags .tags .tag .icon{width:12px;height:12px}.suggest-input-tags .tags .tag .icon .lucide{width:100%;height:100%}.suggest-input-tags .tags .tag:hover{background-color:var(--interactive-accent);color:var(--text-on-accent)}.suggest-input-tags .tags .tag .remove-button{min-width:24px;text-align:center}.suggest-input-tags .tags .tag .remove-button:hover{color:var(--text-normal)}.tab-container{display:flex;flex-direction:column;flex-wrap:nowrap;overflow-x:auto;gap:6px;width:100%}.tab-container .tab-titles{display:flex;flex-direction:row;flex-wrap:nowrap;justify-content:flex-start;gap:6px;padding:3px 6px}.tab-container .tab-titles .tab-item-title{display:flex;flex-direction:row;flex-wrap:nowrap;justify-content:center;align-items:center;align-content:center;gap:6px;border-radius:3px;cursor:pointer;padding:4.2px 6.5px}.tab-container .tab-titles .tab-item-title.active,.tab-container .tab-titles .tab-item-title.active:hover{color:var(--nav-item-color-active);background-color:var(--nav-item-background-active);font-weight:var(--nav-item-weight-active)}.tab-container .tab-titles .tab-item-title:not(.active):hover{color:var(--nav-item-color-active);background-color:var(--nav-item-background-active);font-weight:var(--nav-item-weight-active)}.tab-container .tab-items .tab-item{padding:3px 6px;display:none}.tab-container .tab-items .tab-item.active{display:block}.contribution-graph .activity-container{display:flex;flex-direction:row;gap:.6rem;padding:.4rem;position:relative}.contribution-graph .activity-container .activity-summary{flex-grow:1;padding-right:.4rem;font-size:.8rem}.contribution-graph .activity-container .activity-content{border:1px solid var(--background-modifier-border);padding:.4rem;display:flex;flex-direction:column;justify-content:baseline;align-items:baseline;gap:.4rem;min-width:50%;max-width:300px}.contribution-graph .activity-container .activity-content .activity-list{display:flex;flex-direction:column;justify-content:baseline;align-items:flex-start;gap:.4rem;font-size:.7rem;color:var(--text-muted)}.contribution-graph .activity-container .activity-content .activity-list .activity-item{display:flex;flex-direction:row;align-items:center}.contribution-graph .activity-container .activity-content .activity-list .activity-item .label{color:var(--text-muted);padding:.4rem .6rem;white-space:wrap;word-break:break-all}.contribution-graph .activity-container .activity-content .activity-list .activity-item .label:hover{background-color:var(--background-modifier-hover)}.contribution-graph .activity-container .activity-content .activity-navigation{font-size:.65rem;display:flex;flex-direction:row;justify-content:flex-end;width:100%}.contribution-graph .activity-container .activity-content .activity-navigation a{color:var(--text-muted);text-decoration:none;padding:.4rem .6rem}.contribution-graph .activity-container .activity-content .activity-navigation a:hover{background-color:var(--background-modifier-hover)}.contribution-graph .activity-container .close-button{position:absolute;right:0;top:0;color:var(--text-faint);box-shadow:none;border-width:0px;background-color:transparent;cursor:pointer}.contribution-graph .activity-container .close-button:hover{color:var(--text-accent)}.about-container{display:flex;flex-direction:column;align-items:baseline;gap:1rem}.about-container .about-item{display:flex;flex-direction:row;gap:.8rem;width:100%}.about-container .about-item .label{min-width:88px}.about-container .about-item.center{justify-content:center}.about-container .about-item img{max-width:200px;width:200px}.input-range-value-label{font-size:.8rem;color:var(--text-muted);cursor:pointer;padding:4px;border-radius:4px}.input-range-value-label:hover{background:var(--interactive-accent);color:var(--text-on-accent)} 2 | -------------------------------------------------------------------------------- /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 | "jsx": "react-jsx", 14 | "strictNullChecks": true, 15 | "paths" :{ 16 | "data-model/*" : ["./node_modules/obsidian-dataview/lib/data-model/*"], 17 | "api/*" : ["./node_modules/obsidian-dataview/lib/api/*"], 18 | "data-index/*" : ["./node_modules/obsidian-dataview/lib/data-index/*"], 19 | "query/*" : ["./node_modules/obsidian-dataview/lib/query/*"], 20 | "expression/*" : ["./node_modules/obsidian-dataview/lib/expression/*"], 21 | "settings" : ["./node_modules/obsidian-dataview/lib/settings"] 22 | }, 23 | "lib": [ 24 | "DOM", 25 | "ES5", 26 | "ES6", 27 | "ES7" 28 | ] 29 | }, 30 | "include": [ 31 | "**/*.ts", 32 | "**/*.tsx" 33 | ] 34 | } -------------------------------------------------------------------------------- /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 | "0.1.0": "1.3.0" 3 | } 4 | --------------------------------------------------------------------------------