├── 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 |
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 |
37 |
45 |
46 |
51 |
57 |
58 |
59 |
64 |
65 |
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 |
96 |
97 |
98 | {#if showWeekNums}
99 |
100 | {/if}
101 | {#each month[1].days as date}
102 |
103 | {/each}
104 |
105 |
106 |
107 | {#if showWeekNums}
108 | W
109 | {/if}
110 | {#each daysOfWeek as dayOfWeek}
111 | {dayOfWeek}
112 | {/each}
113 |
114 |
115 |
116 | {#each month as week (week.weekNum)}
117 |
118 | {#if showWeekNums}
119 |
128 | {/if}
129 | {#each week.days as day (day.format())}
130 |
140 | {/each}
141 |
142 | {/each}
143 |
144 |
145 |
150 |
151 |
152 |
193 |
--------------------------------------------------------------------------------