├── .npmrc ├── versions.json ├── .eslintignore ├── .gitattributes ├── assets ├── screenshot.png ├── instructions1.png ├── instructions2.png └── instructions3.png ├── src ├── typings │ ├── global.d.ts │ └── obsidian.d.ts ├── utils │ ├── fileUtils.ts │ ├── nonce.ts │ ├── markwhenState.ts │ ├── colorUtils.ts │ ├── colorMap.ts │ ├── dateTextInterpolation.ts │ └── dateTimeUtilities.ts ├── markwhenWorker.ts ├── templates.ts ├── icons.ts ├── MarkwhenCodemirrorPlugin.ts ├── main.ts └── MarkwhenView.ts ├── .editorconfig ├── .prettierrc ├── copyAssets.sh ├── manifest.json ├── .gitignore ├── .prettierignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature-request.yml │ └── bug-report.yml └── workflows │ └── release.yml ├── styles.css ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── LICENSE ├── vite.config.ts ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "1.0.0" 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mark-when/obsidian-plugin/HEAD/assets/screenshot.png -------------------------------------------------------------------------------- /assets/instructions1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mark-when/obsidian-plugin/HEAD/assets/instructions1.png -------------------------------------------------------------------------------- /assets/instructions2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mark-when/obsidian-plugin/HEAD/assets/instructions2.png -------------------------------------------------------------------------------- /assets/instructions3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mark-when/obsidian-plugin/HEAD/assets/instructions3.png -------------------------------------------------------------------------------- /src/typings/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.html' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /src/typings/obsidian.d.ts: -------------------------------------------------------------------------------- 1 | import 'obsidian'; 2 | 3 | declare module 'obsidian' { 4 | interface Vault { 5 | getAbstractFileByPathInsensitive(path: string): TAbstractFile | null; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "tabWidth": 4, 6 | "printWidth": 80, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "always" 9 | } -------------------------------------------------------------------------------- /copyAssets.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cp ./out/* ~/Documents/Obsidian\ Vault/.obsidian/plugins/markwhen 4 | cp ./styles.css ~/Documents/Obsidian\ Vault/.obsidian/plugins/markwhen 5 | cp ./manifest.json ~/Documents/Obsidian\ Vault/.obsidian/plugins/markwhen -------------------------------------------------------------------------------- /src/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | export function getDefaultFileName() { 2 | return `Markwhen ${new Date() 3 | .toLocaleString('en-US', { hour12: false }) 4 | .replace(/\//g, '-') 5 | .replace(/:/g, '.') 6 | .replace(/,/, '')}.mw`; // TODO: improve this 7 | } 8 | -------------------------------------------------------------------------------- /src/markwhenWorker.ts: -------------------------------------------------------------------------------- 1 | import { parse, Caches } from '@markwhen/parser'; 2 | 3 | const cache = new Caches(); 4 | addEventListener('message', (message) => { 5 | try { 6 | postMessage(parse(message.data.rawTimelineString, cache)); 7 | } catch (e) { 8 | postMessage({ error: e }); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /src/utils/nonce.ts: -------------------------------------------------------------------------------- 1 | export const getNonce = () => { 2 | let text = ''; 3 | const possible = 4 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 5 | for (let i = 0; i < 32; i++) { 6 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 7 | } 8 | return text; 9 | }; 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "markwhen", 3 | "name": "Markwhen", 4 | "version": "0.0.7", 5 | "minAppVersion": "1.0.0", 6 | "description": "Create timelines, gantt charts, calendars, and more using markwhen.", 7 | "author": "Markwhen", 8 | "authorUrl": "https://github.com/mark-when", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature 3 | labels: ['triage'] 4 | body: 5 | - type: textarea 6 | id: feature 7 | attributes: 8 | label: What would you like to be added? 9 | validations: 10 | required: true 11 | 12 | - type: textarea 13 | id: rationale 14 | attributes: 15 | label: Why is this needed? 16 | validations: 17 | required: true 18 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | 10 | .workspace-leaf-content .markwhen-view { 11 | padding: 0; 12 | overflow: hidden; 13 | } 14 | 15 | .markwhenEditor { 16 | max-width: var(--file-line-width); 17 | margin-left: auto; 18 | margin-right: auto; 19 | } 20 | 21 | .mw-hidden { 22 | display: none !important; 23 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "allowSyntheticDefaultImports": true, 15 | "types": [ 16 | "vite/client" 17 | ], 18 | "lib": [ 19 | "DOM", 20 | "ES5", 21 | "ES6", 22 | "ES7" 23 | ] 24 | }, 25 | "include": [ 26 | "**/*.ts" 27 | ] 28 | } -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /src/templates.ts: -------------------------------------------------------------------------------- 1 | import calendar from '@markwhen/calendar/dist/index.html?raw'; 2 | import oneview from '@markwhen/oneview/dist/index.html?raw'; 3 | import resume from '@markwhen/resume/dist/index.html?raw'; 4 | import timeline from '@markwhen/timeline2/dist/index.html?raw'; 5 | 6 | export type ViewType = 'timeline' | 'calendar' | 'resume' | 'text' | 'oneview'; 7 | 8 | export const getTemplateURL = (vt: ViewType) => { 9 | let template: string = ''; 10 | if (vt === 'calendar') template = calendar; 11 | else if (vt === 'oneview') template = oneview; 12 | else if (vt === 'resume') template = resume; 13 | else if (vt === 'timeline') template = timeline; 14 | 15 | return URL.createObjectURL(new Blob([template], { type: 'text/html' })); 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/markwhenState.ts: -------------------------------------------------------------------------------- 1 | import { ParseResult, Timeline } from '@markwhen/parser'; 2 | import { MarkwhenState, AppState } from '@markwhen/view-client'; 3 | import { useColors } from './colorMap'; 4 | 5 | export function getMarkwhenState( 6 | mw: ParseResult, 7 | rawText: string 8 | ): MarkwhenState | undefined { 9 | return { 10 | rawText, 11 | parsed: mw, 12 | transformed: mw.events, 13 | }; 14 | } 15 | 16 | export function getAppState(mw: Timeline): AppState { 17 | const isDark = 18 | window.document.body.attributes 19 | .getNamedItem('class') 20 | ?.value.contains('theme-dark') && 21 | !window.document.body.attributes 22 | .getNamedItem('class') 23 | ?.value.contains('theme-light'); // edge: 1 & 1 or 0 & 0 24 | 25 | return { 26 | isDark, 27 | hoveringPath: undefined, 28 | detailPath: undefined, 29 | colorMap: mw ? useColors(mw) ?? {} : {}, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Rob Koch 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /src/icons.ts: -------------------------------------------------------------------------------- 1 | import { addIcon } from 'obsidian'; 2 | 3 | export const MARKWHEN_ICON = 'markwhen-icon'; 4 | export const ONEVIEW_ICON = 'oneview'; 5 | 6 | addIcon( 7 | MARKWHEN_ICON, 8 | '' 9 | ); 10 | 11 | addIcon( 12 | ONEVIEW_ICON, 13 | ' ' 14 | ); 15 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { UserConfig, defineConfig } from 'vite'; 2 | import path from 'path'; 3 | import builtins from 'builtin-modules'; 4 | 5 | export default defineConfig(async ({ mode }) => { 6 | const { resolve } = path; 7 | const prod = mode === 'production'; 8 | 9 | return { 10 | build: { 11 | lib: { 12 | entry: resolve(__dirname, 'src/main.ts'), 13 | name: 'main', 14 | fileName: () => 'main.js', 15 | formats: ['cjs'], 16 | }, 17 | minify: prod, 18 | sourcemap: prod ? false : 'inline', 19 | cssCodeSplit: false, 20 | emptyOutDir: false, 21 | outDir: 'out', 22 | rollupOptions: { 23 | input: { 24 | main: resolve(__dirname, 'src/main.ts'), 25 | }, 26 | external: [ 27 | 'obsidian', 28 | 'electron', 29 | '@codemirror/autocomplete', 30 | '@codemirror/collab', 31 | '@codemirror/commands', 32 | '@codemirror/language', 33 | '@codemirror/lint', 34 | '@codemirror/search', 35 | '@codemirror/state', 36 | '@codemirror/view', 37 | '@lezer/common', 38 | '@lezer/highlight', 39 | '@lezer/lr', 40 | ...builtins, 41 | ], 42 | }, 43 | }, 44 | } as UserConfig; 45 | }); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markwhen", 3 | "version": "0.0.7", 4 | "description": "Markwhen integration for Obsidian.md", 5 | "main": "main.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc -noEmit -skipLibCheck && vite build", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "lint": "eslint ./src", 11 | "dev": "vite build --watch & watch ./copyAssets.sh", 12 | "vite": "vite build --watch & watch ./copyAssets.sh" 13 | }, 14 | "keywords": [ 15 | "timeline", 16 | "gantt", 17 | "gantt chart", 18 | "calendar", 19 | "markdown", 20 | "project planning" 21 | ], 22 | "author": "Rob Koch", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@types/luxon": "^3.4.2", 26 | "@types/node": "^20.12.7", 27 | "@typescript-eslint/eslint-plugin": "5.29.0", 28 | "@typescript-eslint/parser": "5.29.0", 29 | "builtin-modules": "3.3.0", 30 | "obsidian": "^1.5.7-1", 31 | "tslib": "2.4.0", 32 | "typescript": "4.7.4", 33 | "vite": "^5.2.10", 34 | "watch": "^0.13.0" 35 | }, 36 | "dependencies": { 37 | "@codemirror/state": "^6.4.1", 38 | "@codemirror/view": "^6.26.3", 39 | "@markwhen/calendar": "^1.3.4", 40 | "@markwhen/oneview": "^1.0.0", 41 | "@markwhen/parser": "^0.15.0", 42 | "@markwhen/resume": "^1.1.0", 43 | "@markwhen/timeline": "^1.3.3", 44 | "@markwhen/timeline2": "^1.4.4", 45 | "@markwhen/view-client": "^1.5.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug encountered while using the Markwhen plugin 3 | labels: ['triage'] 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: What happened? 9 | description: | 10 | Please provide as much info as possible. 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: expected 16 | attributes: 17 | label: What did you expect to happen? 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: repro 23 | attributes: 24 | label: How can we reproduce it (as minimally and precisely as possible)? 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | id: additional 30 | attributes: 31 | label: Anything else we need to know? 32 | 33 | - type: input 34 | id: pluginVersion 35 | attributes: 36 | label: Plugin version 37 | description: You can find the version under Settings -> Community plugins -> Installed plugins -> Chem. 38 | validations: 39 | required: true 40 | 41 | - type: input 42 | id: obsidianVersion 43 | attributes: 44 | label: Obsidian version 45 | description: You can find the version under Settings -> About -> Current version. 46 | validations: 47 | required: true 48 | -------------------------------------------------------------------------------- /src/utils/colorUtils.ts: -------------------------------------------------------------------------------- 1 | // RGB, so we can use rgba(... ) with a different alpha where we need it 2 | export const COLORS = [ 3 | '22, 163, 76', 4 | '2, 132, 199', 5 | '212, 50, 56', 6 | '242, 202, 45', 7 | '80, 73, 229', 8 | '145, 57, 234', 9 | '214, 45, 123', 10 | '234, 88, 11', 11 | '168, 162, 157', 12 | '255, 255, 255', 13 | '0, 0, 0', 14 | ]; 15 | export const HUMAN_COLORS = [ 16 | 'green', 17 | 'blue', 18 | 'red', 19 | 'yellow', 20 | 'indigo', 21 | 'purple', 22 | 'pink', 23 | 'orange', 24 | 'gray', 25 | 'white', 26 | 'black', 27 | ]; 28 | 29 | export function hexToRgb(hex: string): string | undefined { 30 | hex = hex.replace('#', '').replace(')', ''); 31 | const isShortHex = hex.length === 3; 32 | const r = parseInt( 33 | isShortHex ? hex.slice(0, 1).repeat(2) : hex.slice(0, 2), 34 | 16 35 | ); 36 | if (isNaN(r)) { 37 | return undefined; 38 | } 39 | const g = parseInt( 40 | isShortHex ? hex.slice(1, 2).repeat(2) : hex.slice(2, 4), 41 | 16 42 | ); 43 | if (isNaN(g)) { 44 | return undefined; 45 | } 46 | const b = parseInt( 47 | isShortHex ? hex.slice(2, 3).repeat(2) : hex.slice(4, 6), 48 | 16 49 | ); 50 | if (isNaN(b)) { 51 | return undefined; 52 | } 53 | return `${r}, ${g}, ${b}`; 54 | } 55 | 56 | function componentToHex(c: number) { 57 | const hex = c.toString(16); 58 | return hex.length == 1 ? '0' + hex : hex; 59 | } 60 | 61 | function rgbNumberToHex(...rgb: number[]) { 62 | return ( 63 | '#' + 64 | componentToHex(rgb[0]) + 65 | componentToHex(rgb[1]) + 66 | componentToHex(rgb[2]) 67 | ); 68 | } 69 | 70 | export function rgbStringToHex(s: string) { 71 | return rgbNumberToHex(...s.split(',').map((n) => parseInt(n.trim()))); 72 | } 73 | -------------------------------------------------------------------------------- /src/MarkwhenCodemirrorPlugin.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView, PluginValue, ViewUpdate } from '@codemirror/view'; 2 | import { ParseResult, Timeline, emptyTimeline } from '@markwhen/parser'; 3 | import parseWorker from './markwhenWorker?worker&inline'; 4 | import { StateEffect } from '@codemirror/state'; 5 | 6 | export const useParserWorker = (parsed: (mw: ParseResult) => void) => { 7 | let running = false; 8 | let parsingString = ''; 9 | let queuedString = ''; 10 | const worker = new parseWorker(); 11 | 12 | worker.onmessage = (message: MessageEvent) => { 13 | const { timelines: fromWorker, error } = message.data; 14 | if (!error) { 15 | parsed(fromWorker); 16 | } else { 17 | console.log(error); 18 | } 19 | if (queuedString !== parsingString) { 20 | parsingString = queuedString; 21 | worker.postMessage({ rawTimelineString: queuedString }); 22 | } else { 23 | running = false; 24 | } 25 | }; 26 | 27 | return (s: string) => { 28 | queuedString = s; 29 | if (!running) { 30 | running = true; 31 | parsingString = s; 32 | worker.postMessage({ rawTimelineString: s }); 33 | } else { 34 | console.info('Would post but running'); 35 | } 36 | }; 37 | }; 38 | 39 | export const parseResult = StateEffect.define(); 40 | 41 | export class MarkwhenCodemirrorPlugin implements PluginValue { 42 | markwhen: ParseResult = emptyTimeline() as ParseResult; 43 | view: EditorView; 44 | worker = useParserWorker((mw) => { 45 | this.markwhen = mw; 46 | this.view.dispatch({ 47 | effects: parseResult.of(mw), 48 | }); 49 | }); 50 | 51 | constructor(view: EditorView) { 52 | this.view = view; 53 | this.worker(view.state.doc.sliceString(0)); 54 | } 55 | 56 | update(update: ViewUpdate): void { 57 | if (update.docChanged) { 58 | this.worker(update.state.sliceDoc()); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/colorMap.ts: -------------------------------------------------------------------------------- 1 | import { COLORS, HUMAN_COLORS, hexToRgb } from './colorUtils'; 2 | 3 | export type ColorMap = Record>; 4 | 5 | const colorMapAndRangesFromMarkwhen = (timeline: any, colorIndex: number) => { 6 | const map = {} as Record; 7 | const ranges = timeline.ranges.flatMap((r: any) => { 8 | if (r.type !== 'tag') { 9 | return []; 10 | } 11 | if (map[r.content.tag]) { 12 | r.content.color = map[r.content.tag]; 13 | return [r]; 14 | } 15 | const headerDefinition = 16 | r.content?.tag && timeline.header[')' + r.content.tag]; 17 | const tagColorDefintion: string | undefined = 18 | !!headerDefinition && 19 | ((typeof headerDefinition === 'string' && headerDefinition) || 20 | (typeof headerDefinition === 'object' && 21 | headerDefinition.color)); 22 | if (tagColorDefintion) { 23 | const humanColorIndex = HUMAN_COLORS.indexOf(tagColorDefintion); 24 | if (humanColorIndex === -1) { 25 | const rgb = hexToRgb(tagColorDefintion); 26 | if (rgb) { 27 | r.content.color = rgb; 28 | } else { 29 | r.content.color = COLORS[colorIndex++ % COLORS.length]; 30 | } 31 | } else { 32 | r.content.color = COLORS[humanColorIndex]; 33 | } 34 | } else { 35 | r.content.color = COLORS[colorIndex++ % COLORS.length]; 36 | } 37 | map[r.content.tag] = r.content.color; 38 | return [r]; 39 | }); 40 | return [map, ranges, colorIndex] as const; 41 | }; 42 | 43 | export const useColors = (markwhen: any) => { 44 | let colorIndex = 0; 45 | const colorMap = {} as ColorMap; 46 | for (const [path, timeline] of [['default', markwhen]] as [string, any][]) { 47 | const [map, , index] = colorMapAndRangesFromMarkwhen( 48 | timeline, 49 | colorIndex 50 | ); 51 | colorMap[path] = map; 52 | colorIndex = index; 53 | } 54 | return colorMap; 55 | }; 56 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # or your default branch 7 | 8 | jobs: 9 | check-version: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | version-changed: ${{ steps.version-check.outputs.changed }} 13 | new-version: ${{ steps.version-check.outputs.version }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 2 # Need at least 2 commits to compare 18 | 19 | - name: Check if version changed 20 | id: version-check 21 | run: | 22 | # Get current version from package.json 23 | current_version=$(node -p "require('./package.json').version") 24 | 25 | # Get previous version from package.json in the previous commit 26 | previous_version=$(git show HEAD~1:package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')).version") 27 | 28 | echo "Current version: $current_version" 29 | echo "Previous version: $previous_version" 30 | 31 | if [ "$current_version" != "$previous_version" ]; then 32 | echo "Version changed from $previous_version to $current_version" 33 | echo "changed=true" >> $GITHUB_OUTPUT 34 | echo "version=$current_version" >> $GITHUB_OUTPUT 35 | else 36 | echo "Version unchanged" 37 | echo "changed=false" >> $GITHUB_OUTPUT 38 | fi 39 | 40 | build: 41 | needs: check-version 42 | if: needs.check-version.outputs.version-changed == 'true' 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | - uses: actions/checkout@v3 47 | 48 | - name: Use Node.js 49 | uses: actions/setup-node@v3 50 | with: 51 | node-version: "22.x" 52 | 53 | - name: Build plugin 54 | run: | 55 | npm install 56 | npm run build 57 | 58 | - name: Create release 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | run: | 62 | tag="${{ needs.check-version.outputs.new-version }}" 63 | 64 | # Create and push the tag 65 | git config user.name github-actions 66 | git config user.email github-actions@github.com 67 | git tag "$tag" 68 | git push origin "$tag" 69 | 70 | gh release create "$tag" \ 71 | --title="$tag" \ 72 | --draft \ 73 | out/main.js manifest.json styles.css -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markwhen Obsidian Plugin 2 | 3 | [Markwhen docs](https://docs.markwhen.com) 4 | 5 | ![markwhen-obsidian-plugin](./assets/screenshot.png) 6 | 7 | # Instructions 8 | 9 | ![instructions1](./assets/instructions1.png) 10 | ![instructions2](./assets/instructions2.png) 11 | ![instructions3](./assets/instructions3.png) 12 | 13 | ![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22markwhen%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json) 14 | 15 | This plugin integrates [Markwhen](https://markwhen.com) into [Obsidian.md](https://obsidian.md/). You can use markwhen syntax to create timelines. 16 | 17 | [Main markwhen documentation](https://docs.markwhen.com). 18 | 19 | > [!Note] 20 | > Latest release: 0.0.4 21 | > Document version: 0.0.4 22 | 23 | ## Installation 24 | 25 | > [!Note] 26 | > Make sure that you are not in the **Restricted Mode**. 27 | 28 | ### Install from official plugin distribution 29 | 30 | 1. In Obsidian, open **Settings**. 31 | 2. Under **Community plugins**, click **Browse**. 32 | 3. Search for "Markwhen" and then select it. 33 | 4. Select **Install**, then enable it. 34 | 35 | You can also find and install Markwhen plugin here: 36 | 37 | ### Install via BRAT 38 | 39 | Register `https://github.com/mark-when/obsidian-plugin` in [BRAT](https://github.com/TfTHacker/obsidian42-brat) to receive upcoming releases automatically before we got reviewed from Obsidian team! 40 | 41 | ### Install the plugin manually 42 | 43 | 1. Go to the repo's latest [release page](https://github.com/mark-when/obsidian-plugin/releases/latest), and download `main.js`, `manifest.json` and `styles.css` (or the zip file). 44 | 2. Copy these files to your local path `[your vault]/.obsidian/plugins/markwhen/`. 45 | 3. Relaunch Obsidian, or refresh the plugin list, you will see this plugin. 46 | 4. In the plugin list, enable `Markwhen` and enjoy! 47 | 48 | ## Development 49 | 50 | Ensure you first have Obsidian installed, and set up a development vault. 51 | 52 | You can download and enable the [Hot-Reload](https://github.com/pjeby/hot-reload) plugin in the dev vault to experience a smooth debugging workflow. Every time `main.js`, `manifest.json` or `styles.css` updates, it will trigger an auto-reload. 53 | 54 | ### Linux / MacOS developers 55 | 56 | If the path to your vault is something other than `~/Documents/Obsidian Vault`, update `copyAssets.sh` to point to your vault's location. 57 | 58 | ```sh 59 | git clone git@github.com:mark-when/obsidian-plugin.git 60 | cd obsidian-plugin 61 | npm i 62 | npm run vite 63 | ``` 64 | 65 | ### Windows developers 66 | 67 | Since there's no watch command out-of-the-box, you can place the repo right in the dev vault config directory (i.e. `[your vault]/.obsidian/plugins/markwhen/`), and set the `outDir` to `./` in `vite.config.ts` (vite complains about this). 68 | 69 | ```cmd 70 | cd your-dev-vault/.obsidian/plugins 71 | git clone git@github.com:mark-when/obsidian-plugin.git markwhen 72 | cd markwhen 73 | npm i 74 | npm run vite 75 | ``` 76 | 77 | > [!Note] 78 | > The plugin id in the manifest is `markwhen`, indicating users will find their plugin under the `.obsidian/plugins/markwhen` directory if they install this plugin from official Obsidian distribution. 79 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | ExtraButtonComponent, 4 | normalizePath, 5 | Notice, 6 | Plugin, 7 | PluginSettingTab, 8 | Setting, 9 | TextComponent, 10 | TFile, 11 | TFolder, 12 | } from 'obsidian'; 13 | 14 | import { MARKWHEN_ICON } from './icons'; 15 | import { MarkwhenView, VIEW_TYPE_MARKWHEN } from './MarkwhenView'; 16 | import { getDefaultFileName } from './utils/fileUtils'; 17 | import { getTemplateURL, ViewType } from './templates'; 18 | import { parse } from '@markwhen/parser'; 19 | import { getAppState, getMarkwhenState } from './utils/markwhenState'; 20 | 21 | interface MarkwhenPluginSettings { 22 | folder: string; 23 | } 24 | 25 | const DEFAULT_SETTINGS: MarkwhenPluginSettings = { 26 | folder: 'Markwhen', 27 | }; 28 | 29 | export default class MarkwhenPlugin extends Plugin { 30 | settings: MarkwhenPluginSettings; 31 | 32 | async onload() { 33 | await this.loadSettings(); 34 | 35 | this.addCommand({ 36 | id: 'markwhen-new-file', 37 | name: 'Create new Markwhen File', 38 | callback: () => { 39 | this.createAndOpenMWFile(getDefaultFileName()); 40 | }, 41 | }); 42 | 43 | this.addCommand({ 44 | id: 'markwhen-open-text-view', 45 | name: 'Open text view', 46 | callback: async () => { 47 | this.openViewFromCurrent('text'); 48 | }, 49 | }); 50 | 51 | this.addCommand({ 52 | id: 'markwhen-open-oneview-view', 53 | name: 'Open vertical timeline view', 54 | callback: async () => { 55 | this.openViewFromCurrent('oneview'); 56 | }, 57 | }); 58 | 59 | this.addCommand({ 60 | id: 'markwhen-open-timeline-view', 61 | name: 'Open timeline view', 62 | callback: async () => { 63 | this.openViewFromCurrent('timeline'); 64 | }, 65 | }); 66 | 67 | this.addCommand({ 68 | id: 'markwhen-open-calendar-view', 69 | name: 'Open calendar view', 70 | callback: async () => { 71 | this.openViewFromCurrent('calendar'); 72 | }, 73 | }); 74 | 75 | this.addRibbonIcon( 76 | MARKWHEN_ICON, 77 | 'Create new Markwhen file', // tooltip 78 | () => { 79 | //TODO: better UX dealing with ribbon icons 80 | this.createAndOpenMWFile(getDefaultFileName()); 81 | } 82 | ); 83 | 84 | this.registerView( 85 | VIEW_TYPE_MARKWHEN, 86 | (leaf) => new MarkwhenView(leaf, 'text', this) 87 | ); 88 | 89 | this.registerExtensions(['mw'], VIEW_TYPE_MARKWHEN); 90 | 91 | this.registerMarkdownCodeBlockProcessor( 92 | 'mw', 93 | this.renderTimeline.bind(this) 94 | ); 95 | 96 | this.addSettingTab(new MarkwhenPluginSettingTab(this.app, this)); 97 | } 98 | 99 | onunload() {} 100 | 101 | renderTimeline(mw: string, el: HTMLElement) { 102 | const container = el.createEl('div', { 103 | cls: 'mw-timeline', 104 | }); 105 | const frame = container.createEl('iframe'); 106 | frame.src = getTemplateURL('timeline'); 107 | frame.height = `500px`; 108 | frame.width = '100%'; 109 | const parsed = parse(mw); 110 | frame.onload = () => { 111 | frame.contentWindow?.postMessage( 112 | { 113 | type: 'appState', 114 | request: true, 115 | id: `markwhen_0`, 116 | params: getAppState(parsed), 117 | }, 118 | '*' 119 | ); 120 | frame.contentWindow?.postMessage( 121 | { 122 | type: 'markwhenState', 123 | request: true, 124 | id: `markwhen_0`, 125 | params: getMarkwhenState(parsed, mw), 126 | }, 127 | '*' 128 | ); 129 | }; 130 | } 131 | 132 | async openViewFromCurrent(viewType: ViewType) { 133 | const currentFile = this.app.workspace.getActiveFile(); 134 | if (currentFile === null || currentFile.extension !== 'mw') { 135 | new Notice('You must be on a Markwhen file to run this command.'); 136 | return; 137 | } 138 | 139 | const leaf = this.app.workspace.getLeaf('split'); 140 | await leaf.open(new MarkwhenView(leaf, viewType, this)); 141 | await leaf.openFile(currentFile); 142 | await leaf.setViewState({ 143 | type: VIEW_TYPE_MARKWHEN, 144 | active: true, 145 | }); 146 | } 147 | 148 | async loadSettings() { 149 | this.settings = Object.assign( 150 | {}, 151 | DEFAULT_SETTINGS, 152 | await this.loadData() 153 | ); 154 | } 155 | 156 | async saveSettings() { 157 | await this.saveData(this.settings); 158 | } 159 | 160 | async activateView(): Promise { 161 | const leaf = this.app.workspace.getLeaf('tab'); 162 | 163 | leaf.setViewState({ 164 | type: VIEW_TYPE_MARKWHEN, 165 | active: true, 166 | }); 167 | 168 | this.app.workspace.revealLeaf(leaf); 169 | } 170 | 171 | //Credits to https://github.com/yuleicul/obsidian-ketcher on file operations 172 | async createAndOpenMWFile( 173 | filename: string, 174 | foldername?: string, 175 | initData?: string 176 | ) { 177 | const file = await this.createMWFile(filename, foldername, initData); 178 | this.app.workspace.getLeaf('tab').openFile(file); 179 | } 180 | 181 | async createMWFile( 182 | filename: string, 183 | foldername?: string, 184 | initData?: string 185 | ): Promise { 186 | const folderPath = normalizePath(foldername || this.settings.folder); 187 | await this.checkAndCreateFolder(folderPath); 188 | const fname = normalizePath(`${folderPath}/${filename}`); 189 | const file = await this.app.vault.create(fname, initData ?? ''); 190 | return file; 191 | } 192 | 193 | async checkAndCreateFolder(folderPath: string) { 194 | const vault = this.app.vault; 195 | folderPath = normalizePath(folderPath); 196 | //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/658 197 | const folder = vault.getAbstractFileByPathInsensitive(folderPath); 198 | if (folder && folder instanceof TFolder) return; 199 | if (folder && folder instanceof TFile) return; //File name corruption 200 | await vault.createFolder(folderPath); 201 | } 202 | } 203 | 204 | class MarkwhenPluginSettingTab extends PluginSettingTab { 205 | plugin: MarkwhenPlugin; 206 | 207 | constructor(app: App, plugin: MarkwhenPlugin) { 208 | super(app, plugin); 209 | this.plugin = plugin; 210 | } 211 | 212 | display(): void { 213 | const { containerEl } = this; 214 | 215 | containerEl.empty(); 216 | 217 | const folderSetting = new Setting(containerEl) 218 | .setName('Default folder') 219 | .setDesc('Default folder for new Markwhen files.') 220 | .addExtraButton((button: ExtraButtonComponent) => { 221 | button.setIcon('rotate-ccw').onClick(async () => { 222 | folderText.setValue(DEFAULT_SETTINGS.folder); 223 | this.plugin.settings.folder = DEFAULT_SETTINGS.folder; // Extract to Default Object 224 | await this.plugin.saveSettings(); 225 | }); 226 | }); 227 | 228 | const folderText = new TextComponent(folderSetting.controlEl) 229 | .setPlaceholder(DEFAULT_SETTINGS.folder) 230 | .setValue(this.plugin.settings.folder) 231 | .onChange(async (value) => { 232 | this.plugin.settings.folder = value; 233 | await this.plugin.saveSettings(); 234 | }); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/utils/dateTextInterpolation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DateFormat, 3 | DateRange, 4 | Event, 5 | RELATIVE_TIME_REGEX, 6 | RelativeDate, 7 | toDateRange, 8 | } from '@markwhen/parser'; 9 | import { Duration, DurationLikeObject, Interval } from 'luxon'; 10 | import { 11 | dateRangeToString, 12 | dateTimeToString, 13 | DisplayScale, 14 | } from './dateTimeUtilities'; 15 | 16 | const equivalentRanges = (r1: DateRange, r2: DateRange) => 17 | +r1.fromDateTime === +r2.fromDateTime && +r1.toDateTime === +r2.toDateTime; 18 | 19 | const relativeTimeRegexGlobal = new RegExp( 20 | `${RELATIVE_TIME_REGEX.source}`, 21 | 'g' 22 | ); 23 | 24 | const getRelativeTimeMatches = (s: string) => { 25 | const relativeMatches: RegExpMatchArray[] = []; 26 | for (const match of s.matchAll(relativeTimeRegexGlobal) || []) { 27 | if (match[0].trim()) { 28 | relativeMatches.push(match); 29 | } 30 | } 31 | return relativeMatches; 32 | }; 33 | 34 | const roundToMinutes = (d: Duration) => { 35 | const minutes = d.as('minutes'); 36 | if (minutes % 1) { 37 | // try to round by 5 if that's what we're going for 38 | const lastDigit = Math.round(minutes % 10); 39 | let roundedMinutes: number; 40 | if (lastDigit === 4) { 41 | roundedMinutes = Math.ceil(minutes); 42 | } else if (lastDigit === 5 || lastDigit === 6) { 43 | roundedMinutes = Math.floor(minutes); 44 | } else { 45 | roundedMinutes = Math.round(minutes / 10) * 10; 46 | } 47 | return d.set({ 48 | milliseconds: 0, 49 | seconds: 0, 50 | minutes: roundedMinutes, 51 | }); 52 | } 53 | return d; 54 | }; 55 | 56 | const toHuman = (d: Duration) => { 57 | const rescaled = roundToMinutes(d).rescale(); 58 | return rescaled.toHuman(); 59 | }; 60 | 61 | const rangeStringFromDiff = ( 62 | originalRange: DateRange, 63 | newRange: DateRange, 64 | originalDiffString: string 65 | ) => { 66 | const originalReferenceDate = originalRange.fromDateTime.minus( 67 | RelativeDate.diffFromString(originalDiffString) 68 | ); 69 | const newFromDiff = toHuman( 70 | newRange.fromDateTime.diff(originalReferenceDate) 71 | ); 72 | 73 | const newToDiff = toHuman(newRange.toDateTime.diff(newRange.fromDateTime)); 74 | 75 | // checking if these start with a minus is our way of making sure 76 | // they aren't negative 77 | if ( 78 | newFromDiff && 79 | newToDiff && 80 | !newFromDiff.startsWith('-') && 81 | !newToDiff.startsWith('-') 82 | ) { 83 | return `${newFromDiff} - ${newToDiff}`; 84 | } else if (newFromDiff && !newFromDiff.startsWith('-')) { 85 | return newFromDiff; 86 | } else if (newToDiff && !newToDiff.startsWith('-')) { 87 | return newToDiff; 88 | } 89 | }; 90 | 91 | function editRelativeEventDateRange( 92 | event: Event, 93 | range: DateRange, 94 | scale: DisplayScale, 95 | preferredInterpolationFormat: DateFormat | undefined, 96 | originalRange: DateRange 97 | ): string | undefined { 98 | const dateText = event.firstLine.datePart || ''; 99 | const relativeMatches = getRelativeTimeMatches(dateText); 100 | if (!relativeMatches.length) { 101 | return; 102 | } 103 | if (relativeMatches.length > 2) { 104 | console.warn("We shouldn't have more than 2 relative matches"); 105 | } 106 | 107 | const movingFrom = +originalRange.fromDateTime !== +range.fromDateTime; 108 | const movingTo = +originalRange !== +range.toDateTime; 109 | 110 | const indexOfFirst = dateText.indexOf(relativeMatches[0][0]); 111 | const indexOfSecond = dateText.indexOf(relativeMatches[1]?.[0]); 112 | const fromRelative = indexOfFirst === 0 ? relativeMatches[0] : undefined; 113 | const toRelative = 114 | indexOfFirst > 0 115 | ? relativeMatches[0] 116 | : indexOfSecond > 0 117 | ? relativeMatches[1] 118 | : undefined; 119 | 120 | if (movingFrom && movingTo) { 121 | if (fromRelative && toRelative) { 122 | const newRangeString = rangeStringFromDiff( 123 | originalRange, 124 | range, 125 | relativeMatches[0][0] 126 | ); 127 | if (newRangeString) { 128 | return newRangeString; 129 | } 130 | } else if (fromRelative) { 131 | if (fromRelative![0] === event.firstLine.datePart) { 132 | const originalReferenceDate = originalRange.fromDateTime; 133 | 134 | const newFromDiff = toHuman( 135 | range.fromDateTime.diff(originalReferenceDate) 136 | ); 137 | 138 | const newToDiff = toHuman( 139 | range.toDateTime.diff(range.fromDateTime) 140 | ); 141 | 142 | if (newFromDiff && newToDiff) { 143 | return `${newFromDiff} - ${newToDiff}`; 144 | } else if (newFromDiff) { 145 | return newFromDiff; 146 | } else if (newToDiff) { 147 | return newToDiff; 148 | } 149 | } 150 | } else if (toRelative) { 151 | const interval = Interval.fromDateTimes( 152 | range.fromDateTime, 153 | range.toDateTime 154 | ); 155 | 156 | let bestDiff; 157 | const durationKeys = [ 158 | 'years', 159 | 'months', 160 | 'weeks', 161 | 'days', 162 | 'hours', 163 | 'minutes', 164 | ] as (keyof DurationLikeObject)[]; 165 | for (const durKey of durationKeys) { 166 | const possibleDiff = interval.toDuration(durKey); 167 | const amount = possibleDiff.as(durKey); 168 | if (amount > 0 && amount % 1 === 0) { 169 | bestDiff = possibleDiff; 170 | break; 171 | } 172 | } 173 | bestDiff = bestDiff ?? range.toDateTime.diff(range.fromDateTime); 174 | const newToDiff = toHuman(bestDiff); 175 | 176 | if (newToDiff && !newToDiff.startsWith('-')) { 177 | const newFrom = dateTimeToString( 178 | range.fromDateTime, 179 | scale, 180 | true, 181 | preferredInterpolationFormat 182 | ); 183 | // Generally speaking if the date has slashes we delimit the range with a dash, 184 | // and if a date has dashes we delimit the range with a slash. Yes, this could be more robust 185 | // but I'd rather get it out the door 186 | const separator = newFrom?.includes('/') ? '-' : '/'; 187 | return `${newFrom} ${separator} ${newToDiff}`; 188 | } 189 | } 190 | } else if (movingFrom) { 191 | if (fromRelative && toRelative) { 192 | const newRangeString = rangeStringFromDiff( 193 | originalRange, 194 | range, 195 | relativeMatches[0][0] 196 | ); 197 | if (newRangeString) { 198 | return newRangeString; 199 | } 200 | } else if (fromRelative) { 201 | const newRangeString = rangeStringFromDiff( 202 | originalRange, 203 | range, 204 | relativeMatches[0][0] 205 | ); 206 | if (newRangeString) { 207 | return newRangeString; 208 | } 209 | } else if (toRelative) { 210 | console.log('unimplemented'); 211 | } 212 | } else if (movingTo) { 213 | if (fromRelative && toRelative) { 214 | const newRangeString = rangeStringFromDiff( 215 | originalRange, 216 | range, 217 | relativeMatches[0][0] 218 | ); 219 | if (newRangeString) { 220 | return newRangeString; 221 | } 222 | } else if (fromRelative) { 223 | if (fromRelative![0] === event.firstLine.datePart) { 224 | const originalReferenceDate = originalRange.fromDateTime; 225 | 226 | const newFromDiff = toHuman( 227 | range.fromDateTime.diff(originalReferenceDate) 228 | ); 229 | 230 | const newToDiff = toHuman( 231 | range.toDateTime.diff(range.fromDateTime) 232 | ); 233 | 234 | if (newFromDiff && newToDiff) { 235 | return `${newFromDiff} - ${newToDiff}`; 236 | } else if (newFromDiff) { 237 | return newFromDiff; 238 | } else if (newToDiff) { 239 | return newToDiff; 240 | } 241 | } 242 | } else if (toRelative) { 243 | const interval = Interval.fromDateTimes( 244 | range.fromDateTime, 245 | range.toDateTime 246 | ); 247 | 248 | let bestDiff; 249 | const durationKeys = [ 250 | 'years', 251 | 'months', 252 | 'weeks', 253 | 'days', 254 | 'hours', 255 | 'minutes', 256 | ] as (keyof DurationLikeObject)[]; 257 | for (const durKey of durationKeys) { 258 | const possibleDiff = interval.toDuration(durKey); 259 | const amount = possibleDiff.as(durKey); 260 | if (amount > 0 && amount % 1 === 0) { 261 | bestDiff = possibleDiff; 262 | break; 263 | } 264 | } 265 | bestDiff = bestDiff ?? range.toDateTime.diff(range.fromDateTime); 266 | const newToDiff = toHuman(bestDiff); 267 | 268 | if (newToDiff && !newToDiff.startsWith('-')) { 269 | const originalText = event.firstLine.datePart!; 270 | const index = originalText.indexOf(toRelative[0]); 271 | const originalFrom = event.firstLine.datePart!.substring( 272 | 0, 273 | index 274 | ); 275 | // Generally speaking if the date has slashes we delimit the range with a dash, 276 | // and if a date has dashes we delimit the range with a slash. Yes, this could be more robust 277 | // but I'd rather get it out the door 278 | return `${originalFrom} ${newToDiff}`; 279 | } 280 | } 281 | } 282 | } 283 | 284 | export function editEventDateRange( 285 | event: Event, 286 | range: DateRange, 287 | scale: DisplayScale, 288 | preferredInterpolationFormat: DateFormat | undefined 289 | ): string | undefined { 290 | const originalRange = toDateRange(event.dateRangeIso); 291 | if (equivalentRanges(originalRange, range)) { 292 | return; 293 | } 294 | 295 | return ( 296 | editRelativeEventDateRange( 297 | event, 298 | range, 299 | scale, 300 | preferredInterpolationFormat, 301 | originalRange 302 | ) || dateRangeToString(range, scale, preferredInterpolationFormat) 303 | ); 304 | } 305 | -------------------------------------------------------------------------------- /src/MarkwhenView.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceLeaf, MarkdownView, TFile, Platform } from 'obsidian'; 2 | import MarkwhenPlugin from './main'; 3 | import { MARKWHEN_ICON } from './icons'; 4 | export const VIEW_TYPE_MARKWHEN = 'markwhen-view'; 5 | import { getAppState, getMarkwhenState } from './utils/markwhenState'; 6 | import { 7 | ParseResult, 8 | get, 9 | isEvent, 10 | parse, 11 | toDateRange, 12 | } from '@markwhen/parser'; 13 | import { EditorView, ViewPlugin } from '@codemirror/view'; 14 | import { StateEffect } from '@codemirror/state'; 15 | import { 16 | MarkwhenCodemirrorPlugin, 17 | parseResult, 18 | } from './MarkwhenCodemirrorPlugin'; 19 | 20 | import { type ViewType, getTemplateURL } from './templates'; 21 | import { editEventDateRange } from './utils/dateTextInterpolation'; 22 | import { dateRangeToString } from './utils/dateTimeUtilities'; 23 | 24 | export class MarkwhenView extends MarkdownView { 25 | readonly plugin: MarkwhenPlugin; 26 | editorView: EditorView; 27 | viewType!: ViewType; 28 | views: Partial<{ [vt in ViewType]: HTMLIFrameElement }>; 29 | codemirrorPlugin: ViewPlugin; 30 | updateId = 0; 31 | 32 | constructor( 33 | leaf: WorkspaceLeaf, 34 | viewType: ViewType = 'text', 35 | plugin: MarkwhenPlugin 36 | ) { 37 | super(leaf); 38 | this.plugin = plugin; 39 | this.viewType = viewType; 40 | this.views = {}; 41 | for (const view of ['timeline', 'calendar', 'oneview'] as ViewType[]) { 42 | this.views[view] = this.contentEl.createEl('iframe', { 43 | attr: { 44 | style: 'height: 100%; width: 100%', 45 | }, 46 | }); 47 | this.views[view]?.addClass('mw'); 48 | } 49 | this.codemirrorPlugin = ViewPlugin.fromClass(MarkwhenCodemirrorPlugin); 50 | } 51 | 52 | createIFrameForViewType( 53 | viewType: ViewType, 54 | root: HTMLElement 55 | ): HTMLIFrameElement { 56 | return root.createEl('iframe', { 57 | attr: { 58 | style: 'height: 100%; width: 100%', 59 | src: getTemplateURL(viewType), 60 | }, 61 | }); 62 | } 63 | 64 | getDisplayText() { 65 | return this.file?.name ?? 'Markwhen'; 66 | } 67 | 68 | getIcon() { 69 | return MARKWHEN_ICON; 70 | } 71 | 72 | getViewType() { 73 | // This implements the TextFileView class provided by Obsidian API 74 | // Don't get confused with Markwhen visualizations 75 | return VIEW_TYPE_MARKWHEN; 76 | } 77 | 78 | async split(viewType: ViewType) { 79 | const leaf = this.app.workspace.getLeaf('split'); 80 | await leaf.open(new MarkwhenView(leaf, viewType, this.plugin)); 81 | await leaf.openFile(this.file!); 82 | await leaf.setViewState({ 83 | type: VIEW_TYPE_MARKWHEN, 84 | active: true, 85 | }); 86 | } 87 | 88 | updateVisualization(mw: ParseResult) { 89 | const frame = this.activeFrame(); 90 | if (!frame) { 91 | return; 92 | } 93 | frame.contentWindow?.postMessage( 94 | { 95 | type: 'appState', 96 | request: true, 97 | id: `markwhen_${this.updateId++}`, 98 | params: getAppState(mw), 99 | }, 100 | '*' 101 | ); 102 | frame.contentWindow?.postMessage( 103 | { 104 | type: 'markwhenState', 105 | request: true, 106 | id: `markwhen_${this.updateId++}`, 107 | params: getMarkwhenState(mw, this.data), 108 | }, 109 | '*' 110 | ); 111 | } 112 | 113 | registerExtensions() { 114 | const cm = this.getCodeMirror(); 115 | if (cm) { 116 | this.editorView = cm; 117 | const parseListener = EditorView.updateListener.of((update) => { 118 | update.transactions.forEach((tr) => { 119 | tr.effects.forEach((effect) => { 120 | if (effect.is(parseResult)) { 121 | this.updateVisualization(effect.value); 122 | } 123 | }); 124 | }); 125 | }); 126 | this.editorView.dispatch({ 127 | effects: StateEffect.appendConfig.of([ 128 | this.codemirrorPlugin, 129 | parseListener, 130 | ]), 131 | }); 132 | } 133 | } 134 | 135 | async onLoadFile(file: TFile) { 136 | super.onLoadFile(file); 137 | 138 | // Idk how else to register these extensions - I don't want to 139 | // register them in the main file because I need the update listener 140 | // to dispatch updates to visualizations. 141 | // 142 | // Meanwhile the extensions aren't 143 | // registered when I don't use setTimeout. Is there another hook I can use? 144 | // Other than onLoadFile or onOpen? 145 | setTimeout(() => { 146 | this.registerExtensions(); 147 | }, 500); 148 | } 149 | 150 | async onOpen() { 151 | super.onOpen(); 152 | 153 | const action = (viewType: ViewType) => async (evt: MouseEvent) => { 154 | if (evt.metaKey || evt.ctrlKey) { 155 | await this.split(viewType); 156 | } else if (this.viewType !== viewType) { 157 | await this.setViewType(viewType); 158 | } 159 | }; 160 | 161 | this.addAction( 162 | 'calendar', 163 | `Click to view calendar\n${ 164 | Platform.isMacOS ? '⌘' : 'Ctrl' 165 | }+Click to open to the right`, 166 | action('calendar') 167 | ); 168 | 169 | this.addAction( 170 | MARKWHEN_ICON, 171 | `Click to view timeline\n${ 172 | Platform.isMacOS ? '⌘' : 'Ctrl' 173 | }+Click to open to the right`, 174 | action('timeline') 175 | ); 176 | 177 | this.addAction( 178 | 'oneview', 179 | `Click to view vertical timeline\n${ 180 | Platform.isMacOS ? '⌘' : 'Ctrl' 181 | }+Click to open to the right`, 182 | action('oneview') 183 | ); 184 | 185 | // Hook for resume view 186 | 187 | // this.addAction( 188 | // 'file-text', 189 | // `Click to view resume\n${ 190 | // Platform.isMacOS ? '⌘' : 'Ctrl' 191 | // }+Click to open to the right`, 192 | // action('resume') 193 | // ); 194 | 195 | this.addAction( 196 | 'pen-line', 197 | `Click to edit text\n${ 198 | Platform.isMacOS ? '⌘' : 'Ctrl' 199 | }+Click to open to the right`, 200 | action('text') 201 | ); 202 | 203 | this.setViewType(this.viewType); 204 | this.registerDomEvent(window, 'message', async (e) => { 205 | if ( 206 | e.source == this.activeFrame()?.contentWindow && 207 | e.data.request 208 | ) { 209 | if (e.data.type === 'newEvent') { 210 | const { dateRangeIso, granularity } = e.data.params; 211 | const newEventString = `\n${dateRangeToString( 212 | toDateRange(dateRangeIso), 213 | granularity 214 | ? granularity === 'instant' 215 | ? 'minute' 216 | : granularity 217 | : 'day' 218 | )}: new event`; 219 | this.getCodeMirror()?.dispatch({ 220 | changes: { 221 | from: this.data.length, 222 | to: this.data.length, 223 | insert: newEventString, 224 | }, 225 | }); 226 | } else if ( 227 | e.data.type === 'markwhenState' || 228 | e.data.type === 'appState' 229 | ) { 230 | this.updateVisualization(this.getMw()!); 231 | } else if (e.data.type === 'editEventDateRange') { 232 | const events = this.getMw()?.events; 233 | if (!events) { 234 | return; 235 | } 236 | const { path, range, scale, preferredInterpolationFormat } = 237 | e.data.params; 238 | const event = get(events, path); 239 | if (!event || !isEvent(event)) { 240 | return; 241 | } 242 | const newText = editEventDateRange( 243 | event, 244 | toDateRange(range), 245 | scale, 246 | preferredInterpolationFormat 247 | ); 248 | if (newText) { 249 | this.getCodeMirror()?.dispatch({ 250 | changes: { 251 | from: event.textRanges.datePart.from, 252 | to: event.textRanges.datePart.to, 253 | insert: newText, 254 | }, 255 | }); 256 | } 257 | } 258 | } 259 | }); 260 | } 261 | 262 | activeFrame() { 263 | for (let i = 0; i < this.contentEl.children.length; i++) { 264 | const el = this.contentEl.children.item(i); 265 | if (el?.nodeName === 'IFRAME' && el.hasClass('active')) { 266 | return el as HTMLIFrameElement; 267 | } 268 | } 269 | } 270 | 271 | onload() { 272 | this.plugin.app.workspace.onLayoutReady(() => { 273 | this.contentEl.addClass('markwhen-view'); 274 | }); 275 | super.onload(); 276 | } 277 | 278 | async setViewType(viewType?: ViewType) { 279 | if (!viewType) { 280 | return; 281 | } 282 | this.viewType = viewType; 283 | if (this.viewType === 'text') { 284 | for (const vt of ['timeline', 'oneview', 'calendar']) { 285 | this.views[vt as ViewType]?.removeClass('mw-active'); 286 | } 287 | Array.from(this.contentEl.children).forEach((el) => { 288 | if (el?.nodeName === 'IFRAME') { 289 | el.addClass('mw-hidden'); 290 | } else { 291 | el?.removeClass('mw-hidden'); 292 | } 293 | }); 294 | } else { 295 | for (const vt of ['timeline', 'calendar', 'oneview']) { 296 | if (vt === viewType) { 297 | const frame = this.views[viewType]; 298 | if (frame) { 299 | if (!frame.src) { 300 | frame.setAttrs({ 301 | src: getTemplateURL(vt), 302 | }); 303 | } 304 | frame.addClass('active'); 305 | } 306 | } else { 307 | this.views[vt as ViewType]?.removeClass('active'); 308 | } 309 | } 310 | for (let i = 0; i < this.contentEl.children.length; i++) { 311 | const el = this.contentEl.children.item(i); 312 | if (el?.nodeName === 'IFRAME' && el.hasClass('active')) { 313 | el.removeClass('mw-hidden'); 314 | } else { 315 | el?.addClass('mw-hidden'); 316 | } 317 | } 318 | } 319 | this.updateVisualization(this.getMw()!); 320 | } 321 | 322 | getCodeMirror(): EditorView | undefined { 323 | // @ts-ignore 324 | return this.editor.cm; 325 | } 326 | 327 | getMw(): ParseResult | undefined { 328 | return ( 329 | this.getCodeMirror()?.plugin(this.codemirrorPlugin)?.markwhen ?? 330 | parse(this.data) 331 | ); 332 | } 333 | 334 | //Avoid loading Markdown file in Markwhen view (action icons, favicons, etc.) 335 | canAcceptExtension(extension: string) { 336 | return extension === 'mw'; 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /src/utils/dateTimeUtilities.ts: -------------------------------------------------------------------------------- 1 | import { DateTime, type DurationUnits } from 'luxon'; 2 | import { 3 | AMERICAN_DATE_FORMAT, 4 | DateRangePart, 5 | EUROPEAN_DATE_FORMAT, 6 | Event, 7 | Eventy, 8 | isEvent, 9 | toDateRange, 10 | type DateFormat, 11 | type DateRange, 12 | } from '@markwhen/parser'; 13 | 14 | export enum Weight { 15 | SECOND = 0, 16 | QUARTER_MINUTE = 1, 17 | MINUTE = 2, 18 | QUARTER_HOUR = 3, 19 | HOUR = 4, 20 | DAY = 5, 21 | MONTH = 6, 22 | YEAR = 7, 23 | DECADE = 8, 24 | CENT = 9, 25 | } 26 | 27 | export type DisplayScale = 28 | | 'second' 29 | | 'quarterminute' 30 | | 'minute' 31 | | 'quarterhour' 32 | | 'hour' 33 | | 'day' 34 | | 'month' 35 | | 'year' 36 | | 'decade' 37 | | 'cent'; 38 | 39 | export const scales: DisplayScale[] = [ 40 | 'second', 41 | 'quarterminute', 42 | 'minute', 43 | 'quarterhour', 44 | 'hour', 45 | 'day', 46 | 'month', 47 | 'year', 48 | 'decade', 49 | 'cent', 50 | ]; 51 | 52 | export function dateScale(dateTime: DateTime) { 53 | if (dateTime.second === 0) { 54 | if (dateTime.minute === 0) { 55 | if (dateTime.hour === 0) { 56 | if (dateTime.day === 1) { 57 | if (dateTime.month === 1) { 58 | if (dateTime.year % 100 === 0) { 59 | return Weight.CENT; 60 | } 61 | if (dateTime.year % 10 === 0) { 62 | return Weight.DECADE; 63 | } 64 | return Weight.YEAR; 65 | } 66 | return Weight.MONTH; 67 | } 68 | return Weight.DAY; 69 | } 70 | return Weight.HOUR; 71 | } else if (dateTime.minute % 15 == 0) { 72 | return Weight.QUARTER_HOUR; 73 | } 74 | return Weight.MINUTE; 75 | } else if (dateTime.second % 15 === 0) { 76 | return Weight.QUARTER_MINUTE; 77 | } 78 | return Weight.SECOND; 79 | } 80 | 81 | export const viewportLeftMarginPixels = 64; 82 | export const diffScale = 'hours'; 83 | 84 | export interface DateInterval { 85 | from: DateTime; 86 | to: DateTime; 87 | } 88 | 89 | export function floorDateTime(dateTime: DateTime, toScale: DisplayScale) { 90 | const year = dateTime.year; 91 | if (toScale === 'cent') { 92 | const roundedYear = year - (year % 100); 93 | return DateTime.fromObject({ year: roundedYear }); 94 | } 95 | if (toScale === 'decade') { 96 | const roundedYear = year - (year % 10); 97 | return DateTime.fromObject({ year: roundedYear }); 98 | } 99 | if (toScale === 'year') { 100 | return DateTime.fromObject({ year }); 101 | } 102 | const month = dateTime.month; 103 | if (toScale === 'month') { 104 | return DateTime.fromObject({ year, month }); 105 | } 106 | const day = dateTime.day; 107 | if (toScale === 'day') { 108 | return DateTime.fromObject({ year, month, day }); 109 | } 110 | const hour = dateTime.hour; 111 | if (toScale === 'hour') { 112 | return DateTime.fromObject({ year, month, day, hour }); 113 | } 114 | const minute = dateTime.minute; 115 | if (toScale === 'quarterhour') { 116 | return DateTime.fromObject({ 117 | year, 118 | month, 119 | day, 120 | hour, 121 | minute: minute - (minute % 15), 122 | }); 123 | } 124 | const second = dateTime.second; 125 | if (toScale === 'quarterminute') { 126 | return DateTime.fromObject({ 127 | year, 128 | month, 129 | day, 130 | hour, 131 | minute, 132 | second: second - (second % 15), 133 | }); 134 | } 135 | if (toScale === 'minute') { 136 | return DateTime.fromObject({ year, month, day, hour, minute }); 137 | } 138 | return DateTime.fromObject({ year, month, day, hour, minute, second }); 139 | } 140 | 141 | export function ceilDateTime(dateTime: DateTime, toScale: DisplayScale) { 142 | let increment; 143 | if (toScale === 'cent') { 144 | increment = { years: 100 }; 145 | } else if (toScale === 'decade') { 146 | increment = { years: 10 }; 147 | } else if (toScale === 'quarterhour') { 148 | increment = { minutes: 15 }; 149 | } else if (toScale === 'quarterminute') { 150 | increment = { seconds: 15 }; 151 | } else { 152 | increment = { [toScale]: 1 }; 153 | } 154 | const ceiled = floorDateTime(dateTime, toScale).plus(increment); 155 | return ceiled; 156 | } 157 | 158 | export function roundDateTime(dateTime: DateTime, toScale: DisplayScale) { 159 | const up = ceilDateTime(dateTime, toScale); 160 | const down = floorDateTime(dateTime, toScale); 161 | const upDiff = dateTime.diff(up); 162 | const downDiff = dateTime.diff(down); 163 | return Math.abs(+upDiff) < Math.abs(+downDiff) ? up : down; 164 | } 165 | 166 | export interface DateTimeAndOffset { 167 | dateTime: DateTime; 168 | left: number; 169 | } 170 | 171 | export type OffsetRange = [DateTimeAndOffset, DateTimeAndOffset]; 172 | 173 | export const humanDuration = (range: DateRange): string => { 174 | const units: DurationUnits = [ 175 | 'years', 176 | 'months', 177 | 'days', 178 | 'hours', 179 | 'minutes', 180 | 'seconds', 181 | ]; 182 | const diff = range.toDateTime.diff(range.fromDateTime, units); 183 | let adjustedUnits = units.filter((u) => diff.get(u) > 0); 184 | return adjustedUnits.length 185 | ? range.toDateTime.diff(range.fromDateTime, adjustedUnits).toHuman() 186 | : 'instant'; 187 | }; 188 | 189 | export const eventHumanDuration = (e: Event): string => 190 | humanDuration(toDateRange(e.dateRangeIso)); 191 | 192 | export const scaleForDuration = (dateRange: DateRangePart): DisplayScale => { 193 | const diff = dateRange.toDateTime 194 | .diff(dateRange.fromDateTime) 195 | .as('seconds'); 196 | if (diff < 60) { 197 | return 'second'; 198 | } 199 | if (diff < 60 * 60) { 200 | return 'minute'; 201 | } 202 | if (diff < 60 * 60 * 24) { 203 | return 'hour'; 204 | } 205 | if (diff < 60 * 60 * 24 * 30) { 206 | return 'day'; 207 | } 208 | if (diff < 60 * 60 * 24 * 30 * 12) { 209 | return 'month'; 210 | } 211 | return 'year'; 212 | }; 213 | 214 | function isAtLeastDaySpecificDate( 215 | dateTime: DateTime, 216 | scale: DisplayScale 217 | ): boolean { 218 | return ( 219 | isDayStartOrEnd(dateTime, scale) || 220 | isMonthStartOrEnd(dateTime, scale) || 221 | isYearStartOrEnd(dateTime, scale) 222 | ); 223 | } 224 | 225 | function isAtLeastDaySpecificRange( 226 | range: DateRange, 227 | scale: DisplayScale 228 | ): boolean { 229 | return ( 230 | isAtLeastDaySpecificDate(range.fromDateTime, scale) && 231 | isAtLeastDaySpecificDate(range.toDateTime, scale) 232 | ); 233 | } 234 | 235 | export function dateRangeToString( 236 | range: DateRange, 237 | scale: DisplayScale, 238 | dateFormat?: DateFormat 239 | ) { 240 | if (isAtLeastDaySpecificRange(range, scale)) { 241 | const fromAsString = dateTimeToString( 242 | range.fromDateTime, 243 | scale, 244 | true, 245 | dateFormat 246 | ); 247 | const toAsString = dateTimeToString( 248 | range.toDateTime, 249 | scale, 250 | false, 251 | dateFormat 252 | ); 253 | if ( 254 | fromAsString === toAsString || 255 | range.fromDateTime === range.toDateTime 256 | ) { 257 | return `${fromAsString}`; 258 | } 259 | return dateFormat 260 | ? `${fromAsString} - ${toAsString}` 261 | : `${fromAsString}/${toAsString}`; 262 | } 263 | return `${asIso(range.fromDateTime)} - ${asIso(range.toDateTime)}`; 264 | } 265 | 266 | export const eventMidpoint = (node: Eventy): DateTime | undefined => { 267 | if (isEvent(node)) { 268 | return dateMidpoint(toDateRange(node.dateRangeIso)); 269 | } else { 270 | if (!node.range || !node.range.fromDateTime || !node.range.toDateTime) 271 | return undefined; 272 | } 273 | return dateMidpoint(node.range); 274 | }; 275 | 276 | export const dateMidpoint = (range: DateRange): DateTime => { 277 | return range.fromDateTime.plus({ 278 | seconds: range.toDateTime.diff(range.fromDateTime).as('seconds') / 2, 279 | }); 280 | }; 281 | 282 | function isMinuteStartOrEnd(dateTime: DateTime, scale: DisplayScale) { 283 | if (!['day', 'hour', 'minute', 'second'].includes(scale)) { 284 | return false; 285 | } 286 | return [57, 58, 59, 0, 1, 2, 3].includes(dateTime.second); 287 | } 288 | 289 | function isDayStartOrEnd(dateTime: DateTime, scale: DisplayScale) { 290 | if (!['month', 'day'].includes(scale)) { 291 | return false; 292 | } 293 | return [23, 0, 1].includes(dateTime.hour); 294 | } 295 | 296 | function isMonthStartOrEnd(dateTime: DateTime, scale: DisplayScale) { 297 | if (!['decade', 'year', 'month'].includes(scale)) { 298 | return false; 299 | } 300 | return [28, 29, 30, 31, 1, 2].includes(dateTime.day); 301 | } 302 | 303 | function isYearStartOrEnd(dateTime: DateTime, scale: DisplayScale): boolean { 304 | if (!['cent', 'decade', 'year', 'month'].includes(scale)) { 305 | return false; 306 | } 307 | if (dateTime.month === 12 && (dateTime.day === 31 || dateTime.day === 30)) { 308 | return true; 309 | } 310 | if (dateTime.month === 1 && (dateTime.day === 1 || dateTime.day === 2)) { 311 | return true; 312 | } 313 | return false; 314 | } 315 | 316 | export function dateTimeToString( 317 | dateTime: DateTime, 318 | scale: DisplayScale, 319 | isStartDate: boolean, 320 | dateFormat: DateFormat | undefined 321 | ): string | undefined { 322 | if (isYearStartOrEnd(dateTime, scale)) { 323 | if (isStartDate) { 324 | const fromYear = dateTime.plus({ days: 2 }).year; 325 | return `${fromYear}`; 326 | } else { 327 | const toYear = dateTime.minus({ days: 2 }).year; 328 | return `${toYear}`; 329 | } 330 | } 331 | if (isMonthStartOrEnd(dateTime, scale)) { 332 | if (isStartDate) { 333 | const adjustedForward = dateTime.plus({ days: 2 }); 334 | const adjustedMonth = 335 | adjustedForward.month < 10 336 | ? '0' + adjustedForward.month 337 | : adjustedForward.month; 338 | return dateFormat 339 | ? `${adjustedMonth}/${adjustedForward.year}` 340 | : `${adjustedForward.year}-${adjustedMonth}`; 341 | } else { 342 | const adjustedBack = dateTime.minus({ days: 2 }); 343 | const adjustedMonth = 344 | adjustedBack.month < 10 345 | ? '0' + adjustedBack.month 346 | : adjustedBack.month; 347 | return dateFormat 348 | ? `${adjustedMonth}/${adjustedBack.year}` 349 | : `${adjustedBack.year}-${adjustedMonth}`; 350 | } 351 | } 352 | if (isDayStartOrEnd(dateTime, scale)) { 353 | if (isStartDate) { 354 | const adjustedForward = dateTime.plus({ hours: 2 }); 355 | return dateFormat 356 | ? dayFormat(adjustedForward, dateFormat) 357 | : adjustedForward.toISODate() || undefined; 358 | } else { 359 | const adjustedBack = dateTime.minus({ hours: 2 }); 360 | return dateFormat 361 | ? dayFormat(adjustedBack, dateFormat) 362 | : adjustedBack.toISODate() || undefined; 363 | } 364 | } 365 | } 366 | 367 | function dayFormat(dateTime: DateTime, dateFormat: DateFormat): string { 368 | const day = dateTime.day; 369 | const month = dateTime.month; 370 | const year = dateTime.year; 371 | if (dateFormat === AMERICAN_DATE_FORMAT) { 372 | return `${month}/${day}/${year}`; 373 | } else if (dateFormat === EUROPEAN_DATE_FORMAT) { 374 | return `${day}/${month}/${year}`; 375 | } 376 | return 'unexpected date format'; 377 | } 378 | 379 | function asIso(dateTime: DateTime): string { 380 | return dateTime.toUTC().toISO({ includeOffset: false }) + 'Z'; 381 | } 382 | --------------------------------------------------------------------------------