├── popper.d.ts ├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── src ├── context.js ├── components │ ├── MetadataResolver.svelte │ ├── Dot.svelte │ ├── Dots.svelte │ ├── popover │ │ ├── PopoverMenu.svelte │ │ ├── Popper.svelte │ │ └── Box.svelte │ ├── Arrow.svelte │ ├── Nav.svelte │ ├── WeekNum.svelte │ ├── Month.svelte │ ├── Day.svelte │ └── Calendar.svelte ├── index.ts ├── __mocks__ │ └── obsidian.ts ├── types.ts ├── utils.ts ├── testUtils │ └── mockApp.ts ├── localization.ts ├── __tests__ │ └── utils.spec.ts └── fileStore.ts ├── .prettierrc ├── tsconfig.json ├── .eslintrc.js ├── rollup.config.js ├── LICENSE ├── package.json ├── index.d.ts └── README.md /popper.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@popperjs/svelte"; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | data.json 4 | main.js 5 | *.code-workspace -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [liamcain] 2 | custom: ["https://paypal.me/hiliam", "https://buymeacoffee.com/liamcain"] 3 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | export const IS_MOBILE = Symbol("isMobile"); 2 | export const DISPLAYED_MONTH = Symbol("displayedMonth"); 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "svelteSortOrder": "scripts-markup-styles", 3 | "svelteStrictMode": true, 4 | "svelteBracketNewLine": true, 5 | "svelteAllowShorthand": true, 6 | "svelteIndentScriptAndStyle": true 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | 4 | "include": ["src/**/*", "popper.d.ts"], 5 | "exclude": ["node_modules/*"], 6 | "compilerOptions": { 7 | "types": ["node", "jest", "svelte"], 8 | "baseUrl": ".", 9 | "paths": { 10 | "src": ["src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.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/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 23 | -------------------------------------------------------------------------------- /src/components/MetadataResolver.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | {#if metadata} 10 | {#await metadata} 11 | 12 | {:then resolvedMeta} 13 | 14 | {/await} 15 | {:else} 16 | 17 | {/if} 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type moment from "moment"; 2 | import type { App } from "obsidian"; 3 | 4 | import Calendar from "./components/Calendar.svelte"; 5 | import type { 6 | ICalendarSource, 7 | IDot, 8 | IDayMetadata, 9 | ISourceSettings, 10 | } from "./types"; 11 | 12 | declare global { 13 | interface Window { 14 | app: App; 15 | moment: typeof moment; 16 | } 17 | } 18 | 19 | export { Calendar }; 20 | export type { ICalendarSource, IDot, IDayMetadata, ISourceSettings }; 21 | export { configureGlobalMomentLocale } from "./localization"; 22 | -------------------------------------------------------------------------------- /src/components/Dot.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 20 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /src/__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | export class TAbstractFile {} 2 | 3 | export class TFile extends TAbstractFile { 4 | public basename: string; 5 | public path: string; 6 | } 7 | 8 | export class TFolder extends TAbstractFile { 9 | public children: TAbstractFile[]; 10 | public path: string; 11 | } 12 | 13 | export class PluginSettingTab {} 14 | export class Modal {} 15 | export class Notice {} 16 | export function normalizePath(notePath: string): string { 17 | if (!notePath.startsWith("/")) { 18 | return `/${notePath}`; 19 | } 20 | return notePath; 21 | } 22 | export class Vault { 23 | static recurseChildren(folder: TFolder, cb: (file: TFile) => void): void { 24 | folder.children.forEach((file) => { 25 | if (file instanceof TFile) { 26 | cb(file); 27 | } else if (file instanceof TFolder) { 28 | Vault.recurseChildren(file, cb); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Dots.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 | {#if metadata} 17 | {#each sortedMeta as { color, display, dots = [] }} 18 | {#if display === "calendar-and-menu"} 19 | {#each dots.slice(0, MAX_DOTS_PER_SOURCE) as dot} 20 | 21 | {/each} 22 | {/if} 23 | {/each} 24 | {/if} 25 |
26 | 27 | 39 | -------------------------------------------------------------------------------- /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 | 7 | import pkg from "./package.json"; 8 | 9 | export default { 10 | input: "src/index.ts", 11 | output: [ 12 | { 13 | file: pkg.module, 14 | format: "es", 15 | name: "obsidian-calendar-ui", 16 | globals: "obsidian", 17 | }, 18 | { 19 | file: pkg.main, 20 | format: "umd", 21 | name: "obsidian-calendar-ui", 22 | globals: "obsidian", 23 | }, 24 | ], 25 | external: ["obsidian"], 26 | plugins: [ 27 | svelte({ 28 | emitCss: false, 29 | preprocess: autoPreprocess(), 30 | }), 31 | typescript(), 32 | resolve({ 33 | browser: true, 34 | dedupe: ["svelte"], 35 | }), 36 | commonjs({ 37 | include: /node_modules/, 38 | }), 39 | ], 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/popover/PopoverMenu.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | {#if isMobile} 25 | 26 | {:else} 27 | 28 | 29 | 30 | 31 | 32 | {/if} 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/components/Arrow.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
17 | 27 |
28 | 29 | 52 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | import type { TFile } from "obsidian"; 3 | import type { IGranularity } from "obsidian-daily-notes-interface"; 4 | 5 | export interface IDot { 6 | isFilled: boolean; 7 | } 8 | 9 | export interface IWeek { 10 | days: Moment[]; 11 | weekNum: number; 12 | } 13 | 14 | export type IMonth = IWeek[]; 15 | 16 | export type IHTMLAttributes = Record; 17 | 18 | export interface IEvaluatedMetadata { 19 | value: number | string; 20 | goal?: number; 21 | dots: IDot[]; 22 | attrs?: IHTMLAttributes; 23 | } 24 | 25 | export type ISourceDisplayOption = "calendar-and-menu" | "menu" | "none"; 26 | 27 | export interface ISourceSettings { 28 | color: string; 29 | display: ISourceDisplayOption; 30 | order: number; 31 | } 32 | 33 | export interface IDayMetadata 34 | extends ICalendarSource, 35 | ISourceSettings, 36 | IEvaluatedMetadata {} 37 | 38 | export interface ICalendarSource { 39 | id: string; 40 | name: string; 41 | description?: string; 42 | 43 | getMetadata?: ( 44 | granularity: IGranularity, 45 | date: Moment, 46 | file: TFile 47 | ) => Promise; 48 | 49 | defaultSettings: Record; 50 | registerSettings?: ( 51 | containerEl: HTMLElement, 52 | settings: ISourceSettings, 53 | saveSettings: (settings: Partial) => void 54 | ) => void; 55 | } 56 | -------------------------------------------------------------------------------- /src/components/popover/Popper.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 34 |
41 | 42 |
43 |
44 | 45 | 59 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | 3 | import type { IMonth, IWeek } from "./types"; 4 | 5 | function isMacOS() { 6 | return navigator.appVersion.indexOf("Mac") !== -1; 7 | } 8 | 9 | export function isMetaPressed(e: MouseEvent): boolean { 10 | return isMacOS() ? e.metaKey : e.ctrlKey; 11 | } 12 | 13 | export function getDaysOfWeek(..._args: unknown[]): string[] { 14 | return window.moment.weekdaysShort(true); 15 | } 16 | 17 | export function isWeekend(date: Moment): boolean { 18 | return date.isoWeekday() === 6 || date.isoWeekday() === 7; 19 | } 20 | 21 | export function getStartOfWeek(days: Moment[]): Moment { 22 | return days[0].weekday(0); 23 | } 24 | 25 | /** 26 | * Generate a 2D array of daily information to power 27 | * the calendar view. 28 | */ 29 | export function getMonth(displayedMonth: Moment, ..._args: unknown[]): IMonth { 30 | const locale = window.moment().locale(); 31 | const month = []; 32 | let week: IWeek; 33 | 34 | const startOfMonth = displayedMonth.clone().locale(locale).date(1); 35 | const startOffset = startOfMonth.weekday(); 36 | let date: Moment = startOfMonth.clone().subtract(startOffset, "days"); 37 | 38 | for (let _day = 0; _day < 42; _day++) { 39 | if (_day % 7 === 0) { 40 | week = { 41 | days: [], 42 | weekNum: date.week(), 43 | }; 44 | month.push(week); 45 | } 46 | 47 | week.days.push(date); 48 | date = date.clone().add(1, "days"); 49 | } 50 | 51 | return month; 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-calendar-ui", 3 | "version": "0.4.0", 4 | "description": "Calendar UI that powers obsidian-calendar-plugin", 5 | "author": "liamcain", 6 | "main": "dist/index.js", 7 | "module": "dist/index.mjs", 8 | "types": "index.d.ts", 9 | "license": "MIT", 10 | "scripts": { 11 | "lint": "svelte-check && eslint . --ext .ts", 12 | "build": "npm run lint && rollup -c", 13 | "build:nolint": "rollup -c", 14 | "test": "jest", 15 | "test:watch": "yarn test -- --watch" 16 | }, 17 | "dependencies": { 18 | "@popperjs/core": "2.9.2", 19 | "@popperjs/svelte": "0.1.1", 20 | "obsidian-daily-notes-interface": "0.9.2", 21 | "svelte": "3.37.0", 22 | "svelte-portal": "2.1.2", 23 | "tslib": "2.2.0" 24 | }, 25 | "devDependencies": { 26 | "@rollup/plugin-commonjs": "18.0.0", 27 | "@rollup/plugin-node-resolve": "11.2.1", 28 | "@rollup/plugin-typescript": "8.2.1", 29 | "@tsconfig/svelte": "1.0.10", 30 | "@types/jest": "26.0.22", 31 | "@types/moment": "2.13.0", 32 | "@typescript-eslint/eslint-plugin": "4.21.0", 33 | "@typescript-eslint/parser": "4.21.0", 34 | "eslint": "7.24.0", 35 | "jest": "26.6.3", 36 | "moment": "2.29.1", 37 | "obsidian": "obsidianmd/obsidian-api#master", 38 | "rollup": "2.45.1", 39 | "rollup-plugin-svelte": "7.1.0", 40 | "svelte-check": "1.4.0", 41 | "svelte-preprocess": "4.7.0", 42 | "ts-jest": "26.5.4", 43 | "typescript": "4.2.4" 44 | }, 45 | "jest": { 46 | "clearMocks": true, 47 | "moduleNameMapper": { 48 | "src/(.*)": "/src/$1" 49 | }, 50 | "transform": { 51 | "^.+\\.ts$": "ts-jest" 52 | }, 53 | "moduleFileExtensions": [ 54 | "js", 55 | "ts" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/testUtils/mockApp.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "obsidian"; 2 | 3 | /* eslint-disable */ 4 | const mockApp: App = { 5 | vault: { 6 | configDir: "", 7 | adapter: { 8 | exists: () => Promise.resolve(false), 9 | getName: () => "", 10 | list: () => Promise.resolve(null), 11 | read: () => Promise.resolve(null), 12 | readBinary: () => Promise.resolve(null), 13 | write: () => Promise.resolve(), 14 | writeBinary: () => Promise.resolve(), 15 | getResourcePath: () => "", 16 | mkdir: () => Promise.resolve(), 17 | trashSystem: () => Promise.resolve(true), 18 | trashLocal: () => Promise.resolve(), 19 | rmdir: () => Promise.resolve(), 20 | remove: () => Promise.resolve(), 21 | rename: () => Promise.resolve(), 22 | copy: () => Promise.resolve(), 23 | }, 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/components/Nav.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 | 66 | 67 | 96 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Locale, Moment } from "moment"; 2 | import type { Plugin, TFile } from "obsidian"; 3 | import type { IGranularity } from "obsidian-daily-notes-interface"; 4 | import { SvelteComponentTyped } from "svelte"; 5 | 6 | export type ILocaleOverride = "system-default" | string; 7 | export type IWeekStartOption = 8 | | "sunday" 9 | | "monday" 10 | | "tuesday" 11 | | "wednesday" 12 | | "thursday" 13 | | "friday" 14 | | "saturday" 15 | | "locale"; 16 | 17 | export interface IDot { 18 | isFilled: boolean; 19 | } 20 | 21 | export interface IEvaluatedMetadata { 22 | value: number | string; 23 | goal?: number; 24 | dots: IDot[]; 25 | } 26 | 27 | export type ISourceDisplayOption = "calendar-and-menu" | "menu" | "none"; 28 | 29 | export interface ISourceSettings { 30 | color: string; 31 | display: ISourceDisplayOption; 32 | order: number; 33 | } 34 | 35 | export interface IDayMetadata 36 | extends ICalendarSource, 37 | ISourceSettings, 38 | IEvaluatedMetadata {} 39 | 40 | export interface ICalendarSource { 41 | id: string; 42 | name: string; 43 | description?: string; 44 | 45 | getMetadata?: ( 46 | granularity: IGranularity, 47 | date: Moment, 48 | file: TFile 49 | ) => Promise; 50 | 51 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 52 | defaultSettings: any; 53 | registerSettings?: ( 54 | containerEl: HTMLElement, 55 | settings: ISourceSettings, 56 | saveSettings: (settings: Partial) => void 57 | ) => void; 58 | } 59 | 60 | export type IHTMLAttributes = Record; 61 | 62 | export interface IEvaluatedMetadata { 63 | value: number; 64 | goal?: number; 65 | dots: IDot[]; 66 | attrs?: IHTMLAttributes; 67 | } 68 | 69 | export interface ISourceSettings { 70 | color: string; 71 | display: ISourceDisplayOption; 72 | order: number; 73 | } 74 | 75 | export interface IDayMetadata 76 | extends ICalendarSource, 77 | ISourceSettings, 78 | IEvaluatedMetadata {} 79 | 80 | export class Calendar extends SvelteComponentTyped<{ 81 | plugin: Plugin; 82 | showWeekNums: boolean; 83 | localeData?: Locale; 84 | eventHandlers: CallableFunction[]; 85 | 86 | // External sources 87 | selectedId?: string | null; 88 | sources?: ICalendarSource[]; 89 | getSourceSettings: (sourceId: string) => ISourceSettings; 90 | 91 | // Override-able local state 92 | today?: Moment; 93 | displayedMonth?: Moment; 94 | }> {} 95 | 96 | export function configureGlobalMomentLocale( 97 | localeOverride: ILocaleOverride, 98 | weekStart: IWeekStartOption 99 | ): string; 100 | -------------------------------------------------------------------------------- /src/localization.ts: -------------------------------------------------------------------------------- 1 | import type { WeekSpec } from "moment"; 2 | 3 | declare global { 4 | interface Window { 5 | _bundledLocaleWeekSpec: WeekSpec; 6 | } 7 | } 8 | 9 | export type ILocaleOverride = "system-default" | string; 10 | export type IWeekStartOption = 11 | | "sunday" 12 | | "monday" 13 | | "tuesday" 14 | | "wednesday" 15 | | "thursday" 16 | | "friday" 17 | | "saturday" 18 | | "locale"; 19 | 20 | const langToMomentLocale = { 21 | en: "en-gb", 22 | zh: "zh-cn", 23 | "zh-TW": "zh-tw", 24 | ru: "ru", 25 | ko: "ko", 26 | it: "it", 27 | id: "id", 28 | ro: "ro", 29 | "pt-BR": "pt-br", 30 | cz: "cs", 31 | da: "da", 32 | de: "de", 33 | es: "es", 34 | fr: "fr", 35 | no: "nn", 36 | pl: "pl", 37 | pt: "pt", 38 | tr: "tr", 39 | hi: "hi", 40 | nl: "nl", 41 | ar: "ar", 42 | ja: "ja", 43 | }; 44 | 45 | const weekdays = [ 46 | "sunday", 47 | "monday", 48 | "tuesday", 49 | "wednesday", 50 | "thursday", 51 | "friday", 52 | "saturday", 53 | ]; 54 | 55 | function overrideGlobalMomentWeekStart(weekStart: IWeekStartOption): void { 56 | const { moment } = window; 57 | const currentLocale = moment.locale(); 58 | 59 | // Save the initial locale weekspec so that we can restore 60 | // it when toggling between the different options in settings. 61 | if (!window._bundledLocaleWeekSpec) { 62 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 63 | window._bundledLocaleWeekSpec = (moment.localeData())._week; 64 | } 65 | 66 | if (weekStart === "locale") { 67 | moment.updateLocale(currentLocale, { 68 | week: window._bundledLocaleWeekSpec, 69 | }); 70 | } else { 71 | moment.updateLocale(currentLocale, { 72 | week: { 73 | dow: weekdays.indexOf(weekStart) || 0, 74 | }, 75 | }); 76 | } 77 | } 78 | 79 | /** 80 | * Sets the locale used by the calendar. This allows the calendar to 81 | * default to the user's locale (e.g. Start Week on Sunday/Monday/Friday) 82 | * 83 | * @param localeOverride locale string (e.g. "en-US") 84 | */ 85 | export function configureGlobalMomentLocale( 86 | localeOverride: ILocaleOverride = "system-default", 87 | weekStart: IWeekStartOption = "locale" 88 | ): string { 89 | const obsidianLang = localStorage.getItem("language") || "en"; 90 | const systemLang = navigator.language?.toLowerCase(); 91 | 92 | let momentLocale = langToMomentLocale[obsidianLang]; 93 | 94 | if (localeOverride !== "system-default") { 95 | momentLocale = localeOverride; 96 | } else if (systemLang.startsWith(obsidianLang)) { 97 | // If the system locale is more specific (en-gb vs en), use the system locale. 98 | momentLocale = systemLang; 99 | } 100 | 101 | const currentLocale = window.moment.locale(momentLocale); 102 | console.debug( 103 | `[Calendar] Trying to switch Moment.js global locale to ${momentLocale}, got ${currentLocale}` 104 | ); 105 | 106 | overrideGlobalMomentWeekStart(weekStart); 107 | 108 | return currentLocale; 109 | } 110 | -------------------------------------------------------------------------------- /src/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | import mockApp from "../testUtils/mockApp"; 4 | import { getMonth } from "../utils"; 5 | 6 | jest.mock("obsidian"); 7 | 8 | describe("getMonth", () => { 9 | beforeEach(() => { 10 | window.app = mockApp; 11 | window.moment = moment; 12 | }); 13 | 14 | describe("january", () => { 15 | it("creates correct calendar starting on Sunday", () => { 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | (moment.localeData())._week.dow = 0; 18 | 19 | const monthData = getMonth( 20 | moment({ year: 2020, month: 0, day: 1 }), 21 | "sunday" 22 | ); 23 | 24 | expect( 25 | monthData.map((week) => week.days.map((day) => day.date())) 26 | ).toEqual([ 27 | [29, 30, 31, 1, 2, 3, 4], 28 | [5, 6, 7, 8, 9, 10, 11], 29 | [12, 13, 14, 15, 16, 17, 18], 30 | [19, 20, 21, 22, 23, 24, 25], 31 | [26, 27, 28, 29, 30, 31, 1], 32 | [2, 3, 4, 5, 6, 7, 8], 33 | ]); 34 | }); 35 | 36 | it("creates correct calendar starting on Monday", () => { 37 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 | (moment.localeData())._week.dow = 1; 39 | 40 | const monthData = getMonth( 41 | moment({ year: 2020, month: 0, day: 1 }), 42 | "monday" 43 | ); 44 | 45 | expect( 46 | monthData.map((week) => week.days.map((day) => day.date())) 47 | ).toEqual([ 48 | [30, 31, 1, 2, 3, 4, 5], 49 | [6, 7, 8, 9, 10, 11, 12], 50 | [13, 14, 15, 16, 17, 18, 19], 51 | [20, 21, 22, 23, 24, 25, 26], 52 | [27, 28, 29, 30, 31, 1, 2], 53 | [3, 4, 5, 6, 7, 8, 9], 54 | ]); 55 | }); 56 | }); 57 | 58 | describe("february", () => { 59 | it("creates correct calendar starting on Sunday", () => { 60 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 61 | (moment.localeData())._week.dow = 0; 62 | 63 | const monthData = getMonth( 64 | moment({ year: 2020, month: 1, day: 1 }), 65 | "sunday" 66 | ); 67 | 68 | expect( 69 | monthData.map((week) => week.days.map((day) => day.date())) 70 | ).toEqual([ 71 | [26, 27, 28, 29, 30, 31, 1], 72 | [2, 3, 4, 5, 6, 7, 8], 73 | [9, 10, 11, 12, 13, 14, 15], 74 | [16, 17, 18, 19, 20, 21, 22], 75 | [23, 24, 25, 26, 27, 28, 29], 76 | [1, 2, 3, 4, 5, 6, 7], 77 | ]); 78 | }); 79 | 80 | it("creates correct calendar starting on Monday", () => { 81 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 82 | (moment.localeData())._week.dow = 1; 83 | 84 | const monthData = getMonth( 85 | moment({ year: 2020, month: 1, day: 1 }), 86 | "monday" 87 | ); 88 | 89 | expect( 90 | monthData.map((week) => week.days.map((day) => day.date())) 91 | ).toEqual([ 92 | [27, 28, 29, 30, 31, 1, 2], 93 | [3, 4, 5, 6, 7, 8, 9], 94 | [10, 11, 12, 13, 14, 15, 16], 95 | [17, 18, 19, 20, 21, 22, 23], 96 | [24, 25, 26, 27, 28, 29, 1], 97 | [2, 3, 4, 5, 6, 7, 8], 98 | ]); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `obsidian-calendar-ui` 2 | 3 | The UI package that powers the [Obsidian Calendar plugin](https://github.com/liamcain/obsidian-calendar-plugin). 4 | 5 | ## The problem 6 | 7 | You want a drop-in calendar widget for your Obsidian plugin that seamlessly blends with the user's theme, provides localization that matches the user's locale and language settings, and you don't want to learn about all intracasies of week numbers. 8 | 9 | ## The solution 10 | 11 | This package provides an out-of-the-box calendar view for Obsidian plugins. Built using Obsidian's CSS variables, it will match any theme or custom styling that the user has configured. It also syncs with the user's locale, meaning settings like `Week Start`, and `Week number` come preconfigured. Finally, the interface is generic so you can use it for any use case that you might have. 12 | 13 | ## Basic Usage 14 | 15 | ### Standalone Component 16 | 17 | You can render the component anywhere you would like in the DOM by initializing 18 | it with a `target` element. 19 | 20 | ```ts 21 | // const contentEl = ...; 22 | this.calendar = new Calendar({ 23 | target: contentEl, // the HTML element you're attaching it to 24 | props: { 25 | // Settings 26 | showWeekNums: boolean; 27 | 28 | // Localization 29 | localeOverride: ILocaleOverride; 30 | weekStart: IWeekStartOption; 31 | 32 | // Event Handlers 33 | onHoverDay?: (date: Moment, targetEl: EventTarget) => void; 34 | onHoverWeek?: (date: Moment, targetEl: EventTarget) => void; 35 | onClickDay?: (date: Moment, isMetaPressed: boolean) => void; 36 | onClickWeek?: (date: Moment, isMetaPressed: boolean) => void; 37 | onContextMenuDay?: (date: Moment, event: MouseEvent) => boolean; 38 | onContextMenuWeek?: (date: Moment, event: MouseEvent) => boolean; 39 | 40 | // External sources 41 | selectedId?: string | null; 42 | sources?: ICalendarSource[]; 43 | 44 | // Override-able local state 45 | today?: Moment; 46 | displayedMonth?: Moment; 47 | }, 48 | }); 49 | ``` 50 | 51 | ### Within a Svelte component 52 | 53 | If you are building a plugin using Svelte.js, you can also render the calendar as a subcomponent. The calendar plugin uses this approach: 54 | 55 | ```svelte 56 | 71 | ``` 72 | 73 | ## Calendar Sources 74 | 75 | If you want to attach metadata to the calendar (e.g. adding classes or dots to a particular day, you'll need to provide a calendar source. A calendar source has the following interface: 76 | 77 | ```ts 78 | export interface ICalendarSource { 79 | getDailyMetadata?: (date: Moment) => Promise; 80 | getWeeklyMetadata?: (date: Moment) => Promise; 81 | } 82 | 83 | export interface IDayMetadata { 84 | classes?: string[]; 85 | dataAttributes?: Record; 86 | dots?: IDot[]; 87 | } 88 | ``` 89 | 90 | For reference, the calendar plugin has the following Calendar Sources: 91 | 92 | - `tasks`: Creates hollow dots on days with `- [ ]` in them 93 | - `wordCount`: Creates dots based on the wordcount of a daily note 94 | - `tags`: Attaches `[data-tags]` for frontmatter tags within a daily note 95 | 96 | The calendar plugin relies heavily on "Daily Notes" but because this interface is generic, you're plugin doesn't need to. This unlocks the ability to use the calendar UI with any source, be it a flat Markdown file, or Google Calendar. 97 | -------------------------------------------------------------------------------- /src/components/WeekNum.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 76 | 77 | 78 | 79 |
91 | {weekNum} 92 | 93 |
94 |
95 | 96 | 97 | 128 | -------------------------------------------------------------------------------- /src/components/Month.svelte: -------------------------------------------------------------------------------- 1 | 86 | 87 | 88 |
98 | 99 | 100 | {$displayedMonth.format("MMM")} 101 | 102 | 103 | {$displayedMonth.format("YYYY")} 104 | 105 | 106 | {#if metadata} 107 | 108 | {/if} 109 |
110 |
111 | 112 | 130 | -------------------------------------------------------------------------------- /src/components/popover/Box.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |
17 | {#each showcaseItems as showcaseItem} 18 |
19 |
20 | {showcaseItem.value}{#if showcaseItem.goal} 22 | /{showcaseItem.goal} 23 | {/if} 24 |
25 |
26 | 31 | 32 | 33 | {showcaseItem.name} 34 |
35 |
36 | {/each} 37 |
38 |
39 | {#each overflowItems as overflowItem} 40 |
41 |
42 | {overflowItem.value} 43 |
44 | 45 | 50 | 51 |
52 | {overflowItem.name} 53 |
54 |
55 | {/each} 56 |
57 |
58 | 59 | 156 | -------------------------------------------------------------------------------- /src/components/Day.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 107 | 108 | 109 | 110 |
124 | {date.format("D")} 125 | 126 |
127 |
128 | 129 | 130 | 167 | -------------------------------------------------------------------------------- /src/fileStore.ts: -------------------------------------------------------------------------------- 1 | import type { Moment } from "moment"; 2 | import { App, Plugin, TAbstractFile, TFile } from "obsidian"; 3 | import { 4 | getAllDailyNotes, 5 | getAllMonthlyNotes, 6 | getAllWeeklyNotes, 7 | getDateFromFile, 8 | getDateFromPath, 9 | getDateUID, 10 | IGranularity, 11 | } from "obsidian-daily-notes-interface"; 12 | import { get, Writable, writable } from "svelte/store"; 13 | 14 | import type { ICalendarSource, IDayMetadata, ISourceSettings } from "./types"; 15 | 16 | type PeriodicNoteID = string; 17 | 18 | export function getDateUIDFromFile(file: TFile | null): string { 19 | if (!file) { 20 | return null; 21 | } 22 | for (const granularity of ["day", "week", "month"] as IGranularity[]) { 23 | const date = getDateFromFile(file, granularity); 24 | if (date) { 25 | return getDateUID(date, granularity); 26 | } 27 | } 28 | return null; 29 | } 30 | 31 | export function getDateUIDFromPath(path: string | null): string { 32 | if (!path) { 33 | return null; 34 | } 35 | for (const granularity of ["day", "week", "month"] as IGranularity[]) { 36 | const date = getDateFromPath(path, granularity); 37 | if (date) { 38 | return getDateUID(date, granularity); 39 | } 40 | } 41 | return null; 42 | } 43 | 44 | export default class PeriodicNotesCache { 45 | private app: App; 46 | public store: Writable>; 47 | private sources: ICalendarSource[]; 48 | 49 | constructor(plugin: Plugin, sources: ICalendarSource[]) { 50 | this.app = plugin.app; 51 | this.sources = sources; 52 | this.store = writable>({}); 53 | 54 | plugin.app.workspace.onLayoutReady(() => { 55 | const { vault } = this.app; 56 | plugin.registerEvent(vault.on("create", this.onFileCreated, this)); 57 | plugin.registerEvent(vault.on("delete", this.onFileDeleted, this)); 58 | plugin.registerEvent(vault.on("rename", this.onFileRenamed, this)); 59 | plugin.registerEvent(vault.on("modify", this.onFileModified, this)); 60 | this.initialize(); 61 | }); 62 | 63 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 64 | const workspace = this.app.workspace as any; 65 | plugin.registerEvent( 66 | workspace.on("periodic-notes:settings-updated", this.initialize, this) 67 | ); 68 | plugin.registerEvent( 69 | workspace.on("calendar:metadata-updated", this.initialize, this) 70 | ); 71 | } 72 | 73 | public onFileCreated(file: TAbstractFile): void { 74 | if (file instanceof TFile && file.extension == "md") { 75 | const uid = getDateUIDFromFile(file); 76 | if (uid) { 77 | this.store.update((notes) => ({ ...notes, [uid]: file })); 78 | } 79 | } 80 | } 81 | 82 | public onFileDeleted(file: TAbstractFile): void { 83 | if (file instanceof TFile && file.extension == "md") { 84 | const uid = getDateUIDFromFile(file); 85 | if (uid) { 86 | this.store.update((notes) => ({ ...notes, [uid]: undefined })); 87 | } 88 | } 89 | } 90 | 91 | public onFileModified(file: TAbstractFile): void { 92 | if (file instanceof TFile && file.extension == "md") { 93 | const uid = getDateUIDFromFile(file); 94 | if (uid) { 95 | this.store.update((notes) => ({ ...notes, [uid]: file })); 96 | } 97 | } 98 | } 99 | 100 | public onFileRenamed(file: TAbstractFile, oldPath: string): void { 101 | const uid = getDateUIDFromPath(oldPath); 102 | if (uid) { 103 | this.store.update((notes) => ({ ...notes, [uid]: undefined })); 104 | } 105 | this.onFileCreated(file); 106 | } 107 | 108 | /** 109 | * Load any necessary state asynchronously 110 | */ 111 | public initialize(): void { 112 | this.store.set({ 113 | ...getAllDailyNotes(), 114 | ...getAllWeeklyNotes(), 115 | ...getAllMonthlyNotes(), 116 | }); 117 | } 118 | 119 | public getFile(date: Moment, granularity: IGranularity): TFile | null { 120 | const uid = getDateUID(date, granularity); 121 | return get(this.store)[uid]; 122 | } 123 | 124 | public getFileForPeriodicNote(id: PeriodicNoteID): TFile | null { 125 | return get(this.store)[id]; 126 | } 127 | 128 | public async getEvaluatedMetadata( 129 | granularity: IGranularity, 130 | date: Moment, 131 | getSourceSettings: (sourceId: string) => ISourceSettings, 132 | ..._args: unknown[] 133 | ): Promise { 134 | const uid = getDateUID(date, granularity); 135 | const file = this.getFileForPeriodicNote(uid); 136 | 137 | const metadata = []; 138 | for (const source of this.sources) { 139 | const evaluatedMetadata = 140 | (await source.getMetadata?.(granularity, date, file)) || {}; 141 | const sourceSettings = getSourceSettings(source.id); 142 | 143 | metadata.push({ 144 | ...evaluatedMetadata, 145 | ...source, 146 | ...sourceSettings, 147 | }); 148 | } 149 | return metadata; 150 | } 151 | 152 | public onDragStart(event: DragEvent, file: TFile): void { 153 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 154 | const dragManager = (this.app).dragManager; 155 | const dragData = dragManager.dragFile(event, file); 156 | dragManager.onDragStart(event, dragData); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/components/Calendar.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 86 | 87 |
88 |
151 | 152 | 193 | --------------------------------------------------------------------------------