├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── images ├── how-to-close.png ├── how-to-pin.png ├── how-to-reopen.png ├── how-to-weekend.png └── screenshot-full.png ├── manifest.json ├── package.json ├── rollup.config.js ├── src ├── constants.ts ├── io │ ├── dailyNotes.ts │ └── weeklyNotes.ts ├── main.ts ├── settings.ts ├── testUtils │ ├── mockApp.ts │ └── settings.ts ├── ui │ ├── Calendar.svelte │ ├── __mocks__ │ │ └── obsidian.ts │ ├── fileMenu.ts │ ├── modal.ts │ ├── sources │ │ ├── index.ts │ │ ├── streak.ts │ │ ├── tags.ts │ │ ├── tasks.ts │ │ └── wordCount.ts │ ├── stores.ts │ └── utils.ts └── view.ts ├── styles.css ├── tsconfig.json ├── versions.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint"], 5 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 6 | rules: { 7 | "@typescript-eslint/no-unused-vars": [ 8 | 2, 9 | { args: "all", argsIgnorePattern: "^_" }, 10 | ], 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [liamcain] 2 | custom: ["https://paypal.me/hiliam", "https://buymeacoffee.com/liamcain"] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug, needs-review 6 | assignees: "" 7 | --- 8 | 9 | > **Before Submitting:** Double-check that you are running the latest version of the plugin. The bug might have already been fixed 😄 10 | 11 | ### Describe the bug 12 | 13 | _A clear and concise description of what the bug is._ 14 | 15 | ### Steps to reproduce 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. See error 20 | 21 | ### Expected behavior 22 | 23 | _A clear and concise description of what you expected to happen_. 24 | 25 | ### Screenshots 26 | 27 | _If applicable, add screenshots to help explain your problem._ 28 | 29 | ### Environment (please specify) 30 | 31 | #### OS 32 | 33 | [Windows/macOS/Linux] 34 | 35 | #### Obsidian Version (e.g. v0.10.6) 36 | 37 | `(Settings → About → Current Version)` 38 | 39 | ### Theme (if applicable): 40 | 41 | _If the bug is visual, please provide the name of the Community Theme you're using._ 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | > **Before Submitting:** Double-check that you are running the latest version of the plugin. The feature might have already been included 😄 10 | 11 | **Is your feature request related to a problem? Please describe.** 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Describe alternatives you've considered** 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 20 | **Additional context** 21 | Add any other context or screenshots about the feature request here. 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | lint-and-test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Install modules 16 | run: yarn 17 | 18 | - name: Lint 19 | run: yarn run lint 20 | 21 | - name: Run tests 22 | run: yarn run test -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: calendar 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: "14.x" # You might need to adjust this value to your own version 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install -g yarn 26 | yarn 27 | yarn run build --if-present 28 | mkdir ${{ env.PLUGIN_NAME }} 29 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 30 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 31 | ls 32 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | - name: Upload zip file 45 | id: upload-zip 46 | uses: actions/upload-release-asset@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | upload_url: ${{ steps.create_release.outputs.upload_url }} 51 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 52 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 53 | asset_content_type: application/zip 54 | - name: Upload main.js 55 | id: upload-main 56 | uses: actions/upload-release-asset@v1 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | with: 60 | upload_url: ${{ steps.create_release.outputs.upload_url }} 61 | asset_path: ./main.js 62 | asset_name: main.js 63 | asset_content_type: text/javascript 64 | - name: Upload manifest.json 65 | id: upload-manifest 66 | uses: actions/upload-release-asset@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | upload_url: ${{ steps.create_release.outputs.upload_url }} 71 | asset_path: ./manifest.json 72 | asset_name: manifest.json 73 | asset_content_type: application/json 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | data.json 4 | main.js 5 | .vscode/ 6 | *.code-workspace -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "svelteSortOrder": "scripts-markup-styles", 3 | "svelteBracketNewLine": true, 4 | "svelteAllowShorthand": true, 5 | "svelteIndentScriptAndStyle": true 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Liam Cain 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # obsidian-calendar-plugin 2 | 3 | This plugin for [Obsidian](https://obsidian.md/) creates a simple Calendar view for visualizing and navigating between your daily notes. 4 | 5 | ![screenshot-full](https://raw.githubusercontent.com/liamcain/obsidian-calendar-plugin/master/images/screenshot-full.png) 6 | 7 | ## Usage 8 | 9 | After enabling the plugin in the settings menu, you should see the calendar view appear in the right sidebar. 10 | 11 | The plugin reads your Daily Note settings to know your date format, your daily note template location, and the location for new daily notes it creates. 12 | 13 | ## Features 14 | 15 | - Go to any **daily note**. 16 | - Create new daily notes for days that don't have one. (This is helpful for when you need to backfill old notes or if you're planning ahead for future notes! This will use your current **daily note** template!) 17 | - Visualize your writing. Each day includes a meter to approximate how much you've written that day. 18 | - Use **Weekly notes** for an added organization layer! They work just like daily notes, but have their own customization options. 19 | 20 | ## Settings 21 | 22 | - **Start week on [default: locale]**: Configure the Calendar view to show Sunday or Monday as the first day of the week. Choosing 'locale' will set the start day to be whatever is the default for your chosen locale (`Settings > About > Language`) 23 | - **Words per Dot [default: 250]**: Starting in version 1.3, dots reflect the word count of your files. By default, each dot represents 250 words, you can change that value to whatever you want. Set this to `0` to disable the word count entirely. **Note:** There is a max of 5 dots so that the view doesn't get too big! 24 | - **Confirm before creating new note [default: on]**: If you don't like that a modal prompts you before creating a new daily note, you can turn it off. 25 | - **Show Week Number [default: off]**: Enable this to add a new column to the calendar view showing the [Week Number](https://en.wikipedia.org/wiki/Week#Week_numbering). Clicking on these cells will open your **weekly note**. 26 | 27 | ## Customization 28 | 29 | The following CSS Variables can be overridden in your `obsidian.css` file. 30 | 31 | ```css 32 | /* obsidian-calendar-plugin */ 33 | /* https://github.com/liamcain/obsidian-calendar-plugin */ 34 | 35 | #calendar-container { 36 | --color-background-heading: transparent; 37 | --color-background-day: transparent; 38 | --color-background-weeknum: transparent; 39 | --color-background-weekend: transparent; 40 | 41 | --color-dot: var(--text-muted); 42 | --color-arrow: var(--text-muted); 43 | --color-button: var(--text-muted); 44 | 45 | --color-text-title: var(--text-normal); 46 | --color-text-heading: var(--text-muted); 47 | --color-text-day: var(--text-normal); 48 | --color-text-today: var(--interactive-accent); 49 | --color-text-weeknum: var(--text-muted); 50 | } 51 | ``` 52 | 53 | In addition to the CSS Variables, there are some classes you can override for further customization. For example, if you don't like how bright the title is, you can override it with: 54 | 55 | ```css 56 | #calendar-container .year { 57 | color: var(--text-normal); 58 | } 59 | ``` 60 | 61 | > **Note:** It's especially important when overriding the classes to prefix them with `#calendar-container` to avoid any unexpected changes within Obsidian! 62 | 63 | ### Caution to Theme Creators 64 | 65 | If you use "Inspect Element" on the calendar, you will notice that the CSS classes are quite illegible. For example: `.task.svelte-1lgyrog.svelte-1lgyrog`. What's going on here? The classes that begin with `svelte-` are autogenerated and are used to avoid the calendar styles affecting any other elements in the app. That being said: **ignore them!** Those CSS classes are likely to change from release to release, and your overrides _will_ break. Just target the human-readable part of the class names. So to override `task.svelte-1lgyrog.svelte-1lgyrog`, you should use `#calendar-container .task { ... }` 66 | 67 | ## Compatibility 68 | 69 | `obsidian-calendar-plugin` currently requires Obsidian v0.9.11 or above to work properly. 70 | 71 | ## Installation 72 | 73 | You can install the plugin via the Community Plugins tab within Obsidian. Just search for "Calendar." 74 | 75 | ## FAQ 76 | 77 | ### What do the dots mean? 78 | 79 | Each solid dot represents 250 words in your daily note. So 4 dots means you've written a thousands words for that day! If you want to change that threshold, you can set a different value for "Words Per Dot" in the Calendar settings. 80 | 81 | The hollow dots, on the other hand, mean that the day has incomplete tasks in it. (**Note:** There will only ever be 1 hollow dot on a particular day, regardless of the number of remaining tasks) 82 | 83 | ### How do I change the styling of the Calendar? 84 | 85 | By default, the calendar should seamlessly match your theme, but if you'd like to further customize it, you can! In your `obsidian.css` file (inside your vault) you can configure the styling to your heart's content. 86 | 87 | ### Can I add week numbers to the calendar? 88 | 89 | In the settings, you can enable "Show Week Numbers" to add a "week number" column to the calendar. Click on the week number to open a "weekly note". 90 | 91 | ### How do I hide the calendar plugin without disabling the plugin? 92 | 93 | Just like other sidebar views (e.g. Backlinks, Outline), the calendar view can be closed by right-clicking on the view icon. 94 | 95 | ![how-to-close](./images/how-to-close.png) 96 | 97 | ### I accidentally closed the calendar. How do I reopen it? 98 | 99 | If you close the calendar widget (right-clicking on the panel nav and clicking close), you can always reopen the view from the Command Palette. Just search for `Calendar: Open view`. 100 | 101 | ![how-to-reopen](./images/how-to-reopen.png) 102 | 103 | ### How do I have the calendar start on Monday? 104 | 105 | From the Settings menu, you can toggle "Start week on Monday". 106 | 107 | ### How do I include "unformatted" words in my weekly note filenames? 108 | 109 | If you want the weekly note format to include a word (e.g. "Week 21 of Year 2020") you can do so by surrounding the words with `[]` brackets. This tells [moment](https://momentjs.com/docs/#/displaying/format/) to ignore the words. So for the example above, you would set your format to `[Week] ww [of Year] gggg`. 110 | 111 | ### I don't like showing the week numbers but I still want to use weekly notes. Can I still use them? 112 | 113 | You can open the current weekly note from the command palette by searching `Calendar: Open weekly Note`. This will open the weekly note for the current week. 114 | 115 | To configure the `format`, `folder`, and `template`, you will temporarily need to toggle on "Show weekly numbers" in the settings, but if you toggle it back off, your settings will persist. 116 | 117 | ## Protips 118 | 119 | ### Embed your entire week in a weekly note 120 | 121 | If you add the following snippet to your weekly note template, you can a seamless view of your week in a single click. 122 | 123 | ```md 124 | ## Week at a Glance 125 | 126 | ![[{{sunday:gggg-MM-DD}}]] 127 | ![[{{monday:gggg-MM-DD}}]] 128 | ![[{{tuesday:gggg-MM-DD}}]] 129 | ![[{{wednesday:gggg-MM-DD}}]] 130 | ![[{{thursday:gggg-MM-DD}}]] 131 | ![[{{friday:gggg-MM-DD}}]] 132 | ![[{{saturday:gggg-MM-DD}}]] 133 | ``` 134 | 135 | ### Hover Preview 136 | 137 | Just like the Obsidian's graph and internal links, the calendar supports page previews for your daily notes. Just hover over a cell while holding down `Ctrl/Cmd` on your keyboard! 138 | 139 | ### The calendar can be moved (and pinned!) anywhere 140 | 141 | Just because the calendar appears in the right sidebar doesn't mean it has to stay there. Feel free to drag it to the left sidebar, or (if you have the screen real estate for it) into the main content area. If you move it out of the sidebar, the view can even be pinned; great for more advanced tile layouts! 142 | 143 | ![how-to-pin](./images/how-to-pin.png) 144 | 145 | ### Open daily notes in a new split 146 | 147 | If you `Ctrl/Command`-Click on a note in your calendar, it will open daily note in a new split. Useful if you want to open a bunch of daily notes in a row (especially if you have the **Sliding Panes** plugin enabled!) 148 | 149 | ### Reveal open note on calendar 150 | 151 | If you open a note from a different month, you might want to see it on the calendar view. To do so, you can run the command `Calendar: Reveal open note` from the command palette. 152 | 153 | ### Add custom styling for weekends 154 | 155 | If you want to style weekends to be distinguishable from weekdays, you can set the `var(--color-background-weekend)` to be any color you want. 156 | 157 | ![how-to-weekend](./images/how-to-weekend.png) 158 | 159 | ### Weekly Notes (deprecated) 160 | 161 | #### Weekly notes have a new home 162 | 163 | The weekly note functionality has been split out into its [very own plugin](https://github.com/liamcain/obsidian-periodic-notes/). In the future, the functionality will be removed from the Calendar plugin; so if you're currently using weekly notes, I encourage you to make the switch. Don't worry, the behavior is functionally identical and will still integrate with the calendar view! 164 | 165 | This split was inspired by the [One Thing Well](https://en.wikipedia.org/wiki/Unix_philosophy) philosophy. Plugins should be as modular. Some users might want weekly notes and have no use for a calendar view. And vice versa. 166 | 167 | If you are currently using weekly notes within the Calendar plugin, the new Periodic Notes plugin will migrate your settings for you automatically. 168 | 169 | ### Usage 170 | 171 | You can open **weekly notes** in 2 ways: searching `Calendar: open weekly note` in the command palette or by clicking on the week number. Weekly notes can be configured from the Calendar settings. There are 3 settings: 172 | 173 | - **Folder:** The folder that your weekly notes go into. It can be the same or different from your daily notes. By default they are placed in your vault root. 174 | - **Template:** Configure a template for weekly notes. Weekly notes have slightly different template tags than daily notes. See here for the list of supported [weekly note template tags](#template-tags). 175 | 176 | > Note: The path here won't autocomplete for you, you'll need to enter the full path. 177 | 178 | - **Format:** The date format for the weekly note filename. Defaults to `"gggg-[W]ww`. If you use `DD` in the week format, this will refer to first day of the week (Sunday or Monday, depending on your settings). 179 | 180 | #### Template Tags 181 | 182 | | Tag | Description | 183 | | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 184 | | `sunday`, `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday`, `sunday` | Because weekly tags refer to main days, you can refer to individual days like this `{{sunday:gggg-MM-DD}}` to automatically insert the date for that particular day. Note, you must specify the date format! | 185 | | `title` | Works the same as the daily note `{{title}}`. It will insert the title of the note | 186 | | `date`, `time` | Works the same as the daily note `{{date}}` and `{{time}}`. It will insert the date and time of the first day of the week. Useful for creating a heading (e.g. `# # {{date:gggg [Week] ww}}`). | 187 | 188 | ## See it in action 189 | 190 | - [Nick Milo provides a nice plugin walkthrough](https://www.youtube.com/watch?v=X61wRmfZU8Y&t=1099s) 191 | - [Santi Younger demos how Calendar + Periodic Notes can be used for weekly review](https://www.youtube.com/watch?v=T9y8JABS9_Q) 192 | - [Filipe Donadio uses the calendar to plan his day](https://www.youtube.com/watch?v=hxf3_dXIcqc) 193 | 194 | ## Say Thanks 🙏 195 | 196 | If you like this plugin and would like to buy me a coffee, you can! 197 | 198 | [BuyMeACoffee](https://www.buymeacoffee.com/liamcain) 199 | 200 | Like my work and want to see more like it? You can sponsor me. 201 | 202 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/liamcain?style=social)](https://github.com/sponsors/liamcain) 203 | -------------------------------------------------------------------------------- /images/how-to-close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcain/obsidian-calendar-plugin/ef3f2696da11aa1d11a272179caea062d6144640/images/how-to-close.png -------------------------------------------------------------------------------- /images/how-to-pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcain/obsidian-calendar-plugin/ef3f2696da11aa1d11a272179caea062d6144640/images/how-to-pin.png -------------------------------------------------------------------------------- /images/how-to-reopen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcain/obsidian-calendar-plugin/ef3f2696da11aa1d11a272179caea062d6144640/images/how-to-reopen.png -------------------------------------------------------------------------------- /images/how-to-weekend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcain/obsidian-calendar-plugin/ef3f2696da11aa1d11a272179caea062d6144640/images/how-to-weekend.png -------------------------------------------------------------------------------- /images/screenshot-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamcain/obsidian-calendar-plugin/ef3f2696da11aa1d11a272179caea062d6144640/images/screenshot-full.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "calendar", 3 | "name": "Calendar", 4 | "description": "Calendar view of your daily notes", 5 | "version": "1.5.10", 6 | "author": "Liam Cain", 7 | "authorUrl": "https://github.com/liamcain/", 8 | "fundingUrl": "https://www.buymeacoffee.com/liamcain", 9 | "isDesktopOnly": false, 10 | "minAppVersion": "0.9.11" 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "calendar", 3 | "version": "1.5.10", 4 | "description": "Calendar view of your daily notes", 5 | "author": "liamcain", 6 | "main": "main.js", 7 | "license": "MIT", 8 | "scripts": { 9 | "lint": "svelte-check && eslint . --ext .ts", 10 | "build": "npm run lint && rollup -c", 11 | "test": "jest --passWithNoTests", 12 | "test:watch": "yarn test -- --watch" 13 | }, 14 | "dependencies": { 15 | "obsidian": "obsidianmd/obsidian-api#master", 16 | "obsidian-calendar-ui": "0.3.12", 17 | "obsidian-daily-notes-interface": "0.9.0", 18 | "svelte": "3.35.0", 19 | "tslib": "2.1.0" 20 | }, 21 | "devDependencies": { 22 | "@rollup/plugin-commonjs": "18.0.0", 23 | "@rollup/plugin-node-resolve": "11.2.1", 24 | "@rollup/plugin-typescript": "8.2.1", 25 | "@tsconfig/svelte": "1.0.10", 26 | "@types/jest": "26.0.22", 27 | "@types/moment": "2.13.0", 28 | "@typescript-eslint/eslint-plugin": "4.20.0", 29 | "@typescript-eslint/parser": "4.20.0", 30 | "eslint": "7.23.0", 31 | "jest": "26.6.3", 32 | "moment": "2.29.1", 33 | "rollup": "2.44.0", 34 | "rollup-plugin-svelte": "7.1.0", 35 | "svelte-check": "1.3.0", 36 | "svelte-jester": "1.3.2", 37 | "svelte-preprocess": "4.7.0", 38 | "ts-jest": "26.5.4", 39 | "typescript": "4.2.3" 40 | }, 41 | "jest": { 42 | "moduleNameMapper": { 43 | "src/(.*)": "/src/$1" 44 | }, 45 | "transform": { 46 | "^.+\\.svelte$": [ 47 | "svelte-jester", 48 | { 49 | "preprocess": true 50 | } 51 | ], 52 | "^.+\\.ts$": "ts-jest" 53 | }, 54 | "moduleFileExtensions": [ 55 | "js", 56 | "ts", 57 | "svelte" 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from "rollup-plugin-svelte"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | import typescript from "@rollup/plugin-typescript"; 5 | import autoPreprocess from "svelte-preprocess"; 6 | import { env } from "process"; 7 | 8 | export default { 9 | input: "src/main.ts", 10 | output: { 11 | format: "cjs", 12 | file: "main.js", 13 | exports: "default", 14 | }, 15 | external: ["obsidian", "fs", "os", "path"], 16 | plugins: [ 17 | svelte({ 18 | emitCss: false, 19 | preprocess: autoPreprocess(), 20 | }), 21 | typescript({ sourceMap: env.env === "DEV" }), 22 | resolve({ 23 | browser: true, 24 | dedupe: ["svelte"], 25 | }), 26 | commonjs({ 27 | include: "node_modules/**", 28 | }), 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_WEEK_FORMAT = "gggg-[W]ww"; 2 | export const DEFAULT_WORDS_PER_DOT = 250; 3 | export const VIEW_TYPE_CALENDAR = "calendar"; 4 | 5 | export const TRIGGER_ON_OPEN = "calendar:open"; 6 | -------------------------------------------------------------------------------- /src/io/dailyNotes.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | import type { TFile } from "obsidian"; 3 | import { 4 | createDailyNote, 5 | getDailyNoteSettings, 6 | } from "obsidian-daily-notes-interface"; 7 | 8 | import type { ISettings } from "src/settings"; 9 | import { createConfirmationDialog } from "src/ui/modal"; 10 | 11 | /** 12 | * Create a Daily Note for a given date. 13 | */ 14 | export async function tryToCreateDailyNote( 15 | date: Moment, 16 | inNewSplit: boolean, 17 | settings: ISettings, 18 | cb?: (newFile: TFile) => void 19 | ): Promise { 20 | const { workspace } = window.app; 21 | const { format } = getDailyNoteSettings(); 22 | const filename = date.format(format); 23 | 24 | const createFile = async () => { 25 | const dailyNote = await createDailyNote(date); 26 | const leaf = inNewSplit 27 | ? workspace.splitActiveLeaf() 28 | : workspace.getUnpinnedLeaf(); 29 | 30 | await leaf.openFile(dailyNote, { active : true }); 31 | cb?.(dailyNote); 32 | }; 33 | 34 | if (settings.shouldConfirmBeforeCreate) { 35 | createConfirmationDialog({ 36 | cta: "Create", 37 | onAccept: createFile, 38 | text: `File ${filename} does not exist. Would you like to create it?`, 39 | title: "New Daily Note", 40 | }); 41 | } else { 42 | await createFile(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/io/weeklyNotes.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | import type { TFile } from "obsidian"; 3 | import { 4 | createWeeklyNote, 5 | getWeeklyNoteSettings, 6 | } from "obsidian-daily-notes-interface"; 7 | 8 | import type { ISettings } from "src/settings"; 9 | import { createConfirmationDialog } from "src/ui/modal"; 10 | 11 | /** 12 | * Create a Weekly Note for a given date. 13 | */ 14 | export async function tryToCreateWeeklyNote( 15 | date: Moment, 16 | inNewSplit: boolean, 17 | settings: ISettings, 18 | cb?: (file: TFile) => void 19 | ): Promise { 20 | const { workspace } = window.app; 21 | const { format } = getWeeklyNoteSettings(); 22 | const filename = date.format(format); 23 | 24 | const createFile = async () => { 25 | const dailyNote = await createWeeklyNote(date); 26 | const leaf = inNewSplit 27 | ? workspace.splitActiveLeaf() 28 | : workspace.getUnpinnedLeaf(); 29 | 30 | await leaf.openFile(dailyNote, { active : true }); 31 | cb?.(dailyNote); 32 | }; 33 | 34 | if (settings.shouldConfirmBeforeCreate) { 35 | createConfirmationDialog({ 36 | cta: "Create", 37 | onAccept: createFile, 38 | text: `File ${filename} does not exist. Would you like to create it?`, 39 | title: "New Weekly Note", 40 | }); 41 | } else { 42 | await createFile(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import type { Moment, WeekSpec } from "moment"; 2 | import { App, Plugin, WorkspaceLeaf } from "obsidian"; 3 | 4 | import { VIEW_TYPE_CALENDAR } from "./constants"; 5 | import { settings } from "./ui/stores"; 6 | import { 7 | appHasPeriodicNotesPluginLoaded, 8 | CalendarSettingsTab, 9 | ISettings, 10 | } from "./settings"; 11 | import CalendarView from "./view"; 12 | 13 | declare global { 14 | interface Window { 15 | app: App; 16 | moment: () => Moment; 17 | _bundledLocaleWeekSpec: WeekSpec; 18 | } 19 | } 20 | 21 | export default class CalendarPlugin extends Plugin { 22 | public options: ISettings; 23 | private view: CalendarView; 24 | 25 | onunload(): void { 26 | this.app.workspace 27 | .getLeavesOfType(VIEW_TYPE_CALENDAR) 28 | .forEach((leaf) => leaf.detach()); 29 | } 30 | 31 | async onload(): Promise { 32 | this.register( 33 | settings.subscribe((value) => { 34 | this.options = value; 35 | }) 36 | ); 37 | 38 | this.registerView( 39 | VIEW_TYPE_CALENDAR, 40 | (leaf: WorkspaceLeaf) => (this.view = new CalendarView(leaf)) 41 | ); 42 | 43 | this.addCommand({ 44 | id: "show-calendar-view", 45 | name: "Open view", 46 | checkCallback: (checking: boolean) => { 47 | if (checking) { 48 | return ( 49 | this.app.workspace.getLeavesOfType(VIEW_TYPE_CALENDAR).length === 0 50 | ); 51 | } 52 | this.initLeaf(); 53 | }, 54 | }); 55 | 56 | this.addCommand({ 57 | id: "open-weekly-note", 58 | name: "Open Weekly Note", 59 | checkCallback: (checking) => { 60 | if (checking) { 61 | return !appHasPeriodicNotesPluginLoaded(); 62 | } 63 | this.view.openOrCreateWeeklyNote(window.moment(), false); 64 | }, 65 | }); 66 | 67 | this.addCommand({ 68 | id: "reveal-active-note", 69 | name: "Reveal active note", 70 | callback: () => this.view.revealActiveNote(), 71 | }); 72 | 73 | await this.loadOptions(); 74 | 75 | this.addSettingTab(new CalendarSettingsTab(this.app, this)); 76 | 77 | if (this.app.workspace.layoutReady) { 78 | this.initLeaf(); 79 | } else { 80 | this.registerEvent( 81 | this.app.workspace.on("layout-ready", this.initLeaf.bind(this)) 82 | ); 83 | } 84 | } 85 | 86 | initLeaf(): void { 87 | if (this.app.workspace.getLeavesOfType(VIEW_TYPE_CALENDAR).length) { 88 | return; 89 | } 90 | this.app.workspace.getRightLeaf(false).setViewState({ 91 | type: VIEW_TYPE_CALENDAR, 92 | }); 93 | } 94 | 95 | async loadOptions(): Promise { 96 | const options = await this.loadData(); 97 | settings.update((old) => { 98 | return { 99 | ...old, 100 | ...(options || {}), 101 | }; 102 | }); 103 | 104 | await this.saveData(this.options); 105 | } 106 | 107 | async writeOptions( 108 | changeOpts: (settings: ISettings) => Partial 109 | ): Promise { 110 | settings.update((old) => ({ ...old, ...changeOpts(old) })); 111 | await this.saveData(this.options); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from "obsidian"; 2 | import { appHasDailyNotesPluginLoaded } from "obsidian-daily-notes-interface"; 3 | import type { ILocaleOverride, IWeekStartOption } from "obsidian-calendar-ui"; 4 | 5 | import { DEFAULT_WEEK_FORMAT, DEFAULT_WORDS_PER_DOT } from "src/constants"; 6 | 7 | import type CalendarPlugin from "./main"; 8 | 9 | export interface ISettings { 10 | wordsPerDot: number; 11 | weekStart: IWeekStartOption; 12 | shouldConfirmBeforeCreate: boolean; 13 | 14 | // Weekly Note settings 15 | showWeeklyNote: boolean; 16 | weeklyNoteFormat: string; 17 | weeklyNoteTemplate: string; 18 | weeklyNoteFolder: string; 19 | 20 | localeOverride: ILocaleOverride; 21 | } 22 | 23 | const weekdays = [ 24 | "sunday", 25 | "monday", 26 | "tuesday", 27 | "wednesday", 28 | "thursday", 29 | "friday", 30 | "saturday", 31 | ]; 32 | 33 | export const defaultSettings = Object.freeze({ 34 | shouldConfirmBeforeCreate: true, 35 | weekStart: "locale" as IWeekStartOption, 36 | 37 | wordsPerDot: DEFAULT_WORDS_PER_DOT, 38 | 39 | showWeeklyNote: false, 40 | weeklyNoteFormat: "", 41 | weeklyNoteTemplate: "", 42 | weeklyNoteFolder: "", 43 | 44 | localeOverride: "system-default", 45 | }); 46 | 47 | export function appHasPeriodicNotesPluginLoaded(): boolean { 48 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 49 | const periodicNotes = (window.app).plugins.getPlugin("periodic-notes"); 50 | return periodicNotes && periodicNotes.settings?.weekly?.enabled; 51 | } 52 | 53 | export class CalendarSettingsTab extends PluginSettingTab { 54 | private plugin: CalendarPlugin; 55 | 56 | constructor(app: App, plugin: CalendarPlugin) { 57 | super(app, plugin); 58 | this.plugin = plugin; 59 | } 60 | 61 | display(): void { 62 | this.containerEl.empty(); 63 | 64 | if (!appHasDailyNotesPluginLoaded()) { 65 | this.containerEl.createDiv("settings-banner", (banner) => { 66 | banner.createEl("h3", { 67 | text: "⚠️ Daily Notes plugin not enabled", 68 | }); 69 | banner.createEl("p", { 70 | cls: "setting-item-description", 71 | text: 72 | "The calendar is best used in conjunction with either the Daily Notes plugin or the Periodic Notes plugin (available in the Community Plugins catalog).", 73 | }); 74 | }); 75 | } 76 | 77 | this.containerEl.createEl("h3", { 78 | text: "General Settings", 79 | }); 80 | this.addDotThresholdSetting(); 81 | this.addWeekStartSetting(); 82 | this.addConfirmCreateSetting(); 83 | this.addShowWeeklyNoteSetting(); 84 | 85 | if ( 86 | this.plugin.options.showWeeklyNote && 87 | !appHasPeriodicNotesPluginLoaded() 88 | ) { 89 | this.containerEl.createEl("h3", { 90 | text: "Weekly Note Settings", 91 | }); 92 | this.containerEl.createEl("p", { 93 | cls: "setting-item-description", 94 | text: 95 | "Note: Weekly Note settings are moving. You are encouraged to install the 'Periodic Notes' plugin to keep the functionality in the future.", 96 | }); 97 | this.addWeeklyNoteFormatSetting(); 98 | this.addWeeklyNoteTemplateSetting(); 99 | this.addWeeklyNoteFolderSetting(); 100 | } 101 | 102 | this.containerEl.createEl("h3", { 103 | text: "Advanced Settings", 104 | }); 105 | this.addLocaleOverrideSetting(); 106 | } 107 | 108 | addDotThresholdSetting(): void { 109 | new Setting(this.containerEl) 110 | .setName("Words per dot") 111 | .setDesc("How many words should be represented by a single dot?") 112 | .addText((textfield) => { 113 | textfield.setPlaceholder(String(DEFAULT_WORDS_PER_DOT)); 114 | textfield.inputEl.type = "number"; 115 | textfield.setValue(String(this.plugin.options.wordsPerDot)); 116 | textfield.onChange(async (value) => { 117 | this.plugin.writeOptions(() => ({ 118 | wordsPerDot: value !== "" ? Number(value) : undefined, 119 | })); 120 | }); 121 | }); 122 | } 123 | 124 | addWeekStartSetting(): void { 125 | const { moment } = window; 126 | 127 | const localizedWeekdays = moment.weekdays(); 128 | const localeWeekStartNum = window._bundledLocaleWeekSpec.dow; 129 | const localeWeekStart = moment.weekdays()[localeWeekStartNum]; 130 | 131 | new Setting(this.containerEl) 132 | .setName("Start week on:") 133 | .setDesc( 134 | "Choose what day of the week to start. Select 'Locale default' to use the default specified by moment.js" 135 | ) 136 | .addDropdown((dropdown) => { 137 | dropdown.addOption("locale", `Locale default (${localeWeekStart})`); 138 | localizedWeekdays.forEach((day, i) => { 139 | dropdown.addOption(weekdays[i], day); 140 | }); 141 | dropdown.setValue(this.plugin.options.weekStart); 142 | dropdown.onChange(async (value) => { 143 | this.plugin.writeOptions(() => ({ 144 | weekStart: value as IWeekStartOption, 145 | })); 146 | }); 147 | }); 148 | } 149 | 150 | addConfirmCreateSetting(): void { 151 | new Setting(this.containerEl) 152 | .setName("Confirm before creating new note") 153 | .setDesc("Show a confirmation modal before creating a new note") 154 | .addToggle((toggle) => { 155 | toggle.setValue(this.plugin.options.shouldConfirmBeforeCreate); 156 | toggle.onChange(async (value) => { 157 | this.plugin.writeOptions(() => ({ 158 | shouldConfirmBeforeCreate: value, 159 | })); 160 | }); 161 | }); 162 | } 163 | 164 | addShowWeeklyNoteSetting(): void { 165 | new Setting(this.containerEl) 166 | .setName("Show week number") 167 | .setDesc("Enable this to add a column with the week number") 168 | .addToggle((toggle) => { 169 | toggle.setValue(this.plugin.options.showWeeklyNote); 170 | toggle.onChange(async (value) => { 171 | this.plugin.writeOptions(() => ({ showWeeklyNote: value })); 172 | this.display(); // show/hide weekly settings 173 | }); 174 | }); 175 | } 176 | 177 | addWeeklyNoteFormatSetting(): void { 178 | new Setting(this.containerEl) 179 | .setName("Weekly note format") 180 | .setDesc("For more syntax help, refer to format reference") 181 | .addText((textfield) => { 182 | textfield.setValue(this.plugin.options.weeklyNoteFormat); 183 | textfield.setPlaceholder(DEFAULT_WEEK_FORMAT); 184 | textfield.onChange(async (value) => { 185 | this.plugin.writeOptions(() => ({ weeklyNoteFormat: value })); 186 | }); 187 | }); 188 | } 189 | 190 | addWeeklyNoteTemplateSetting(): void { 191 | new Setting(this.containerEl) 192 | .setName("Weekly note template") 193 | .setDesc( 194 | "Choose the file you want to use as the template for your weekly notes" 195 | ) 196 | .addText((textfield) => { 197 | textfield.setValue(this.plugin.options.weeklyNoteTemplate); 198 | textfield.onChange(async (value) => { 199 | this.plugin.writeOptions(() => ({ weeklyNoteTemplate: value })); 200 | }); 201 | }); 202 | } 203 | 204 | addWeeklyNoteFolderSetting(): void { 205 | new Setting(this.containerEl) 206 | .setName("Weekly note folder") 207 | .setDesc("New weekly notes will be placed here") 208 | .addText((textfield) => { 209 | textfield.setValue(this.plugin.options.weeklyNoteFolder); 210 | textfield.onChange(async (value) => { 211 | this.plugin.writeOptions(() => ({ weeklyNoteFolder: value })); 212 | }); 213 | }); 214 | } 215 | 216 | addLocaleOverrideSetting(): void { 217 | const { moment } = window; 218 | 219 | const sysLocale = navigator.language?.toLowerCase(); 220 | 221 | new Setting(this.containerEl) 222 | .setName("Override locale:") 223 | .setDesc( 224 | "Set this if you want to use a locale different from the default" 225 | ) 226 | .addDropdown((dropdown) => { 227 | dropdown.addOption("system-default", `Same as system (${sysLocale})`); 228 | moment.locales().forEach((locale) => { 229 | dropdown.addOption(locale, locale); 230 | }); 231 | dropdown.setValue(this.plugin.options.localeOverride); 232 | dropdown.onChange(async (value) => { 233 | this.plugin.writeOptions(() => ({ 234 | localeOverride: value as ILocaleOverride, 235 | })); 236 | }); 237 | }); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/testUtils/mockApp.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "obsidian"; 2 | 3 | /* eslint-disable */ 4 | const mockApp: App = { 5 | vault: { 6 | adapter: { 7 | exists: () => Promise.resolve(false), 8 | getName: () => "", 9 | list: () => Promise.resolve(null), 10 | read: () => Promise.resolve(null), 11 | readBinary: () => Promise.resolve(null), 12 | write: () => Promise.resolve(), 13 | writeBinary: () => Promise.resolve(), 14 | getResourcePath: () => "", 15 | mkdir: () => Promise.resolve(), 16 | trashSystem: () => Promise.resolve(true), 17 | trashLocal: () => Promise.resolve(), 18 | rmdir: () => Promise.resolve(), 19 | remove: () => Promise.resolve(), 20 | rename: () => Promise.resolve(), 21 | copy: () => Promise.resolve(), 22 | }, 23 | configDir: ".obsidian", 24 | getName: () => "", 25 | getAbstractFileByPath: () => null, 26 | getRoot: () => ({ 27 | children: [], 28 | isRoot: () => true, 29 | name: "", 30 | parent: null, 31 | path: "", 32 | vault: null, 33 | }), 34 | create: jest.fn(), 35 | createFolder: () => Promise.resolve(null), 36 | createBinary: () => Promise.resolve(null), 37 | read: () => Promise.resolve(""), 38 | cachedRead: () => Promise.resolve("foo"), 39 | readBinary: () => Promise.resolve(null), 40 | getResourcePath: () => null, 41 | delete: () => Promise.resolve(), 42 | trash: () => Promise.resolve(), 43 | rename: () => Promise.resolve(), 44 | modify: () => Promise.resolve(), 45 | modifyBinary: () => Promise.resolve(), 46 | copy: () => Promise.resolve(null), 47 | getAllLoadedFiles: () => [], 48 | getMarkdownFiles: () => [], 49 | getFiles: () => [], 50 | on: () => null, 51 | off: () => null, 52 | offref: () => null, 53 | tryTrigger: () => null, 54 | trigger: () => null, 55 | }, 56 | workspace: null, 57 | metadataCache: { 58 | getCache: () => null, 59 | getFileCache: () => null, 60 | getFirstLinkpathDest: () => null, 61 | on: () => null, 62 | off: () => null, 63 | offref: () => null, 64 | tryTrigger: () => null, 65 | fileToLinktext: () => "", 66 | trigger: () => null, 67 | resolvedLinks: null, 68 | unresolvedLinks: null, 69 | }, 70 | // @ts-ignore 71 | internalPlugins: { 72 | plugins: { 73 | "daily-notes": { 74 | instance: { 75 | options: { 76 | format: "", 77 | template: "", 78 | folder: "", 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | }; 85 | /* eslint-enable */ 86 | 87 | export default mockApp; 88 | -------------------------------------------------------------------------------- /src/testUtils/settings.ts: -------------------------------------------------------------------------------- 1 | import type { ISettings } from "src/settings"; 2 | 3 | export function getDefaultSettings( 4 | overrides: Partial = {} 5 | ): ISettings { 6 | return Object.assign( 7 | {}, 8 | { 9 | weekStart: "sunday", 10 | shouldConfirmBeforeCreate: false, 11 | wordsPerDot: 50, 12 | showWeeklyNote: false, 13 | weeklyNoteFolder: "", 14 | weeklyNoteFormat: "", 15 | weeklyNoteTemplate: "", 16 | }, 17 | overrides 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/Calendar.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55 | 56 | 70 | -------------------------------------------------------------------------------- /src/ui/__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | export class TFile {} 2 | export class PluginSettingTab {} 3 | export class Modal {} 4 | export class Notice {} 5 | export function normalizePath(): string { 6 | return ""; 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/fileMenu.ts: -------------------------------------------------------------------------------- 1 | import { App, Menu, Point, TFile } from "obsidian"; 2 | 3 | export function showFileMenu(app: App, file: TFile, position: Point): void { 4 | const fileMenu = new Menu(app); 5 | fileMenu.addItem((item) => 6 | item 7 | .setTitle("Delete") 8 | .setIcon("trash") 9 | .onClick(() => { 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | (app).fileManager.promptForFileDeletion(file); 12 | }) 13 | ); 14 | 15 | app.workspace.trigger( 16 | "file-menu", 17 | fileMenu, 18 | file, 19 | "calendar-context-menu", 20 | null 21 | ); 22 | fileMenu.showAtPosition(position); 23 | } 24 | -------------------------------------------------------------------------------- /src/ui/modal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal } from "obsidian"; 2 | 3 | interface IConfirmationDialogParams { 4 | cta: string; 5 | // eslint-disable-next-line 6 | onAccept: (...args: any[]) => Promise; 7 | text: string; 8 | title: string; 9 | } 10 | 11 | export class ConfirmationModal extends Modal { 12 | constructor(app: App, config: IConfirmationDialogParams) { 13 | super(app); 14 | 15 | const { cta, onAccept, text, title } = config; 16 | 17 | this.contentEl.createEl("h2", { text: title }); 18 | this.contentEl.createEl("p", { text }); 19 | 20 | this.contentEl.createDiv("modal-button-container", (buttonsEl) => { 21 | buttonsEl 22 | .createEl("button", { text: "Never mind" }) 23 | .addEventListener("click", () => this.close()); 24 | 25 | buttonsEl 26 | .createEl("button", { 27 | cls: "mod-cta", 28 | text: cta, 29 | }) 30 | .addEventListener("click", async (e) => { 31 | await onAccept(e); 32 | this.close(); 33 | }); 34 | }); 35 | } 36 | } 37 | 38 | export function createConfirmationDialog({ 39 | cta, 40 | onAccept, 41 | text, 42 | title, 43 | }: IConfirmationDialogParams): void { 44 | new ConfirmationModal(window.app, { cta, onAccept, text, title }).open(); 45 | } 46 | -------------------------------------------------------------------------------- /src/ui/sources/index.ts: -------------------------------------------------------------------------------- 1 | export { streakSource } from "./streak"; 2 | export { customTagsSource } from "./tags"; 3 | export { tasksSource } from "./tasks"; 4 | export { wordCountSource } from "./wordCount"; 5 | -------------------------------------------------------------------------------- /src/ui/sources/streak.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | import type { TFile } from "obsidian"; 3 | import type { ICalendarSource, IDayMetadata } from "obsidian-calendar-ui"; 4 | import { getDailyNote, getWeeklyNote } from "obsidian-daily-notes-interface"; 5 | import { get } from "svelte/store"; 6 | 7 | import { dailyNotes, weeklyNotes } from "../stores"; 8 | import { classList } from "../utils"; 9 | 10 | const getStreakClasses = (file: TFile): string[] => { 11 | return classList({ 12 | "has-note": !!file, 13 | }); 14 | }; 15 | 16 | export const streakSource: ICalendarSource = { 17 | getDailyMetadata: async (date: Moment): Promise => { 18 | const file = getDailyNote(date, get(dailyNotes)); 19 | return { 20 | classes: getStreakClasses(file), 21 | dots: [], 22 | }; 23 | }, 24 | 25 | getWeeklyMetadata: async (date: Moment): Promise => { 26 | const file = getWeeklyNote(date, get(weeklyNotes)); 27 | return { 28 | classes: getStreakClasses(file), 29 | dots: [], 30 | }; 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/ui/sources/tags.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | import { parseFrontMatterTags, TFile } from "obsidian"; 3 | import type { ICalendarSource, IDayMetadata } from "obsidian-calendar-ui"; 4 | import { getDailyNote, getWeeklyNote } from "obsidian-daily-notes-interface"; 5 | import { get } from "svelte/store"; 6 | 7 | import { partition } from "src/ui/utils"; 8 | 9 | import { dailyNotes, weeklyNotes } from "../stores"; 10 | 11 | function getNoteTags(note: TFile | null): string[] { 12 | if (!note) { 13 | return []; 14 | } 15 | 16 | const { metadataCache } = window.app; 17 | const frontmatter = metadataCache.getFileCache(note)?.frontmatter; 18 | 19 | const tags = []; 20 | 21 | if (frontmatter) { 22 | const frontmatterTags = parseFrontMatterTags(frontmatter) || []; 23 | tags.push(...frontmatterTags); 24 | } 25 | 26 | // strip the '#' at the beginning 27 | return tags.map((tag) => tag.substring(1)); 28 | } 29 | 30 | function getFormattedTagAttributes(note: TFile | null): Record { 31 | const attrs: Record = {}; 32 | const tags = getNoteTags(note); 33 | 34 | const [emojiTags, nonEmojiTags] = partition(tags, (tag) => 35 | /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/.test( 36 | tag 37 | ) 38 | ); 39 | 40 | if (nonEmojiTags) { 41 | attrs["data-tags"] = nonEmojiTags.join(" "); 42 | } 43 | if (emojiTags) { 44 | attrs["data-emoji-tag"] = emojiTags[0]; 45 | } 46 | 47 | return attrs; 48 | } 49 | 50 | export const customTagsSource: ICalendarSource = { 51 | getDailyMetadata: async (date: Moment): Promise => { 52 | const file = getDailyNote(date, get(dailyNotes)); 53 | return { 54 | dataAttributes: getFormattedTagAttributes(file), 55 | dots: [], 56 | }; 57 | }, 58 | getWeeklyMetadata: async (date: Moment): Promise => { 59 | const file = getWeeklyNote(date, get(weeklyNotes)); 60 | return { 61 | dataAttributes: getFormattedTagAttributes(file), 62 | dots: [], 63 | }; 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/ui/sources/tasks.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | import type { TFile } from "obsidian"; 3 | import type { ICalendarSource, IDayMetadata, IDot } from "obsidian-calendar-ui"; 4 | import { getDailyNote, getWeeklyNote } from "obsidian-daily-notes-interface"; 5 | import { get } from "svelte/store"; 6 | 7 | import { dailyNotes, weeklyNotes } from "../stores"; 8 | 9 | export async function getNumberOfRemainingTasks(note: TFile): Promise { 10 | if (!note) { 11 | return 0; 12 | } 13 | 14 | const { vault } = window.app; 15 | const fileContents = await vault.cachedRead(note); 16 | return (fileContents.match(/(-|\*) \[ \]/g) || []).length; 17 | } 18 | 19 | export async function getDotsForDailyNote( 20 | dailyNote: TFile | null 21 | ): Promise { 22 | if (!dailyNote) { 23 | return []; 24 | } 25 | const numTasks = await getNumberOfRemainingTasks(dailyNote); 26 | 27 | const dots = []; 28 | if (numTasks) { 29 | dots.push({ 30 | className: "task", 31 | color: "default", 32 | isFilled: false, 33 | }); 34 | } 35 | return dots; 36 | } 37 | 38 | export const tasksSource: ICalendarSource = { 39 | getDailyMetadata: async (date: Moment): Promise => { 40 | const file = getDailyNote(date, get(dailyNotes)); 41 | const dots = await getDotsForDailyNote(file); 42 | return { 43 | dots, 44 | }; 45 | }, 46 | 47 | getWeeklyMetadata: async (date: Moment): Promise => { 48 | const file = getWeeklyNote(date, get(weeklyNotes)); 49 | const dots = await getDotsForDailyNote(file); 50 | 51 | return { 52 | dots, 53 | }; 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /src/ui/sources/wordCount.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | import type { TFile } from "obsidian"; 3 | import type { ICalendarSource, IDayMetadata, IDot } from "obsidian-calendar-ui"; 4 | import { getDailyNote, getWeeklyNote } from "obsidian-daily-notes-interface"; 5 | import { get } from "svelte/store"; 6 | 7 | import { DEFAULT_WORDS_PER_DOT } from "src/constants"; 8 | 9 | import { dailyNotes, settings, weeklyNotes } from "../stores"; 10 | import { clamp, getWordCount } from "../utils"; 11 | 12 | const NUM_MAX_DOTS = 5; 13 | 14 | export async function getWordLengthAsDots(note: TFile): Promise { 15 | const { wordsPerDot = DEFAULT_WORDS_PER_DOT } = get(settings); 16 | if (!note || wordsPerDot <= 0) { 17 | return 0; 18 | } 19 | const fileContents = await window.app.vault.cachedRead(note); 20 | 21 | const wordCount = getWordCount(fileContents); 22 | const numDots = wordCount / wordsPerDot; 23 | return clamp(Math.floor(numDots), 1, NUM_MAX_DOTS); 24 | } 25 | 26 | export async function getDotsForDailyNote( 27 | dailyNote: TFile | null 28 | ): Promise { 29 | if (!dailyNote) { 30 | return []; 31 | } 32 | const numSolidDots = await getWordLengthAsDots(dailyNote); 33 | 34 | const dots = []; 35 | for (let i = 0; i < numSolidDots; i++) { 36 | dots.push({ 37 | color: "default", 38 | isFilled: true, 39 | }); 40 | } 41 | return dots; 42 | } 43 | 44 | export const wordCountSource: ICalendarSource = { 45 | getDailyMetadata: async (date: Moment): Promise => { 46 | const file = getDailyNote(date, get(dailyNotes)); 47 | const dots = await getDotsForDailyNote(file); 48 | return { 49 | dots, 50 | }; 51 | }, 52 | 53 | getWeeklyMetadata: async (date: Moment): Promise => { 54 | const file = getWeeklyNote(date, get(weeklyNotes)); 55 | const dots = await getDotsForDailyNote(file); 56 | 57 | return { 58 | dots, 59 | }; 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /src/ui/stores.ts: -------------------------------------------------------------------------------- 1 | import type { TFile } from "obsidian"; 2 | import { 3 | getAllDailyNotes, 4 | getAllWeeklyNotes, 5 | } from "obsidian-daily-notes-interface"; 6 | import { writable } from "svelte/store"; 7 | 8 | import { defaultSettings, ISettings } from "src/settings"; 9 | 10 | import { getDateUIDFromFile } from "./utils"; 11 | 12 | function createDailyNotesStore() { 13 | let hasError = false; 14 | const store = writable>(null); 15 | return { 16 | reindex: () => { 17 | try { 18 | const dailyNotes = getAllDailyNotes(); 19 | store.set(dailyNotes); 20 | hasError = false; 21 | } catch (err) { 22 | if (!hasError) { 23 | // Avoid error being shown multiple times 24 | console.log("[Calendar] Failed to find daily notes folder", err); 25 | } 26 | store.set({}); 27 | hasError = true; 28 | } 29 | }, 30 | ...store, 31 | }; 32 | } 33 | 34 | function createWeeklyNotesStore() { 35 | let hasError = false; 36 | const store = writable>(null); 37 | return { 38 | reindex: () => { 39 | try { 40 | const weeklyNotes = getAllWeeklyNotes(); 41 | store.set(weeklyNotes); 42 | hasError = false; 43 | } catch (err) { 44 | if (!hasError) { 45 | // Avoid error being shown multiple times 46 | console.log("[Calendar] Failed to find weekly notes folder", err); 47 | } 48 | store.set({}); 49 | hasError = true; 50 | } 51 | }, 52 | ...store, 53 | }; 54 | } 55 | 56 | export const settings = writable(defaultSettings); 57 | export const dailyNotes = createDailyNotesStore(); 58 | export const weeklyNotes = createWeeklyNotesStore(); 59 | 60 | function createSelectedFileStore() { 61 | const store = writable(null); 62 | 63 | return { 64 | setFile: (file: TFile) => { 65 | const id = getDateUIDFromFile(file); 66 | store.set(id); 67 | }, 68 | ...store, 69 | }; 70 | } 71 | 72 | export const activeFile = createSelectedFileStore(); 73 | -------------------------------------------------------------------------------- /src/ui/utils.ts: -------------------------------------------------------------------------------- 1 | import type { TFile } from "obsidian"; 2 | import { getDateFromFile, getDateUID } from "obsidian-daily-notes-interface"; 3 | 4 | export const classList = (obj: Record): string[] => { 5 | return Object.entries(obj) 6 | .filter(([_k, v]) => !!v) 7 | .map(([k, _k]) => k); 8 | }; 9 | 10 | export function clamp( 11 | num: number, 12 | lowerBound: number, 13 | upperBound: number 14 | ): number { 15 | return Math.min(Math.max(lowerBound, num), upperBound); 16 | } 17 | 18 | export function partition( 19 | arr: string[], 20 | predicate: (elem: string) => boolean 21 | ): [string[], string[]] { 22 | const pass = []; 23 | const fail = []; 24 | 25 | arr.forEach((elem) => { 26 | if (predicate(elem)) { 27 | pass.push(elem); 28 | } else { 29 | fail.push(elem); 30 | } 31 | }); 32 | 33 | return [pass, fail]; 34 | } 35 | 36 | /** 37 | * Lookup the dateUID for a given file. It compares the filename 38 | * to the daily and weekly note formats to find a match. 39 | * 40 | * @param file 41 | */ 42 | export function getDateUIDFromFile(file: TFile | null): string { 43 | if (!file) { 44 | return null; 45 | } 46 | 47 | // TODO: I'm not checking the path! 48 | let date = getDateFromFile(file, "day"); 49 | if (date) { 50 | return getDateUID(date, "day"); 51 | } 52 | 53 | date = getDateFromFile(file, "week"); 54 | if (date) { 55 | return getDateUID(date, "week"); 56 | } 57 | return null; 58 | } 59 | 60 | export function getWordCount(text: string): number { 61 | const spaceDelimitedChars = /A-Za-z\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0-\u08B4\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7AD\uA7B0-\uA7B7\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC/ 62 | .source; 63 | const nonSpaceDelimitedWords = /\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u4E00-\u9FD5/ 64 | .source; 65 | 66 | const pattern = new RegExp( 67 | [ 68 | `(?:[0-9]+(?:(?:,|\\.)[0-9]+)*|[\\-${spaceDelimitedChars}])+`, 69 | nonSpaceDelimitedWords, 70 | ].join("|"), 71 | "g" 72 | ); 73 | return (text.match(pattern) || []).length; 74 | } 75 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | import { 3 | getDailyNote, 4 | getDailyNoteSettings, 5 | getDateFromFile, 6 | getWeeklyNote, 7 | getWeeklyNoteSettings, 8 | } from "obsidian-daily-notes-interface"; 9 | import { FileView, TFile, ItemView, WorkspaceLeaf } from "obsidian"; 10 | import { get } from "svelte/store"; 11 | 12 | import { TRIGGER_ON_OPEN, VIEW_TYPE_CALENDAR } from "src/constants"; 13 | import { tryToCreateDailyNote } from "src/io/dailyNotes"; 14 | import { tryToCreateWeeklyNote } from "src/io/weeklyNotes"; 15 | import type { ISettings } from "src/settings"; 16 | 17 | import Calendar from "./ui/Calendar.svelte"; 18 | import { showFileMenu } from "./ui/fileMenu"; 19 | import { activeFile, dailyNotes, weeklyNotes, settings } from "./ui/stores"; 20 | import { 21 | customTagsSource, 22 | streakSource, 23 | tasksSource, 24 | wordCountSource, 25 | } from "./ui/sources"; 26 | 27 | export default class CalendarView extends ItemView { 28 | private calendar: Calendar; 29 | private settings: ISettings; 30 | 31 | constructor(leaf: WorkspaceLeaf) { 32 | super(leaf); 33 | 34 | this.openOrCreateDailyNote = this.openOrCreateDailyNote.bind(this); 35 | this.openOrCreateWeeklyNote = this.openOrCreateWeeklyNote.bind(this); 36 | 37 | this.onNoteSettingsUpdate = this.onNoteSettingsUpdate.bind(this); 38 | this.onFileCreated = this.onFileCreated.bind(this); 39 | this.onFileDeleted = this.onFileDeleted.bind(this); 40 | this.onFileModified = this.onFileModified.bind(this); 41 | this.onFileOpen = this.onFileOpen.bind(this); 42 | 43 | this.onHoverDay = this.onHoverDay.bind(this); 44 | this.onHoverWeek = this.onHoverWeek.bind(this); 45 | 46 | this.onContextMenuDay = this.onContextMenuDay.bind(this); 47 | this.onContextMenuWeek = this.onContextMenuWeek.bind(this); 48 | 49 | this.registerEvent( 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | (this.app.workspace).on( 52 | "periodic-notes:settings-updated", 53 | this.onNoteSettingsUpdate 54 | ) 55 | ); 56 | this.registerEvent(this.app.vault.on("create", this.onFileCreated)); 57 | this.registerEvent(this.app.vault.on("delete", this.onFileDeleted)); 58 | this.registerEvent(this.app.vault.on("modify", this.onFileModified)); 59 | this.registerEvent(this.app.workspace.on("file-open", this.onFileOpen)); 60 | 61 | this.settings = null; 62 | settings.subscribe((val) => { 63 | this.settings = val; 64 | 65 | // Refresh the calendar if settings change 66 | if (this.calendar) { 67 | this.calendar.tick(); 68 | } 69 | }); 70 | } 71 | 72 | getViewType(): string { 73 | return VIEW_TYPE_CALENDAR; 74 | } 75 | 76 | getDisplayText(): string { 77 | return "Calendar"; 78 | } 79 | 80 | getIcon(): string { 81 | return "calendar-with-checkmark"; 82 | } 83 | 84 | onClose(): Promise { 85 | if (this.calendar) { 86 | this.calendar.$destroy(); 87 | } 88 | return Promise.resolve(); 89 | } 90 | 91 | async onOpen(): Promise { 92 | // Integration point: external plugins can listen for `calendar:open` 93 | // to feed in additional sources. 94 | const sources = [ 95 | customTagsSource, 96 | streakSource, 97 | wordCountSource, 98 | tasksSource, 99 | ]; 100 | this.app.workspace.trigger(TRIGGER_ON_OPEN, sources); 101 | 102 | this.calendar = new Calendar({ 103 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 104 | target: (this as any).contentEl, 105 | props: { 106 | onClickDay: this.openOrCreateDailyNote, 107 | onClickWeek: this.openOrCreateWeeklyNote, 108 | onHoverDay: this.onHoverDay, 109 | onHoverWeek: this.onHoverWeek, 110 | onContextMenuDay: this.onContextMenuDay, 111 | onContextMenuWeek: this.onContextMenuWeek, 112 | sources, 113 | }, 114 | }); 115 | } 116 | 117 | onHoverDay( 118 | date: Moment, 119 | targetEl: EventTarget, 120 | isMetaPressed: boolean 121 | ): void { 122 | if (!isMetaPressed) { 123 | return; 124 | } 125 | const { format } = getDailyNoteSettings(); 126 | const note = getDailyNote(date, get(dailyNotes)); 127 | this.app.workspace.trigger( 128 | "link-hover", 129 | this, 130 | targetEl, 131 | date.format(format), 132 | note?.path 133 | ); 134 | } 135 | 136 | onHoverWeek( 137 | date: Moment, 138 | targetEl: EventTarget, 139 | isMetaPressed: boolean 140 | ): void { 141 | if (!isMetaPressed) { 142 | return; 143 | } 144 | const note = getWeeklyNote(date, get(weeklyNotes)); 145 | const { format } = getWeeklyNoteSettings(); 146 | this.app.workspace.trigger( 147 | "link-hover", 148 | this, 149 | targetEl, 150 | date.format(format), 151 | note?.path 152 | ); 153 | } 154 | 155 | private onContextMenuDay(date: Moment, event: MouseEvent): void { 156 | const note = getDailyNote(date, get(dailyNotes)); 157 | if (!note) { 158 | // If no file exists for a given day, show nothing. 159 | return; 160 | } 161 | showFileMenu(this.app, note, { 162 | x: event.pageX, 163 | y: event.pageY, 164 | }); 165 | } 166 | 167 | private onContextMenuWeek(date: Moment, event: MouseEvent): void { 168 | const note = getWeeklyNote(date, get(weeklyNotes)); 169 | if (!note) { 170 | // If no file exists for a given day, show nothing. 171 | return; 172 | } 173 | showFileMenu(this.app, note, { 174 | x: event.pageX, 175 | y: event.pageY, 176 | }); 177 | } 178 | 179 | private onNoteSettingsUpdate(): void { 180 | dailyNotes.reindex(); 181 | weeklyNotes.reindex(); 182 | this.updateActiveFile(); 183 | } 184 | 185 | private async onFileDeleted(file: TFile): Promise { 186 | if (getDateFromFile(file, "day")) { 187 | dailyNotes.reindex(); 188 | this.updateActiveFile(); 189 | } 190 | if (getDateFromFile(file, "week")) { 191 | weeklyNotes.reindex(); 192 | this.updateActiveFile(); 193 | } 194 | } 195 | 196 | private async onFileModified(file: TFile): Promise { 197 | const date = getDateFromFile(file, "day") || getDateFromFile(file, "week"); 198 | if (date && this.calendar) { 199 | this.calendar.tick(); 200 | } 201 | } 202 | 203 | private onFileCreated(file: TFile): void { 204 | if (this.app.workspace.layoutReady && this.calendar) { 205 | if (getDateFromFile(file, "day")) { 206 | dailyNotes.reindex(); 207 | this.calendar.tick(); 208 | } 209 | if (getDateFromFile(file, "week")) { 210 | weeklyNotes.reindex(); 211 | this.calendar.tick(); 212 | } 213 | } 214 | } 215 | 216 | public onFileOpen(_file: TFile): void { 217 | if (this.app.workspace.layoutReady) { 218 | this.updateActiveFile(); 219 | } 220 | } 221 | 222 | private updateActiveFile(): void { 223 | const { view } = this.app.workspace.activeLeaf; 224 | 225 | let file = null; 226 | if (view instanceof FileView) { 227 | file = view.file; 228 | } 229 | activeFile.setFile(file); 230 | 231 | if (this.calendar) { 232 | this.calendar.tick(); 233 | } 234 | } 235 | 236 | public revealActiveNote(): void { 237 | const { moment } = window; 238 | const { activeLeaf } = this.app.workspace; 239 | 240 | if (activeLeaf.view instanceof FileView) { 241 | // Check to see if the active note is a daily-note 242 | let date = getDateFromFile(activeLeaf.view.file, "day"); 243 | if (date) { 244 | this.calendar.$set({ displayedMonth: date }); 245 | return; 246 | } 247 | 248 | // Check to see if the active note is a weekly-note 249 | const { format } = getWeeklyNoteSettings(); 250 | date = moment(activeLeaf.view.file.basename, format, true); 251 | if (date.isValid()) { 252 | this.calendar.$set({ displayedMonth: date }); 253 | return; 254 | } 255 | } 256 | } 257 | 258 | async openOrCreateWeeklyNote( 259 | date: Moment, 260 | inNewSplit: boolean 261 | ): Promise { 262 | const { workspace } = this.app; 263 | 264 | const startOfWeek = date.clone().startOf("week"); 265 | 266 | const existingFile = getWeeklyNote(date, get(weeklyNotes)); 267 | 268 | if (!existingFile) { 269 | // File doesn't exist 270 | tryToCreateWeeklyNote(startOfWeek, inNewSplit, this.settings, (file) => { 271 | activeFile.setFile(file); 272 | }); 273 | return; 274 | } 275 | 276 | const leaf = inNewSplit 277 | ? workspace.splitActiveLeaf() 278 | : workspace.getUnpinnedLeaf(); 279 | await leaf.openFile(existingFile); 280 | 281 | activeFile.setFile(existingFile); 282 | workspace.setActiveLeaf(leaf, true, true) 283 | } 284 | 285 | async openOrCreateDailyNote( 286 | date: Moment, 287 | inNewSplit: boolean 288 | ): Promise { 289 | const { workspace } = this.app; 290 | const existingFile = getDailyNote(date, get(dailyNotes)); 291 | if (!existingFile) { 292 | // File doesn't exist 293 | tryToCreateDailyNote( 294 | date, 295 | inNewSplit, 296 | this.settings, 297 | (dailyNote: TFile) => { 298 | activeFile.setFile(dailyNote); 299 | } 300 | ); 301 | return; 302 | } 303 | 304 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 305 | const mode = (this.app.vault as any).getConfig("defaultViewMode"); 306 | const leaf = inNewSplit 307 | ? workspace.splitActiveLeaf() 308 | : workspace.getUnpinnedLeaf(); 309 | await leaf.openFile(existingFile, { active : true, mode }); 310 | 311 | activeFile.setFile(existingFile); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .settings-banner { 2 | background-color: var(--background-secondary-alt); 3 | border-radius: 4px; 4 | margin-bottom: 2em; 5 | padding: 1.5em; 6 | text-align: left; 7 | } 8 | 9 | .settings-banner h4 { 10 | margin: 0; 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | 4 | "include": ["src/**/*"], 5 | "exclude": ["node_modules/*"], 6 | "compilerOptions": { 7 | "types": ["node", "svelte", "jest"], 8 | "baseUrl": ".", 9 | "paths": { 10 | "src": ["src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.5.9": "0.9.11", 3 | "1.5.10": "0.11.0" 4 | } 5 | --------------------------------------------------------------------------------