├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── build.mjs ├── manifest.json ├── package.json ├── src ├── dev.ts ├── homepage.ts ├── main.ts ├── periodic.ts ├── settings.ts ├── types.ts ├── ui.ts └── utils.ts ├── styles.css ├── tests ├── harness.ts ├── opening-tests.ts ├── plugin-tests.ts ├── setting-tests.ts ├── vault │ ├── .obsidian │ │ ├── appearance.json │ │ ├── community-plugins.json │ │ ├── core-plugins-migration.json │ │ ├── core-plugins.json │ │ ├── hotkeys.json │ │ ├── plugins │ │ │ ├── dataview │ │ │ │ └── data.json │ │ │ ├── homepage │ │ │ │ └── data.json │ │ │ ├── journals │ │ │ │ └── data.json │ │ │ └── periodic-notes │ │ │ │ └── data.json │ │ └── snippets │ │ │ └── modal.css │ ├── Base.base │ ├── Canvas.canvas │ ├── Dataview.md │ ├── Home.md │ ├── Image.png │ ├── Kanban.md │ ├── Note A.md │ ├── Note B.md │ └── TestFolder │ │ ├── Note C.md │ │ └── Note D.md └── view-tests.ts ├── tsconfig.json └── versions.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: novov 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report something that isn't working correctly 3 | labels: bug 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | **Thanks for filing an issue!** 9 | Before submitting, try to provide as much detail as possible to ensure that the issue can be reproduced accurately and fixed. 10 | 11 | If possible, ensure your issue doesn't appear without Homepage. For instance, if your homepage is a Daily Note, check that your issue doesn't occur when opening a Daily Note manually. 12 | 13 | If it's related to another plugin, it may be worth checking if that plugin works with Workspaces, as Homepage uses the same logic to replace previously opened notes. 14 | 15 | - type: textarea 16 | attributes: 17 | label: Details 18 | description: Information about the bug. 19 | - type: textarea 20 | attributes: 21 | label: Steps to reproduce 22 | description: Detail exactly how your issue can be reproduced. 23 | - type: textarea 24 | attributes: 25 | label: Debug information 26 | description: > 27 | Use the **Homepage: Copy debug info** command and paste the result here. This helps narrow down any settings and conflicting plugins that may be causing your issue. 28 | render: shell 29 | - type: dropdown 30 | attributes: 31 | description: What operating systems you have experienced this issue on. 32 | multiple: true 33 | label: Operating systems 34 | options: 35 | - Windows 36 | - macOS 37 | - Linux 38 | - iOS 39 | - Android 40 | - type: checkboxes 41 | attributes: 42 | label: Checklist 43 | options: 44 | - label: I updated to the latest version of the plugin. 45 | required: true 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Enhancement 2 | description: Suggest a feature for this plugin 3 | labels: enhancement 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: Summary 8 | description: A clear and concise description of what you've requesting. 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Details 14 | description: Any details, additional information, or further reasoning. 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | *.iml 3 | .idea 4 | .nova 5 | .vscode 6 | 7 | # npm 8 | node_modules 9 | package-lock.json 10 | 11 | # build 12 | out 13 | 14 | # obsidian 15 | data.json 16 | 17 | # testing 18 | tests/*/ 19 | !tests/vault/ 20 | tests/vault/.obsidian/plugins/*/* 21 | !tests/vault/.obsidian/plugins/*/data.json 22 | tests/vault/.obsidian/workspace.json 23 | tests/vault/.obsidian/workspaces.json 24 | tests/vault/.obsidian/app.json 25 | tests/vault/.obsidian/graph.json 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-present Obsidian, novov 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Homepage

2 |

Open a specified note, canvas, base, or workspace upon launching Obsidian.

3 |
4 | 5 | * Use any note, canvas, base, or workspace as a homepage. Alternatively, choose a random note, or use your Daily or Periodic Notes. 6 | * Decide what happens to old tabs that were left open - keep them, replace the last note, or remove them all. 7 | 8 | 9 | * Jump back after startup using the `Open homepage` command, or click the dedicated ribbon button. 10 | * Open in in any view: Reading, Source, Live Preview, or the default. Optionally revert the view when opening another note. 11 | 12 | 13 | * Run any Obsidian command upon opening the homepage, allowing integration with hundreds of plugins. 14 | * Works effectively with tools such as [Dataview](https://github.com/blacksmithgu/obsidian-dataview) to create advanced landing pages. 15 | 16 | 17 | ---- 18 | 19 |

20 | -------------------------------------------------------------------------------- /build.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import copy from "esbuild-plugin-copy"; 3 | import { typecheckPlugin } from "@jgoz/esbuild-plugin-typecheck"; 4 | import opener from "opener"; 5 | 6 | import fs from "fs/promises"; 7 | import { dirname, resolve } from "path"; 8 | import process from "process"; 9 | 10 | function getMode(keyword) { 11 | if (["production", "prod"].includes(keyword)) return "prod"; 12 | else if (keyword === "test") return "test"; 13 | else return "dev"; 14 | } 15 | 16 | async function generateContext(mode) { 17 | let outPath, inPath, outDir, plugins; 18 | 19 | switch (mode) { 20 | case "prod": 21 | break; 22 | case "test": 23 | outPath = "./tests/vault/.obsidian/plugins/homepage/main.js"; 24 | inPath = "./tests/harness.ts"; 25 | break; 26 | case "dev": 27 | default: 28 | outPath = process.argv[2]; 29 | break; 30 | } 31 | 32 | inPath = inPath || "./src/main.ts"; 33 | outPath = outPath || "./out/main.js"; 34 | outDir = dirname(outPath); 35 | plugins = [ 36 | copy({ 37 | resolveFrom: "cwd", 38 | assets: [ 39 | { from: "./styles.css", to: `${outDir}/styles.css` }, 40 | { from: "./manifest.json", to: `${outDir}/manifest.json` } 41 | ], 42 | watch: mode != "prod" 43 | }) 44 | ]; 45 | 46 | if (mode == "prod") plugins.push(typecheckPlugin()); 47 | await fs.mkdir(outDir, { recursive: true }); 48 | 49 | return esbuild.context({ 50 | entryPoints: [inPath], 51 | bundle: true, 52 | external: ["obsidian", "electron"], 53 | format: "cjs", 54 | target: "es2021", 55 | logLevel: "info", 56 | sourcemap: mode == "prod" ? false : "inline", 57 | treeShaking: true, 58 | minify: mode == "prod", 59 | outfile: outPath, 60 | define: { DEV: (mode !== "prod").toString() }, 61 | plugins: plugins 62 | }); 63 | } 64 | 65 | async function startTests() { 66 | opener("obsidian://nv-testing-restart"); 67 | 68 | setTimeout(() => { 69 | const path = encodeURI(resolve("./tests/vault")) 70 | opener(`obsidian://open?path=${path}`) 71 | }, 500); 72 | } 73 | 74 | const mode = getMode(process.argv[2]); 75 | const context = await generateContext(mode); 76 | 77 | if (mode !== "dev") { 78 | await context.rebuild(); 79 | await context.dispose(); 80 | } 81 | else { 82 | await context.watch(); 83 | } 84 | 85 | if (mode === "test") await startTests(); 86 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "homepage", 3 | "name": "Homepage", 4 | "version": "4.2.2", 5 | "minAppVersion": "1.4.10", 6 | "description": "Open a specified note, canvas, base, or workspace on startup, or set it for quick access later.", 7 | "author": "novov", 8 | "authorUrl": "https://novov.me", 9 | "isDesktopOnly": false, 10 | "fundingUrl": { 11 | "Ko-fi": "https://ko-fi.com/novov" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-homepage", 3 | "version": "4.2.2", 4 | "description": "Open a specified note, canvas, base, or workspace upon launching Obsidian, or set it for quick access later.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node build.mjs", 8 | "build": "node build.mjs production", 9 | "test": "node build.mjs test" 10 | }, 11 | "keywords": [], 12 | "author": "novov", 13 | "license": "MIT", 14 | "dependencies": { 15 | "obsidian": "^1.4.10", 16 | "obsidian-daily-notes-interface": "^0.9.4" 17 | }, 18 | "devDependencies": { 19 | "@jgoz/esbuild-plugin-typecheck": "^3.0.2", 20 | "esbuild": "^0.17.0", 21 | "esbuild-plugin-copy": "^2.1.1", 22 | "opener": "^1.5.2", 23 | "tslib": "^2.3.0", 24 | "typescript": "^5.0", 25 | "@typescript-eslint/eslint-plugin": "^5.2.0", 26 | "@typescript-eslint/parser": "^5.2.0" 27 | }, 28 | "eslintConfig": { 29 | "root": true, 30 | "parser": "@typescript-eslint/parser", 31 | "env": { "node": true }, 32 | "plugins": ["@typescript-eslint"], 33 | "extends": [ 34 | "eslint:recommended", 35 | "plugin:@typescript-eslint/eslint-recommended", 36 | "plugin:@typescript-eslint/recommended" 37 | ], 38 | "parserOptions": { "sourceType": "module" }, 39 | "rules": { 40 | "no-case-declarations": "off", 41 | "no-unused-vars": "off", 42 | "@typescript-eslint/ban-ts-comment": "off", 43 | "@typescript-eslint/no-empty-function": "off", 44 | "@typescript-eslint/no-explicit-any": "off", 45 | "@typescript-eslint/no-inferrable-types": "off", 46 | "@typescript-eslint/no-non-null-assertion": "off", 47 | "@typescript-eslint/no-unused-vars": [ 48 | "error", { "args": "none" } 49 | ] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/dev.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from "obsidian"; 2 | import { DEFAULT_SETTINGS } from "./settings"; 3 | import HomepagePlugin from "./main"; 4 | 5 | type KeyedPluginList = Record; 6 | 7 | (HomepagePlugin.prototype as HomepageDebugPlugin).loadDebugInfo = async function (this: HomepageDebugPlugin, info: HomepageDebugSettings): Promise { 8 | if (info.version !== DEFAULT_SETTINGS.version) console.warn("Version not supported"); 9 | 10 | this.app.vault.config = { 11 | ...this.app.vault.config, 12 | livePreview: info._livePreview !== "default" ? info._livePreview : true, 13 | focusNewTab: info._focusNewTab !== "default" ? info._livePreview : true, 14 | defaultViewMode: info._focusNewTab !== "default" ? info._livePreview : "editing" 15 | }; 16 | 17 | for (const pluginName in this.internalPlugins) { 18 | const plugin = this.internalPlugins[pluginName]; 19 | const present = info._internalPlugins.includes(pluginName); 20 | 21 | if (present) plugin.enable(); 22 | else plugin.disable(); 23 | 24 | console.log( 25 | `${present ? "Enabled" : "Disabled"} internal plugin ${pluginName}` 26 | ); 27 | } 28 | 29 | await this.ensurePlugins(info._plugins, true); 30 | 31 | this.settings = info; 32 | this.saveSettings(); 33 | this.homepage = this.getHomepage(); 34 | 35 | console.log("Settings updated!"); 36 | }; 37 | 38 | (HomepagePlugin.prototype as HomepageDebugPlugin).ensurePlugins = async function (this: HomepageDebugPlugin, plugins: string[], enable: boolean): Promise { 39 | const pluginList = await requestUrl( 40 | `https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json` 41 | ).then(r => r.json); 42 | const pluginRegistry = this.app.plugins; 43 | 44 | const keyedPluginList: KeyedPluginList = {}; 45 | for (const item of pluginList) keyedPluginList[item.id] = item; 46 | 47 | for (const id of plugins) { 48 | if (id === "homepage" || !(id in keyedPluginList)) continue; 49 | 50 | const repo = keyedPluginList[id]?.repo; 51 | const manifest = await requestUrl( 52 | `https://raw.githubusercontent.com/${repo}/HEAD/manifest.json` 53 | ).then(r => r.json); 54 | const version = manifest.version; 55 | 56 | if (version !== pluginRegistry.manifests[id]?.version) { 57 | await pluginRegistry.installPlugin(repo, version, manifest); 58 | } 59 | if (enable) { 60 | await pluginRegistry.loadPlugin(id); 61 | await pluginRegistry.enablePluginAndSave(id); 62 | } 63 | else { 64 | await pluginRegistry.disablePlugin(id); 65 | } 66 | 67 | console.log(`${manifest.name} ${manifest.version} installed for testing`); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /src/homepage.ts: -------------------------------------------------------------------------------- 1 | import { App, FileView, MarkdownView, Notice, View as OView, WorkspaceLeaf, WorkspaceWindow } from "obsidian"; 2 | import HomepagePlugin from "./main"; 3 | import { PERIODIC_KINDS, getAutorun, getJournalNote, getPeriodicNote, hasJournal } from "./periodic"; 4 | import { DEFAULT_DATA } from "./settings"; 5 | import { detachAllLeaves, emptyActiveView, equalsCaseless, hasLayoutChange, randomFile, sleep, trimFile, untrimName } from "./utils"; 6 | 7 | export const LEAF_TYPES: string[] = ["markdown", "canvas", "kanban", "bases"]; 8 | 9 | export const DEFAULT: string = "Main Homepage"; 10 | export const MOBILE: string = "Mobile Homepage"; 11 | 12 | export interface HomepageData { 13 | value: string, 14 | kind: string, 15 | openOnStartup: boolean, 16 | openMode: string, 17 | manualOpenMode: string, 18 | view: string, 19 | revertView: boolean, 20 | openWhenEmpty: boolean, 21 | refreshDataview: boolean, 22 | autoCreate: boolean, 23 | autoScroll: boolean, 24 | pin: boolean, 25 | commands: CommandData[], 26 | alwaysApply: boolean, 27 | hideReleaseNotes: boolean 28 | } 29 | 30 | export interface CommandData { 31 | id: string, 32 | period: Period 33 | } 34 | 35 | export enum Mode { 36 | ReplaceAll = "Replace all open notes", 37 | ReplaceLast = "Replace last note", 38 | Retain = "Keep open notes" 39 | } 40 | 41 | export enum View { 42 | Default = "Default view", 43 | Reading = "Reading view", 44 | Source = "Editing view (Source)", 45 | LivePreview = "Editing view (Live Preview)" 46 | } 47 | 48 | export enum Kind { 49 | File = "File", 50 | Workspace = "Workspace", 51 | Random = "Random file", 52 | RandomFolder = "Random in folder", 53 | Graph = "Graph view", 54 | None = "Nothing", 55 | Journal = "Journal", 56 | DailyNote = "Daily Note", 57 | WeeklyNote = "Weekly Note", 58 | MonthlyNote = "Monthly Note", 59 | YearlyNote = "Yearly Note" 60 | } 61 | 62 | export enum Period { 63 | Both = "Both", 64 | Startup = "Startup only", 65 | Manual = "Manual only" 66 | } 67 | 68 | export const UNCHANGEABLE: Kind[] = [Kind.Random, Kind.Graph, Kind.None, ...PERIODIC_KINDS]; 69 | 70 | export class Homepage { 71 | plugin: HomepagePlugin; 72 | app: App; 73 | data: HomepageData; 74 | 75 | name: string; 76 | lastView?: WeakRef = undefined; 77 | openedViews: WeakMap = new WeakMap(); 78 | computedValue: string; 79 | 80 | constructor(name: string, plugin: HomepagePlugin) { 81 | this.name = name; 82 | this.plugin = plugin; 83 | this.app = plugin.app; 84 | 85 | const data = this.plugin.settings.homepages[name]; 86 | 87 | if (data) this.data = Object.assign({}, DEFAULT_DATA, data); 88 | else { 89 | this.plugin.settings.homepages[name] = { ...DEFAULT_DATA }; 90 | this.data = this.plugin.settings.homepages[name]; 91 | } 92 | } 93 | 94 | async open(alternate: boolean = false): Promise { 95 | if (!this.plugin.hasRequiredPlugin(this.data.kind as Kind)) { 96 | new Notice("Homepage cannot be opened due to plugin unavailablity."); 97 | return; 98 | } 99 | else if (this.data.kind === Kind.Journal && !hasJournal(this)) { 100 | new Notice(`Cannot find the journal "${this.data.value}" to use as the homepage.`); 101 | return; 102 | } 103 | 104 | if (this.data.kind === Kind.Workspace) { 105 | await this.launchWorkspace(); 106 | } 107 | else if (this.data.kind !== Kind.None) { 108 | let mode = this.plugin.loaded ? this.data.manualOpenMode : this.data.openMode; 109 | if (alternate) mode = Mode.Retain; 110 | 111 | await this.launchLeaf(mode as Mode); 112 | } 113 | 114 | if (this.data.commands.length < 1) return; 115 | const disallowed = this.plugin.loaded ? Period.Startup : Period.Manual; 116 | 117 | await hasLayoutChange(this.app); 118 | 119 | for (const {id, period} of this.data.commands) { 120 | if (period === disallowed) continue; 121 | this.app.commands.executeCommandById(id); 122 | } 123 | } 124 | 125 | async launchWorkspace(): Promise { 126 | const workspacePlugin = this.plugin.internalPlugins.workspaces?.instance; 127 | 128 | if(!(this.data.value in workspacePlugin.workspaces)) { 129 | new Notice(`Cannot find the workspace "${this.data.value}" to use as the homepage.`); 130 | return; 131 | } 132 | 133 | workspacePlugin.loadWorkspace(this.data.value); 134 | await sleep(100); 135 | } 136 | 137 | async launchLeaf(mode: Mode): Promise { 138 | let leaf: WorkspaceLeaf | undefined; 139 | 140 | this.computedValue = await this.computeValue(); 141 | this.plugin.executing = true; 142 | 143 | if (getAutorun(this.plugin) && !this.plugin.loaded) { 144 | return; 145 | } 146 | 147 | if (mode !== Mode.ReplaceAll) { 148 | const alreadyOpened = this.getOpened(); 149 | 150 | if (alreadyOpened.length > 0) { 151 | this.app.workspace.setActiveLeaf(alreadyOpened[0]); 152 | await this.configure(alreadyOpened[0]); 153 | return; 154 | } 155 | else if (mode == Mode.Retain && emptyActiveView(this.app)) { 156 | //if there is an empty tab, don't keep it 157 | mode = Mode.ReplaceLast; 158 | } 159 | } 160 | 161 | if (mode !== Mode.Retain) { 162 | this.app.workspace.getActiveViewOfType(OView)?.leaf.setPinned(false); 163 | } 164 | if (mode === Mode.ReplaceAll) { 165 | //The API is very finicky when the app is starting, so wait for things to initialise 166 | if (this.app.workspace?.floatingSplit?.children) { 167 | await sleep(0); 168 | (this.app.workspace.floatingSplit.children as WorkspaceWindow[]).forEach(c => c.win!.close()); 169 | } 170 | 171 | await detachAllLeaves(this.app); 172 | await sleep(0); 173 | } 174 | 175 | if (this.data.kind === Kind.Graph) leaf = await this.launchGraph(mode); 176 | else leaf = await this.launchNote(mode); 177 | if (!leaf) return; 178 | 179 | await this.configure(leaf); 180 | } 181 | 182 | async launchGraph(mode: Mode): Promise { 183 | if (mode === Mode.Retain) { 184 | const leaf = this.app.workspace.getLeaf("tab"); 185 | this.app.workspace.setActiveLeaf(leaf); 186 | } 187 | 188 | this.app.commands.executeCommandById("graph:open"); 189 | return this.app.workspace.getActiveViewOfType(OView)?.leaf; 190 | } 191 | 192 | async launchNote(mode: Mode): Promise { 193 | let file = this.app.metadataCache.getFirstLinkpathDest(this.computedValue, "/"); 194 | 195 | if (!file) { 196 | if (!this.data.autoCreate) { 197 | new Notice(`Homepage "${this.computedValue}" does not exist.`); 198 | return undefined; 199 | } 200 | 201 | file = await this.app.vault.create(untrimName(this.computedValue), ""); 202 | } 203 | 204 | const content = await this.app.vault.cachedRead(file); 205 | const leaf = this.app.workspace.getLeaf(mode == Mode.Retain); 206 | await leaf.openFile(file); 207 | this.app.workspace.setActiveLeaf(leaf); 208 | 209 | if (content !== await this.app.vault.read(file)) { 210 | await this.app.vault.modify(file, content); 211 | } 212 | 213 | return leaf; 214 | } 215 | 216 | async configure(leaf: WorkspaceLeaf): Promise { 217 | this.plugin.executing = false; 218 | const view = leaf.view; 219 | 220 | if (!(view instanceof MarkdownView)) { 221 | if (this.data.pin) view.leaf.setPinned(true); 222 | this.configurePlugins(); 223 | return; 224 | } 225 | 226 | const state = view.getState(); 227 | 228 | if (this.data.revertView) { 229 | this.lastView = new WeakRef(view); 230 | } 231 | 232 | if (this.data.autoScroll) { 233 | const count = view.editor.lineCount(); 234 | 235 | if (state.mode == "preview") { 236 | view.previewMode.applyScroll(count - 4); 237 | } 238 | else { 239 | view.editor.setCursor(count); 240 | view.editor.focus(); 241 | } 242 | } 243 | 244 | if (this.data.pin) view.leaf.setPinned(true); 245 | 246 | if (this.data.view !== View.Default) { 247 | switch(this.data.view) { 248 | case View.LivePreview: 249 | case View.Source: 250 | state.mode = "source"; 251 | state.source = this.data.view != View.LivePreview; 252 | break; 253 | case View.Reading: 254 | state.mode = "preview"; 255 | break; 256 | } 257 | 258 | await view.leaf.setViewState({type: "markdown", state: state}); 259 | } 260 | 261 | this.configurePlugins(); 262 | } 263 | 264 | configurePlugins(): void { 265 | if (this.plugin.loaded && this.data.refreshDataview) { 266 | this.plugin.communityPlugins.dataview?.index.touch(); 267 | } 268 | 269 | this.plugin.communityPlugins["obsidian-file-color"]?.generateColorStyles(); 270 | } 271 | 272 | getOpened(): WorkspaceLeaf[] { 273 | if (this.data.kind == Kind.Graph) return this.app.workspace.getLeavesOfType("graph"); 274 | 275 | const leaves = LEAF_TYPES.flatMap(i => this.app.workspace.getLeavesOfType(i)); 276 | 277 | return leaves.filter(leaf => { 278 | const name = leaf.view.getState().file as string; 279 | return equalsCaseless( 280 | name.endsWith("md") ? name.slice(0, -3) : name, 281 | this.computedValue 282 | ); 283 | }); 284 | } 285 | 286 | async computeValue(): Promise { 287 | let val = this.data.value; 288 | let file; 289 | 290 | switch (this.data.kind) { 291 | case Kind.Random: 292 | file = randomFile(this.app); 293 | if (file) val = file; 294 | break; 295 | case Kind.RandomFolder: 296 | file = randomFile(this.app, val); 297 | if (file) val = file; 298 | break; 299 | case Kind.Journal: 300 | val = await getJournalNote(val, this.plugin); 301 | break; 302 | case Kind.DailyNote: 303 | case Kind.WeeklyNote: 304 | case Kind.MonthlyNote: 305 | case Kind.YearlyNote: 306 | val = await getPeriodicNote(this.data.kind, this.plugin); 307 | break; 308 | } 309 | 310 | return val 311 | } 312 | 313 | async save(): Promise { 314 | this.plugin.settings.homepages[this.name] = this.data; 315 | await this.plugin.saveSettings(); 316 | } 317 | 318 | async setToActiveFile(): Promise { 319 | this.data.value = trimFile(this.app.workspace.getActiveFile()!); 320 | await this.save(); 321 | 322 | new Notice(`The homepage has been changed to "${this.data.value}".`); 323 | } 324 | 325 | canSetToFile(): boolean { 326 | return (this.app.workspace.getActiveFile() !== null && 327 | !UNCHANGEABLE.includes(this.data.kind as Kind)); 328 | } 329 | 330 | async revertView(): Promise { 331 | if (this.lastView == undefined || this.data.view == View.Default) return; 332 | 333 | const view = this.lastView.deref(); 334 | if (!view || equalsCaseless(trimFile(view.file!), this.computedValue)) return; 335 | 336 | const state = view.getState(), 337 | config = this.app.vault.config, 338 | mode = config.defaultViewMode || "source", 339 | source = config.livePreview !== undefined ? !config.livePreview : false; 340 | 341 | if ( 342 | view.leaf.getViewState().type == "markdown" && 343 | (mode != state.mode || source != state.source) 344 | ) { 345 | state.mode = mode; 346 | state.source = source; 347 | await view.leaf.setViewState({ type: "markdown", state: state, active: true }); 348 | } 349 | 350 | this.lastView = undefined; 351 | } 352 | 353 | async openWhenEmpty(): Promise { 354 | if (!this.plugin.loaded || this.plugin.executing) return; 355 | const leaf = this.app.workspace.getActiveViewOfType(OView)?.leaf; 356 | 357 | if ( 358 | leaf?.getViewState().type !== "empty" || 359 | leaf.parentSplit.children.length != 1 360 | ) return 361 | 362 | //should always behave the same regardless of mode 363 | await this.open(true); 364 | } 365 | 366 | async apply(): Promise { 367 | const currentView = this.app.workspace.getActiveViewOfType(FileView); 368 | if (!currentView) return; 369 | 370 | const currentValue = trimFile(currentView.file!); 371 | if (this.openedViews.get(currentView) === currentValue) return; 372 | 373 | this.openedViews.set(currentView, currentValue); 374 | 375 | if ( 376 | currentValue === await this.computeValue() && 377 | this.plugin.loaded && !this.plugin.executing 378 | ) { 379 | await this.configure(currentView.leaf); 380 | } 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Keymap, Platform, Plugin, WorkspaceLeaf, addIcon } from "obsidian"; 2 | import { DEFAULT, MOBILE, Homepage, Kind, Period } from "./homepage"; 3 | import { hasRequiredPeriodicity, LEGACY_MOMENT_KIND, MOMENT_MESSAGE } from "./periodic"; 4 | import { DEFAULT_SETTINGS, HomepageSettings, HomepageSettingTab } from "./settings"; 5 | 6 | declare const DEV: boolean; 7 | if (DEV) import("./dev"); 8 | 9 | const ICON: string = `` 10 | 11 | export default class HomepagePlugin extends Plugin { 12 | homepage: Homepage; 13 | settings: HomepageSettings; 14 | 15 | internalPlugins: Record; 16 | communityPlugins: Record; 17 | newRelease: boolean = false; 18 | 19 | loaded: boolean = false; 20 | executing: boolean = false; 21 | 22 | interstitial: HTMLElement; 23 | 24 | async onload(): Promise { 25 | const layoutReady = this.app.workspace.layoutReady; 26 | if (!layoutReady) this.showInterstitial(); 27 | 28 | this.patchReleaseNotes(); 29 | 30 | this.settings = await this.loadSettings(); 31 | this.internalPlugins = this.app.internalPlugins.plugins; 32 | this.communityPlugins = this.app.plugins.plugins; 33 | this.homepage = this.getHomepage(); 34 | 35 | this.app.workspace.onLayoutReady(async () => { 36 | const openInitially = ( 37 | this.homepage.data.openOnStartup && 38 | !layoutReady && !(await this.hasUrlParams()) 39 | ); 40 | 41 | this.patchNewTabPage(); 42 | 43 | if (openInitially) await this.homepage.open(); 44 | this.loaded = true; 45 | 46 | this.unpatchReleaseNotes(); 47 | this.hideInterstitial(); 48 | }); 49 | 50 | addIcon("homepage", ICON); 51 | this.addRibbonIcon( 52 | "homepage", 53 | "Open homepage", 54 | e => this.homepage.open( 55 | e.button == 1 || e.button == 2 || Keymap.isModifier(e, "Mod") 56 | //right click, middle click, or ctrl/cmd 57 | ) 58 | ) 59 | .setAttribute("id", "nv-homepage-icon"); 60 | 61 | this.registerEvent(this.app.workspace.on("layout-change", this.onLayoutChange)); 62 | this.addSettingTab(new HomepageSettingTab(this.app, this)); 63 | 64 | this.addCommand({ 65 | id: "open-homepage", 66 | name: "Open homepage", 67 | callback: () => this.homepage.open(), 68 | }); 69 | 70 | this.addCommand({ 71 | id: "set-to-active-file", 72 | name: "Set to active file", 73 | checkCallback: checking => { 74 | if (checking) return this.homepage.canSetToFile(); 75 | this.homepage.setToActiveFile(); 76 | } 77 | }); 78 | 79 | if (DEV) window.homepage = this; 80 | } 81 | 82 | async onunload(): Promise { 83 | this.app.workspace.off("layout-change", this.onLayoutChange) 84 | this.unpatchNewTabPage(); 85 | 86 | if (DEV) delete window.homepage; 87 | } 88 | 89 | onLayoutChange = async (): Promise => { 90 | if (this.homepage.data.revertView) await this.homepage.revertView(); 91 | if (this.homepage.data.openWhenEmpty) await this.homepage.openWhenEmpty(); 92 | if (this.homepage.data.alwaysApply) await this.homepage.apply(); 93 | } 94 | 95 | getHomepage(): Homepage { 96 | if (this.settings.separateMobile && Platform.isMobile) { 97 | if (!(MOBILE in this.settings.homepages)) { 98 | this.settings.homepages[MOBILE] = { ...this.settings.homepages?.[DEFAULT] }; 99 | this.settings.homepages[MOBILE].commands = [ ...this.settings.homepages?.[DEFAULT]?.commands ]; 100 | } 101 | 102 | return new Homepage(MOBILE, this); 103 | } 104 | return new Homepage(DEFAULT, this); 105 | } 106 | 107 | async loadSettings(): Promise { 108 | const settingsData: HomepageSettings = await this.loadData(); 109 | 110 | if (settingsData?.version !== 4) { 111 | if (!settingsData) return Object.assign({}, DEFAULT_SETTINGS); 112 | 113 | return this.upgradeSettings(settingsData); 114 | } 115 | 116 | return settingsData; 117 | } 118 | 119 | async saveSettings(): Promise { 120 | await this.saveData(this.settings); 121 | } 122 | 123 | showInterstitial(): void { 124 | this.interstitial = createDiv({ cls: "nv-homepage-interstitial" }); 125 | document.body.append(this.interstitial); 126 | window.addEventListener("error", this.hideInterstitial); 127 | } 128 | 129 | hideInterstitial = (): void => { 130 | this.interstitial?.detach(); 131 | window.removeEventListener("error", this.hideInterstitial); 132 | } 133 | 134 | async hasUrlParams(): Promise { 135 | let action: string, params: Array; 136 | 137 | if (Platform.isMobile) { 138 | const launchUrl = await window.Capacitor.Plugins.App.getLaunchUrl(); 139 | if (!launchUrl) return false; 140 | 141 | const url = new URL(launchUrl.url); 142 | params = Array.from(url.searchParams.keys()); 143 | action = url.hostname; 144 | } 145 | else if (window.OBS_ACT) { 146 | params = Object.keys(window.OBS_ACT); 147 | action = window.OBS_ACT.action 148 | } 149 | else return false; 150 | 151 | return ( 152 | ["open", "advanced-uri"].includes(action) && 153 | ["file", "filepath", "workspace"].some(e => params.includes(e)) 154 | ) 155 | } 156 | 157 | hasRequiredPlugin(kind: Kind): boolean { 158 | switch (kind) { 159 | case Kind.Workspace: 160 | return this.internalPlugins["workspaces"]?.enabled; 161 | case Kind.Graph: 162 | return this.internalPlugins["graph"]?.enabled; 163 | case Kind.Journal: 164 | return this.communityPlugins["journals"]; 165 | case Kind.DailyNote: 166 | case Kind.WeeklyNote: 167 | case Kind.MonthlyNote: 168 | case Kind.YearlyNote: 169 | return hasRequiredPeriodicity(kind, this); 170 | default: 171 | return true; 172 | } 173 | } 174 | 175 | patchNewTabPage(): void { 176 | const ntp = this.communityPlugins["new-tab-default-page"]; 177 | if (!ntp) return; 178 | 179 | ntp.nvOrig_checkForNewTab = ntp.checkForNewTab; 180 | ntp.checkForNewTab = async (e: WeakSet) => { 181 | if (this && this.executing) { return; } 182 | return await ntp.nvOrig_checkForNewTab(e); 183 | }; 184 | } 185 | 186 | unpatchNewTabPage(): void { 187 | const ntp = this.communityPlugins["new-tab-default-page"]; 188 | if (!ntp) return; 189 | 190 | ntp.checkForNewTab = ntp._checkForNewTab; 191 | } 192 | 193 | patchReleaseNotes(): void { 194 | this.app.nvOrig_showReleaseNotes = this.app.showReleaseNotes; 195 | this.app.showReleaseNotes = () => this.newRelease = true; 196 | } 197 | 198 | unpatchReleaseNotes(): void { 199 | if (this.newRelease && !this.homepage.data.hideReleaseNotes) { 200 | this.app.nvOrig_showReleaseNotes(); 201 | } 202 | 203 | this.app.showReleaseNotes = this.app.nvOrig_showReleaseNotes; 204 | } 205 | 206 | upgradeSettings(data: any): HomepageSettings { 207 | if (data.version == 3) { 208 | const settings = data as HomepageSettings; 209 | let hasMoment = false; 210 | 211 | for (const homepage of Object.values(settings.homepages)) { 212 | homepage.commands = (homepage.commands as unknown as string[]).map(id => { 213 | return { id: id, period: Period.Both } 214 | }); 215 | 216 | if (homepage.kind == LEGACY_MOMENT_KIND) { 217 | hasMoment = true; 218 | homepage.kind = Kind.DailyNote; 219 | } 220 | } 221 | 222 | if (hasMoment) new Notice(MOMENT_MESSAGE); 223 | settings.version = 4; 224 | 225 | this.saveData(settings); 226 | return settings; 227 | } 228 | 229 | const settings: HomepageSettings = Object.assign({}, DEFAULT_SETTINGS); 230 | 231 | if (data.workspaceEnabled) { 232 | data.value = data.workspace; 233 | data.kind = Kind.Workspace; 234 | } 235 | else if (data.momentFormat) { 236 | data.kind = Kind.DailyNote; 237 | new Notice(MOMENT_MESSAGE); 238 | } 239 | else { 240 | data.value = data.defaultNote; 241 | data.kind = Kind.File; 242 | } 243 | 244 | data.commands = []; 245 | 246 | delete data.workspace; 247 | delete data.momentFormat; 248 | delete data.defaultNote; 249 | delete data.useMoment; 250 | delete data.workspaceEnabled; 251 | settings.homepages[DEFAULT] = data; 252 | 253 | this.saveData(settings); 254 | return settings; 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/periodic.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin, TFile, moment } from "obsidian"; 2 | import HomepagePlugin from "./main"; 3 | import { Homepage, Kind } from "./homepage"; 4 | import { trimFile } from "./utils"; 5 | import { 6 | createDailyNote, getDailyNote, getAllDailyNotes, 7 | createWeeklyNote, getMonthlyNote, getAllMonthlyNotes, 8 | createMonthlyNote, getWeeklyNote, getAllWeeklyNotes, 9 | createYearlyNote, getYearlyNote, getAllYearlyNotes 10 | } from "obsidian-daily-notes-interface"; 11 | 12 | const JOURNAL_CUSTOM_LOCALE = "custom-journal-locale"; 13 | 14 | interface KindInfo { 15 | noun: string, 16 | adjective: string, 17 | create: (date: moment.Moment) => Promise, 18 | get: (date: moment.Moment, dailyNotes: Record) => TFile, 19 | getAll: () => Record 20 | } 21 | 22 | export const PERIODIC_INFO: Record = { 23 | [Kind.DailyNote]: { 24 | noun: "day", 25 | adjective: "daily", 26 | create: createDailyNote, 27 | get: getDailyNote, 28 | getAll: getAllDailyNotes 29 | }, 30 | [Kind.WeeklyNote]: { 31 | noun: "week", 32 | adjective: "weekly", 33 | create: createWeeklyNote, 34 | get: getWeeklyNote, 35 | getAll: getAllWeeklyNotes 36 | }, 37 | [Kind.MonthlyNote]: { 38 | noun: "month", 39 | adjective: "monthly", 40 | create: createMonthlyNote, 41 | get: getMonthlyNote, 42 | getAll: getAllMonthlyNotes 43 | }, 44 | [Kind.YearlyNote]: { 45 | noun: "year", 46 | adjective: "yearly", 47 | create: createYearlyNote, 48 | get: getYearlyNote, 49 | getAll: getAllYearlyNotes 50 | } 51 | }; 52 | 53 | export const PERIODIC_KINDS: Kind[] = [ 54 | Kind.DailyNote, Kind.WeeklyNote, Kind.MonthlyNote, Kind.YearlyNote 55 | ]; 56 | 57 | export const LEGACY_MOMENT_KIND: string = "Date-dependent file"; 58 | export const MOMENT_MESSAGE: string = "Date-dependent notes in Homepage have been removed. Set your Homepage as a Periodic or Daily Note instead."; 59 | 60 | export async function getPeriodicNote(kind: Kind, plugin: HomepagePlugin): Promise { 61 | const periodicNotes = plugin.communityPlugins["periodic-notes"], 62 | info = PERIODIC_INFO[kind], 63 | date = moment().startOf(info.noun as moment.unitOfTime.StartOf); 64 | let note; 65 | 66 | if (isLegacyPeriodicNotes(periodicNotes)) { 67 | const all = info.getAll(); 68 | 69 | if (!Object.keys(all).length) { 70 | note = await info.create(date); 71 | } 72 | else { 73 | note = info.get(date, all) || await info.create(date); 74 | } 75 | 76 | if (!note) note = info.get(date, all); 77 | } 78 | else { 79 | periodicNotes.cache.initialize(); 80 | note = ( 81 | periodicNotes.getPeriodicNote(info.noun, date) || 82 | await periodicNotes.createPeriodicNote(info.noun, date) 83 | ); 84 | } 85 | 86 | return trimFile(note); 87 | } 88 | 89 | export function hasRequiredPeriodicity(kind: Kind, plugin: HomepagePlugin): boolean { 90 | if (kind == Kind.DailyNote && plugin.internalPlugins["daily-notes"]?.enabled) return true; 91 | 92 | const periodicNotes = plugin.communityPlugins["periodic-notes"]; 93 | if (!periodicNotes) return false; 94 | 95 | if (isLegacyPeriodicNotes(periodicNotes)) { 96 | const adjective = PERIODIC_INFO[kind].adjective; 97 | return periodicNotes.settings[adjective]?.enabled; 98 | } 99 | else { 100 | const noun = PERIODIC_INFO[kind].noun; 101 | return periodicNotes?.calendarSetManager?.getActiveSet()[noun]?.enabled; 102 | } 103 | } 104 | 105 | export function getAutorun(plugin: HomepagePlugin): boolean { 106 | const dailyNotes = plugin.internalPlugins["daily-notes"]; 107 | return dailyNotes?.enabled && dailyNotes?.instance.options.autorun; 108 | } 109 | 110 | function isLegacyPeriodicNotes(periodicNotes: Plugin): boolean { 111 | return (periodicNotes?.manifest.version || "0").startsWith("0"); 112 | } 113 | 114 | export function hasJournal(homepage: Homepage): boolean { 115 | const journals = homepage.plugin.communityPlugins["journals"]; 116 | return !!journals.getJournal(homepage.data.value); 117 | } 118 | 119 | export async function getJournalNote(journalName: string, plugin: HomepagePlugin) { 120 | const journals = plugin.communityPlugins["journals"]; 121 | const journal = journals.getJournal(journalName); 122 | const origAutoCreate = journal.config.value.autoCreate; 123 | 124 | // this is hacky, but the core logic is in private methods 125 | journals.reprocessNotes(); 126 | journal.config.value.autoCreate = true; 127 | await journal.autoCreate(); 128 | journal.config.value.autoCreate = origAutoCreate; 129 | 130 | const today = moment().locale(JOURNAL_CUSTOM_LOCALE).startOf("day"); 131 | const path = journal.getNotePath(journal?.get(today)); 132 | 133 | return path.replace(/\.md$/, ""); 134 | } 135 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, ButtonComponent, Notice, Platform, PluginSettingTab, Setting, normalizePath } from "obsidian"; 2 | import HomepagePlugin from "./main"; 3 | import { UNCHANGEABLE, HomepageData, Kind, Mode, View } from "./homepage"; 4 | import { PERIODIC_KINDS, getAutorun } from "./periodic"; 5 | import { SUGGESTORS, CommandBox } from "./ui"; 6 | 7 | type HomepageKey = { [K in keyof HomepageData]: HomepageData[K] extends T ? K : never }[keyof HomepageData]; 8 | type HomepageObject = { [key: string]: HomepageData } 9 | type Callback = (v: T) => void; 10 | 11 | export interface HomepageSettings { 12 | version: number, 13 | homepages: HomepageObject, 14 | separateMobile: boolean 15 | } 16 | 17 | export const DEFAULT_SETTINGS: HomepageSettings = { 18 | version: 4, 19 | homepages: {}, 20 | separateMobile: false 21 | } 22 | 23 | export const DEFAULT_DATA: HomepageData = { 24 | value: "Home", 25 | kind: Kind.File, 26 | openOnStartup: true, 27 | openMode: Mode.ReplaceAll, 28 | manualOpenMode: Mode.Retain, 29 | view: View.Default, 30 | revertView: true, 31 | openWhenEmpty: false, 32 | refreshDataview: false, 33 | autoCreate: false, 34 | autoScroll: false, 35 | pin: false, 36 | commands: [], 37 | alwaysApply: false, 38 | hideReleaseNotes: false 39 | }; 40 | 41 | const DESCRIPTIONS = { 42 | [Kind.File]: "Enter a note, base, or canvas to use.", 43 | [Kind.Workspace]: "Enter an Obsidian workspace to use.", 44 | [Kind.Graph]: "Your graph view will be used.", 45 | [Kind.None]: "Nothing will occur by default. Any commands added will still take effect.", 46 | [Kind.Random]: "A random note, base, or canvas from your Obsidian folder will be selected.", 47 | [Kind.RandomFolder]: "Enter a folder. A random note, base, or canvas from it will be selected.", 48 | [Kind.Journal]: "Enter a Journal to use.", 49 | [Kind.DailyNote]: "Your Daily Note or Periodic Daily Note will be used.", 50 | [Kind.WeeklyNote]: "Your Periodic Weekly Note will be used.", 51 | [Kind.MonthlyNote]: "Your Periodic Monthly Note will be used.", 52 | [Kind.YearlyNote]: "Your Periodic Yearly Note will be used." 53 | } 54 | 55 | export class HomepageSettingTab extends PluginSettingTab { 56 | plugin: HomepagePlugin; 57 | settings: HomepageSettings; 58 | elements: Record; 59 | 60 | commandBox: CommandBox; 61 | 62 | constructor(app: App, plugin: HomepagePlugin) { 63 | super(app, plugin); 64 | this.plugin = plugin; 65 | this.settings = plugin.settings; 66 | 67 | this.plugin.addCommand({ 68 | id: "copy-debug-info", 69 | name: "Copy debug info", 70 | callback: async () => await this.copyDebugInfo() 71 | }); 72 | } 73 | 74 | sanitiseNote(value: string): string | null { 75 | if (value === null || value.match(/^\s*$/) !== null) { 76 | return null; 77 | } 78 | return normalizePath(value); 79 | } 80 | 81 | display(): void { 82 | const kind = this.plugin.homepage.data.kind as Kind; 83 | const autorun = getAutorun(this.plugin); 84 | 85 | let pluginDisabled = false; 86 | let suggestor = SUGGESTORS[kind]; 87 | 88 | this.containerEl.empty(); 89 | this.elements = {}; 90 | 91 | const mainSetting = new Setting(this.containerEl) 92 | .setName("Homepage") 93 | .addDropdown(async dropdown => { 94 | for (const key of Object.values(Kind)) { 95 | if (!this.plugin.hasRequiredPlugin(key)) { 96 | if (key == this.plugin.homepage.data.kind) pluginDisabled = true; 97 | else { 98 | dropdown.selectEl.createEl( 99 | "option", { text: key, attr: { disabled: true } } 100 | ); 101 | continue; 102 | } 103 | } 104 | 105 | dropdown.addOption(key, key); 106 | } 107 | dropdown.setValue(this.plugin.homepage.data.kind); 108 | dropdown.onChange(async option => { 109 | this.plugin.homepage.data.kind = option; 110 | if (option == Kind.Random) this.plugin.homepage.data.value = ""; 111 | 112 | await this.plugin.homepage.save(); 113 | this.display(); 114 | }); 115 | }); 116 | 117 | mainSetting.settingEl.id = "nv-main-setting"; 118 | 119 | const descContainer = mainSetting.settingEl.createEl("article", { 120 | "text": DESCRIPTIONS[kind], 121 | "attr": { "id": "nv-desc" } 122 | }) 123 | 124 | if (pluginDisabled) { 125 | descContainer.createDiv({ 126 | text: `The plugin required for this homepage type isn't available.`, 127 | cls: "mod-warning" 128 | }); 129 | } 130 | 131 | if (UNCHANGEABLE.includes(kind)) { 132 | mainSetting.addText(text => { 133 | text.setDisabled(true); 134 | }); 135 | } 136 | else { 137 | mainSetting.addText(text => { 138 | new suggestor!(this.app, text.inputEl); 139 | text.setPlaceholder(DEFAULT_DATA.value) 140 | text.setValue(DEFAULT_DATA.value == this.plugin.homepage.data.value ? "" : this.plugin.homepage.data.value) 141 | text.onChange(async (value) => { 142 | this.plugin.homepage.data.value = this.sanitiseNote(value) || DEFAULT_DATA.value; 143 | await this.plugin.homepage.save(); 144 | }); 145 | }); 146 | } 147 | 148 | this.addToggle( 149 | "Open on startup", "When launching Obsidian, open the homepage.", 150 | "openOnStartup", 151 | (_) => this.display() 152 | ); 153 | 154 | if (autorun) { 155 | this.elements.openOnStartup.descEl.createDiv({ 156 | text: `This setting has been disabled, as it isn't compatible with Daily Notes' "Open daily note on startup" functionality. To use it, disable the Daily Notes setting.`, 157 | attr: {class: "mod-warning"} 158 | }); 159 | this.disableSetting("openOnStartup"); 160 | } 161 | 162 | this.addToggle( 163 | "Open when empty", "When there are no tabs open, open the homepage.", 164 | "openWhenEmpty" 165 | ); 166 | this.addToggle( 167 | "Use when opening normally", "Use homepage settings when opening it normally, such as from a link or the file browser.", 168 | "alwaysApply" 169 | ); 170 | 171 | const separateMobileSetting = new Setting(this.containerEl) 172 | .setName("Separate mobile homepage") 173 | .setDesc("For mobile devices, store the homepage and its settings separately.") 174 | .addToggle(toggle => toggle 175 | .setValue(this.plugin.settings.separateMobile) 176 | .onChange(async value => { 177 | this.plugin.settings.separateMobile = value; 178 | this.plugin.homepage = this.plugin.getHomepage(); 179 | await this.plugin.saveSettings(); 180 | this.display(); 181 | }) 182 | ); 183 | 184 | if (this.plugin.settings.separateMobile) { 185 | const keyword = Platform.isMobile ? "desktop" : "mobile"; 186 | const mobileInfo = document.createElement("div"); 187 | 188 | separateMobileSetting.setClass("nv-mobile-setting"); 189 | mobileInfo.className = "mod-warning nv-mobile-info"; 190 | mobileInfo.innerHTML = `Mobile settings are stored separately. Therefore, changes to other settings will not affect 191 | ${keyword} devices. To edit ${keyword} settings, use a ${keyword} device.` 192 | 193 | separateMobileSetting.settingEl.append(mobileInfo); 194 | } 195 | 196 | this.addHeading("Commands", "commandsHeading"); 197 | this.containerEl.createDiv({ 198 | cls: "nv-command-desc setting-item-description", 199 | text: "Select commands that will be executed when opening the homepage." } 200 | ); 201 | this.commandBox = new CommandBox(this); 202 | 203 | this.addHeading("Vault environment", "vaultHeading"); 204 | this.addDropdown( 205 | "Opening method", "Determine how extant tabs and views are affected on startup.", 206 | "openMode", 207 | Mode 208 | ); 209 | this.addDropdown( 210 | "Manual opening method", "Determine how extant tabs and views are affected when opening with commands or the ribbon button.", 211 | "manualOpenMode", 212 | Mode 213 | ); 214 | this.addToggle("Pin", "Pin the homepage when opening.", "pin"); 215 | this.addToggle("Hide release notes", "Never display release notes when Obsidian updates.", "hideReleaseNotes"); 216 | this.addToggle("Auto-create", "When the homepage doesn't exist, create a note with its name.", "autoCreate"); 217 | 218 | this.elements.autoCreate.descEl.createDiv({ 219 | text: `If this vault is synced using unofficial services, this may lead to content being overwritten.`, 220 | cls: "mod-warning" 221 | }); 222 | 223 | this.addHeading("Opened view", "paneHeading"); 224 | this.addDropdown( 225 | "Homepage view", "Choose what view to open the homepage in.", 226 | "view", 227 | View 228 | ); 229 | this.addToggle( 230 | "Revert view on close", "When navigating away from the homepage, restore the default view.", 231 | "revertView" 232 | ); 233 | this.addToggle("Auto-scroll", "When opening the homepage, scroll to the bottom and focus on the last line.", "autoScroll"); 234 | 235 | if ("dataview" in this.plugin.communityPlugins) { 236 | this.addToggle( 237 | "Refresh Dataview", "Always attempt to reload Dataview views when opening the homepage.", "refreshDataview" 238 | ); 239 | 240 | this.elements.refreshDataview.descEl.createDiv({ 241 | text: "Requires Dataview auto-refresh to be enabled.", attr: {class: "mod-warning"} 242 | }); 243 | } 244 | 245 | if (!Platform.isMobile) { 246 | new ButtonComponent(this.containerEl) 247 | .setButtonText("Copy debug info") 248 | .setClass("nv-debug-button") 249 | .onClick(async () => await this.copyDebugInfo()); 250 | } 251 | 252 | if ([Kind.Workspace, Kind.None].includes(kind)) { 253 | this.disableSettings("openWhenEmpty", "alwaysApply", "vaultHeading", "openMode", "manualOpenMode", "autoCreate", "pin"); 254 | } 255 | if ([Kind.Workspace, Kind.None, Kind.Graph].includes(kind)) { 256 | this.disableSettings("paneHeading", "view", "revertView", "autoScroll", "refreshDataview"); 257 | } 258 | if (!this.plugin.homepage.data.openOnStartup || autorun) this.disableSetting("openMode"); 259 | if (PERIODIC_KINDS.includes(kind as Kind) || kind === Kind.Journal) this.disableSetting("autoCreate"); 260 | } 261 | 262 | disableSetting(setting: string): void { 263 | this.elements[setting]?.settingEl.setAttribute("nv-greyed", ""); 264 | } 265 | 266 | disableSettings(...settings: string[]): void { 267 | settings.forEach(s => this.disableSetting(s)); 268 | } 269 | 270 | addHeading(name: string, setting: string): void { 271 | const heading = new Setting(this.containerEl).setHeading().setName(name); 272 | this.elements[setting] = heading; 273 | } 274 | 275 | addDropdown(name: string, desc: string, setting: HomepageKey, source: object, callback?: Callback): Setting { 276 | const dropdown = new Setting(this.containerEl) 277 | .setName(name).setDesc(desc) 278 | .addDropdown(async dropdown => { 279 | for (const key of Object.values(source)) { 280 | dropdown.addOption(key, key); 281 | } 282 | dropdown.setValue(this.plugin.homepage.data[setting]); 283 | dropdown.onChange(async option => { 284 | this.plugin.homepage.data[setting] = option; 285 | await this.plugin.homepage.save(); 286 | if (callback) callback(option); 287 | }); 288 | }); 289 | 290 | this.elements[setting] = dropdown; 291 | return dropdown; 292 | } 293 | 294 | addToggle(name: string, desc: string, setting: HomepageKey, callback?: Callback): Setting { 295 | const toggle = new Setting(this.containerEl) 296 | .setName(name).setDesc(desc) 297 | .addToggle(toggle => toggle 298 | .setValue(this.plugin.homepage.data[setting]) 299 | .onChange(async value => { 300 | this.plugin.homepage.data[setting] = value; 301 | await this.plugin.homepage.save(); 302 | if (callback) callback(value); 303 | }) 304 | ); 305 | 306 | this.elements[setting] = toggle; 307 | return toggle; 308 | } 309 | 310 | async copyDebugInfo(): Promise { 311 | const config = this.app.vault.config; 312 | const info = { 313 | ...this.settings, 314 | _defaultViewMode: config.defaultViewMode || "default", 315 | _livePreview: config.livePreview !== undefined ? config.livePreview : "default", 316 | _focusNewTab: config.focusNewTab !== undefined ? config.focusNewTab : "default", 317 | _plugins: Object.keys(this.plugin.communityPlugins), 318 | _internalPlugins: Object.values(this.plugin.internalPlugins).flatMap( 319 | p => p.enabled ? [p.instance.id] : [] 320 | ), 321 | _obsidianVersion: window.electron?.ipcRenderer.sendSync("version") || "unknown" 322 | }; 323 | 324 | await navigator.clipboard.writeText(JSON.stringify(info)); 325 | new Notice("Copied homepage debug information to clipboard"); 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import "obsidian"; 2 | import HomepagePlugin from "./main"; 3 | import { HomepageSettings } from "./settings"; 4 | 5 | declare module "obsidian" { 6 | interface App { 7 | commands: CommandRegistry; 8 | plugins: PluginRegistry; 9 | internalPlugins: PluginRegistry; 10 | setting: any; 11 | showReleaseNotes: () => void; 12 | nvOrig_showReleaseNotes: () => void; 13 | } 14 | 15 | interface CommandRegistry { 16 | findCommand: (id: string) => Command; 17 | executeCommandById: (id: string) => void; 18 | commands: Record; 19 | } 20 | 21 | interface PluginRegistry { 22 | manifests: Record; 23 | plugins: Record; 24 | enablePluginAndSave: (id: string) => Promise; 25 | loadPlugin: (id: string) => Promise; 26 | disablePlugin: (id: string) => Promise; 27 | disablePluginAndSave: (id: string) => Promise; 28 | installPlugin: (repo: string, version: string, manifest: PluginManifest) => Promise; 29 | } 30 | 31 | interface Vault { 32 | config: Record; 33 | } 34 | 35 | interface Workspace { 36 | floatingSplit: WorkspaceSplit 37 | } 38 | 39 | interface WorkspaceItem { 40 | children: WorkspaceItem[]; 41 | } 42 | 43 | interface WorkspaceLeaf { 44 | parentSplit: WorkspaceSplit; 45 | } 46 | 47 | interface WorkspaceMobileDrawer { 48 | addHeaderButton(name: string, callback: Function): Element; 49 | updateInfo(): void; 50 | } 51 | 52 | interface WorkspaceSplit { 53 | children: WorkspaceItem[]; 54 | direction: SplitDirection; 55 | } 56 | } 57 | 58 | declare global { 59 | interface Window { 60 | OBS_ACT: string | any; 61 | Capacitor: any; 62 | electron: any; 63 | electronWindow: any; 64 | homepage?: HomepagePlugin; 65 | } 66 | 67 | interface URLSearchParams { 68 | keys: () => Iterable 69 | } 70 | 71 | interface HomepageDebugPlugin extends HomepagePlugin { 72 | loadDebugInfo: (info: HomepageDebugSettings) => Promise; 73 | ensurePlugins: (plugins: string[], enable: boolean) => Promise; 74 | } 75 | 76 | interface HomepageDebugSettings extends HomepageSettings { 77 | _livePreview: string; 78 | _focusNewTab: string | boolean; 79 | _internalPlugins: string[]; 80 | _plugins: string[]; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/ui.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, AbstractInputSuggest, ButtonComponent, Command, FuzzySuggestModal, 3 | Menu, Notice, TAbstractFile, TFile, TFolder, getIcon, setTooltip 4 | } from "obsidian"; 5 | import { CommandData, Homepage, Kind, Period } from "./homepage"; 6 | import { HomepageSettingTab } from "./settings"; 7 | import { trimFile } from "./utils"; 8 | 9 | type Suggestor = typeof FileSuggest | typeof FolderSuggest | typeof JournalSuggest | typeof WorkspaceSuggest; 10 | 11 | class FileSuggest extends AbstractInputSuggest { 12 | textInputEl: HTMLInputElement; 13 | 14 | getSuggestions(inputStr: string): TFile[] { 15 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 16 | const files: TFile[] = []; 17 | const inputLower = inputStr.toLowerCase(); 18 | 19 | abstractFiles.forEach((file: TAbstractFile) => { 20 | if ( 21 | file instanceof TFile && ["md", "canvas", "base"].contains(file.extension) && 22 | file.path.toLowerCase().contains(inputLower) 23 | ) { 24 | files.push(file); 25 | } 26 | }); 27 | 28 | return files; 29 | } 30 | 31 | renderSuggestion(file: TFile, el: HTMLElement) { 32 | if (file.extension == "md") { 33 | el.setText(trimFile(file)); 34 | } 35 | else { 36 | //we don't use trimFile here as the extension isn't displayed here 37 | el.setText(file.path.split(".").slice(0, -1).join(".")) 38 | el.insertAdjacentHTML( 39 | "beforeend", 40 | `` 41 | ); 42 | } 43 | } 44 | 45 | selectSuggestion(file: TFile) { 46 | this.textInputEl.value = trimFile(file); 47 | this.textInputEl.trigger("input"); 48 | this.close(); 49 | } 50 | } 51 | 52 | class FolderSuggest extends AbstractInputSuggest { 53 | textInputEl: HTMLInputElement; 54 | 55 | getSuggestions(inputStr: string): TFolder[] { 56 | const inputLower = inputStr.toLowerCase(); 57 | 58 | return this.app.vault.getAllFolders().filter(f => 59 | f.path.toLowerCase().contains(inputLower) 60 | ); 61 | } 62 | 63 | renderSuggestion(folder: TFolder, el: HTMLElement) { 64 | el.setText(folder.path); 65 | } 66 | 67 | selectSuggestion(folder: TFolder) { 68 | this.textInputEl.value = folder.path; 69 | this.textInputEl.trigger("input"); 70 | this.close(); 71 | } 72 | } 73 | 74 | 75 | class WorkspaceSuggest extends AbstractInputSuggest { 76 | textInputEl: HTMLInputElement; 77 | 78 | getSuggestions(inputStr: string): string[] { 79 | const workspaces = Object.keys(this.app.internalPlugins.plugins.workspaces?.instance.workspaces); 80 | const inputLower = inputStr.toLowerCase(); 81 | 82 | return workspaces.filter((workspace: string) => workspace.toLowerCase().contains(inputLower)); 83 | } 84 | 85 | renderSuggestion(workspace: string, el: HTMLElement) { 86 | el.setText(workspace); 87 | } 88 | 89 | selectSuggestion(workspace: string) { 90 | this.textInputEl.value = workspace; 91 | this.textInputEl.trigger("input"); 92 | this.close(); 93 | } 94 | } 95 | 96 | class JournalSuggest extends AbstractInputSuggest { 97 | textInputEl: HTMLInputElement; 98 | 99 | getSuggestions(inputStr: string): string[] { 100 | const journals: string[] = this.app.plugins.plugins.journals.journals.map( 101 | (a: any): string => a.name 102 | ); 103 | const inputLower = inputStr.toLowerCase(); 104 | 105 | return journals.filter(j => j.toLowerCase().contains(inputLower)); 106 | } 107 | 108 | renderSuggestion(journal: string, el: HTMLElement) { 109 | el.setText(journal); 110 | } 111 | 112 | selectSuggestion(workspace: string) { 113 | this.textInputEl.value = workspace; 114 | this.textInputEl.trigger("input"); 115 | this.close(); 116 | } 117 | } 118 | 119 | export const SUGGESTORS: Partial> = { 120 | [Kind.File]: FileSuggest, 121 | [Kind.Workspace]: WorkspaceSuggest, 122 | [Kind.RandomFolder]: FolderSuggest, 123 | [Kind.Journal]: JournalSuggest 124 | } 125 | 126 | export class CommandBox { 127 | app: App; 128 | homepage: Homepage; 129 | tab: HomepageSettingTab; 130 | 131 | container: HTMLElement; 132 | dropzone: HTMLElement; 133 | activeDrag: HTMLElement | null; 134 | activeCommand: CommandData | null; 135 | 136 | constructor(tab: HomepageSettingTab) { 137 | this.app = tab.plugin.app; 138 | this.homepage = tab.plugin.homepage; 139 | this.tab = tab; 140 | 141 | this.container = tab.containerEl.createDiv({ cls: "nv-command-box" }); 142 | this.dropzone = document.createElement("div"); 143 | 144 | this.dropzone.className = "nv-command-pill nv-dropzone"; 145 | this.dropzone.addEventListener("dragenter", e => e.preventDefault()); 146 | this.dropzone.addEventListener("dragover", e => e.preventDefault()); 147 | this.dropzone.addEventListener("drop", () => this.terminateDrag()); 148 | 149 | this.update(); 150 | } 151 | 152 | update(): void { 153 | this.container.innerHTML = ""; 154 | this.activeDrag = null; 155 | this.activeCommand = null; 156 | 157 | for (const command of this.homepage.data.commands) { 158 | const appCommand = this.app.commands.findCommand(command.id); 159 | const pill = this.container.createDiv({ 160 | cls: "nv-command-pill", 161 | attr: { draggable: true, } 162 | }); 163 | 164 | pill.addEventListener("dragstart", event => { 165 | event.dataTransfer!.effectAllowed = "move"; 166 | 167 | this.activeCommand = this.homepage.data.commands.splice(this.indexOf(pill), 1)[0]; 168 | this.activeDrag = pill; 169 | 170 | this.dropzone.style.width = `${pill.clientWidth}px`; 171 | this.dropzone.style.height = `${pill.clientHeight}px`; 172 | }); 173 | 174 | pill.addEventListener("dragover", e => this.moveDropzone(pill, e)); 175 | pill.addEventListener("drop", e => e.preventDefault()); 176 | pill.addEventListener("dragend", () => this.terminateDrag()); 177 | 178 | pill.createSpan({ 179 | cls: "nv-command-text", 180 | text: appCommand?.name ?? command.id, 181 | }); 182 | 183 | const periodButton = new ButtonComponent(pill) 184 | .setIcon("route") 185 | .setClass("clickable-icon") 186 | .setClass("nv-command-period") 187 | .onClick(e => this.showMenu(command, e, periodButton)); 188 | 189 | if (command.period != Period.Both) { 190 | periodButton.setClass("nv-command-selected"); 191 | periodButton.setIcon(""); 192 | 193 | periodButton.buttonEl.createSpan({ text: command.period }); 194 | } 195 | 196 | new ButtonComponent(pill) 197 | .setIcon("trash-2") 198 | .setClass("clickable-icon") 199 | .setClass("nv-command-delete") 200 | .onClick(() => this.delete(command)); 201 | 202 | if (!appCommand) { 203 | pill.classList.add("nv-command-invalid"); 204 | pill.prepend(getIcon("ban")!); 205 | 206 | setTooltip(pill, 207 | "This command can't be found, so it won't be executed." 208 | + " It may belong to a disabled plugin.", 209 | { delay: 0.001 } 210 | ); 211 | } 212 | } 213 | 214 | new ButtonComponent(this.container) 215 | .setClass("nv-command-add-button") 216 | .setButtonText("Add...") 217 | .onClick(() => { 218 | const modal = new CommandSuggestModal(this.tab); 219 | modal.open(); 220 | }); 221 | } 222 | 223 | delete(command: CommandData): void { 224 | this.homepage.data.commands.remove(command); 225 | this.homepage.save(); 226 | this.update(); 227 | } 228 | 229 | showMenu(command: CommandData, event: MouseEvent, button: ButtonComponent): void { 230 | const menu = new Menu(); 231 | 232 | for (const key of Object.values(Period)) { 233 | menu.addItem(item => { 234 | item.setTitle(key as string); 235 | item.setChecked(command.period == key); 236 | item.onClick(() => { 237 | command.period = key; 238 | this.homepage.save(); 239 | this.update(); 240 | }); 241 | }); 242 | } 243 | 244 | const rect = button.buttonEl.getBoundingClientRect(); 245 | menu.showAtPosition({ x: rect.x - 22, y: rect.y + rect.height + 8 }); 246 | } 247 | 248 | indexOf(pill: HTMLElement): number { 249 | return Array.from(this.container.children).indexOf(pill); 250 | } 251 | 252 | moveDropzone(anchor: HTMLElement, event: DragEvent): void { 253 | if (!this.activeDrag) return; 254 | 255 | this.activeDrag.hidden = true; 256 | const rect = anchor.getBoundingClientRect(); 257 | 258 | if (event.x < rect.left + (rect.width / 2)) { 259 | this.container.insertBefore(this.dropzone, anchor); 260 | } 261 | else { 262 | this.container.insertAfter(this.dropzone, anchor); 263 | } 264 | 265 | event.preventDefault(); 266 | } 267 | 268 | terminateDrag(): void { 269 | if (!this.activeCommand) return; 270 | 271 | this.homepage.data.commands.splice( 272 | this.indexOf(this.dropzone), 0, this.activeCommand 273 | ); 274 | 275 | this.homepage.save(); 276 | this.update(); 277 | } 278 | } 279 | 280 | export class CommandSuggestModal extends FuzzySuggestModal { 281 | app: App 282 | homepage: Homepage; 283 | tab: HomepageSettingTab; 284 | 285 | constructor(tab: HomepageSettingTab) { 286 | super(tab.plugin.app); 287 | 288 | this.homepage = tab.plugin.homepage; 289 | this.tab = tab; 290 | } 291 | 292 | getItems(): Command[] { 293 | return Object.values(this.app.commands.commands); 294 | } 295 | 296 | getItemText(item: Command): string { 297 | return item.name; 298 | } 299 | 300 | onChooseItem(item: Command) { 301 | if (item.id === "homepage:open-homepage") { 302 | new Notice("Really?"); 303 | return; 304 | } 305 | else if (!this.homepage.data.commands) { 306 | this.homepage.data.commands = []; 307 | } 308 | 309 | this.homepage.data.commands.push({ 310 | id: item.id, period: Period.Both 311 | }); 312 | 313 | this.homepage.save(); 314 | this.tab.commandBox.update(); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { App, Platform, TFolder, TFile, View as OView, WorkspaceMobileDrawer } from "obsidian"; 2 | 3 | export function trimFile(file: TFile): string { 4 | if (!file) return ""; 5 | return file.extension == "md" ? file.path.slice(0, -3): file.path; 6 | } 7 | 8 | export function untrimName(name: string): string { 9 | const hasExtension = name.split("/").slice(-1)[0].contains("."); 10 | return hasExtension ? name : `${name}.md`; 11 | } 12 | 13 | export function wrapAround(value: number, size: number): number { 14 | return ((value % size) + size) % size; 15 | } 16 | 17 | export function randomFile(app: App, root: string | undefined = undefined): string | undefined { 18 | let files = app.vault.getFiles(); 19 | 20 | if (root) { 21 | const resolvedRoot = app.vault.getFolderByPath(root); 22 | if (!resolvedRoot) return undefined; 23 | files = getFilesInFolder(resolvedRoot) 24 | } 25 | 26 | files.filter((f: TFile) => ["md", "canvas", "base"].contains(f.extension)); 27 | 28 | if (files.length) { 29 | const indice = Math.floor(Math.random() * files.length); 30 | return trimFile(files[indice]); 31 | } 32 | 33 | return undefined; 34 | } 35 | 36 | function getFilesInFolder(folder: TFolder): TFile[] { 37 | let files: TFile[] = []; 38 | 39 | for (const item of folder.children) { 40 | if (!(item instanceof TFolder)) files.push(item as TFile); 41 | 42 | else files.push(...getFilesInFolder(item as TFolder)) 43 | } 44 | 45 | return files; 46 | } 47 | 48 | export function emptyActiveView(app: App): boolean { 49 | return app.workspace.getActiveViewOfType(OView)?.getViewType() == "empty"; 50 | } 51 | 52 | export function equalsCaseless(a: string, b: string): boolean { 53 | return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0; 54 | } 55 | 56 | export function sleep(ms: number): Promise { 57 | return new Promise(resolve => setTimeout(resolve, ms)); 58 | } 59 | 60 | export async function detachAllLeaves(app: App): Promise { 61 | const layout = app.workspace.getLayout(); 62 | 63 | layout.main = { 64 | "id": "5324373015726ba8", 65 | "type": "split", 66 | "children": [ 67 | { 68 | "id": "4509724f8bf84da7", 69 | "type": "tabs", 70 | "children": [ 71 | { 72 | "id": "e7a7b303c61786dc", 73 | "type": "leaf", 74 | "state": {"type": "empty", "state": {}, "icon": "lucide-file", "title": "New tab"} 75 | } 76 | ] 77 | } 78 | ], 79 | "direction": "vertical" 80 | } 81 | layout.active = "e7a7b303c61786dc"; 82 | 83 | await app.workspace.changeLayout(layout); 84 | 85 | if (Platform.isMobile) { 86 | (app.workspace.rightSplit as WorkspaceMobileDrawer)?.updateInfo(); 87 | addSyncButton(app); 88 | } 89 | } 90 | 91 | function addSyncButton(app: App): void { 92 | let sync = app.internalPlugins.plugins["sync"]?.instance; 93 | if (!sync) return; 94 | 95 | app.workspace.onLayoutReady(() => { 96 | sync.statusIconEl = (app.workspace.rightSplit as WorkspaceMobileDrawer).addHeaderButton( 97 | "sync-small", sync.openStatusIconMenu.bind(sync) 98 | ); 99 | sync.statusIconEl.addEventListener("contextmenu", sync.openStatusIconMenu.bind(sync)); 100 | sync.statusIconEl.addClass("sync-status-icon"); 101 | }); 102 | } 103 | 104 | export function hasLayoutChange(app: App): Promise { 105 | const sync = app.internalPlugins.plugins.sync; 106 | let promises = [new Promise(resolve => { 107 | const wrapped = async () => { 108 | resolve(); 109 | app.workspace.off("layout-change", wrapped); 110 | }; 111 | 112 | app.workspace.on("layout-change", wrapped); 113 | })]; 114 | 115 | if (sync.enabled && sync.instance.syncing) { 116 | promises.push(new Promise(resolve => { 117 | const wrapped = async () => { 118 | resolve(); 119 | sync.instance.off("status-change", wrapped); 120 | }; 121 | 122 | sync.instance.on("status-change", wrapped); 123 | })); 124 | } 125 | 126 | return Promise.race([ 127 | Promise.all(promises), 128 | new Promise(resolve => setTimeout(resolve, 1500)) 129 | ]); 130 | } 131 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .nv-homepage-interstitial { 2 | position: absolute; 3 | left: 0; 4 | top: 0; 5 | width: 100vw; 6 | height: 100vh; 7 | background: var(--background-primary); 8 | z-index: 9999; 9 | animation: 0.02s ease-in 0.5s forwards nv-interstitial-destroy; 10 | pointer-events: none; 11 | } 12 | 13 | @keyframes nv-interstitial-destroy { 14 | from { opacity: 1; } 15 | to { opacity: 0; } 16 | } 17 | 18 | .setting-item[nv-greyed] { 19 | opacity: .5; 20 | pointer-events: none !important; 21 | } 22 | 23 | #nv-main-setting { 24 | flex-wrap: wrap; 25 | margin-bottom: 30px; 26 | } 27 | 28 | #nv-main-setting .setting-item-control { 29 | padding-top: var(--size-4-2); 30 | flex-basis: 100%; 31 | align-items: stretch; 32 | } 33 | 34 | #nv-main-setting .setting-item-control input, #nv-main-setting .setting-item-control select { 35 | font-size: var(--font-ui-medium); 36 | font-weight: 600; 37 | } 38 | 39 | #nv-main-setting .setting-item-control select { 40 | padding: var(--size-4-3) var(--size-4-4); 41 | padding-right: var(--size-4-8); 42 | height: auto; 43 | } 44 | 45 | #nv-main-setting .setting-item-control input { 46 | flex-grow: 1; 47 | padding: var(--size-4-5) var(--size-4-4); 48 | } 49 | 50 | #nv-main-setting .setting-item-control input[disabled] { 51 | opacity: 0.3; 52 | } 53 | 54 | #nv-main-setting #nv-desc, #nv-main-setting #nv-info { 55 | flex-basis: 100%; 56 | } 57 | 58 | #nv-main-setting #nv-desc { 59 | font-weight: 500; 60 | color: var(--text-normal); 61 | font-size: var(--font-ui-small); 62 | padding: 10px 0 0; 63 | } 64 | 65 | #nv-main-setting #nv-desc.mod-warning { 66 | color: var(--text-error); 67 | } 68 | 69 | #nv-main-setting #nv-desc code { 70 | font-family: var(--font-monospace); 71 | font-size: var(--font-smaller); 72 | border-radius: var(--radius-s); 73 | } 74 | 75 | #nv-main-setting #nv-desc small { 76 | display: block; 77 | font-weight: 400; 78 | color: var(--text-muted); 79 | font-size: calc(var(--font-ui-smaller) * 0.9); 80 | padding: 5px 0 0; 81 | } 82 | 83 | .nv-homepage-file-tag { 84 | display: inline-block; 85 | vertical-align: middle; 86 | margin-left: var(--size-2-2); 87 | } 88 | 89 | .nv-mobile-setting { 90 | flex-wrap: wrap; 91 | row-gap: var(--size-2-2); 92 | } 93 | 94 | .nv-mobile-setting .nv-mobile-info { 95 | font-size: var(--font-ui-smaller); 96 | width: 100%; 97 | margin-right: var(--size-4-18); 98 | } 99 | 100 | .nv-command-desc { 101 | padding: 1.2em 0 0; 102 | border-top: 1px solid var(--background-modifier-border); 103 | } 104 | 105 | .nv-command-box { 106 | margin: 1em 0 1.75em; 107 | display: flex; 108 | flex-wrap: wrap; 109 | gap: 12px; 110 | align-items: center; 111 | } 112 | 113 | .nv-command-pill { 114 | background-color: var(--background-secondary); 115 | border: 1px solid var(--background-modifier-border-hover); 116 | border-radius: var(--radius-s); 117 | font-size: var(--font-ui-small); 118 | padding: var(--size-2-1) var(--size-2-2) var(--size-2-1) var(--size-2-3) ; 119 | } 120 | 121 | .nv-command-pill.nv-command-invalid { 122 | color: var(--text-faint); 123 | } 124 | 125 | .nv-command-pill button { 126 | display: inline-block; 127 | padding: 0; 128 | margin: 0 0 0 3px; 129 | vertical-align: bottom; 130 | } 131 | 132 | .nv-command-pill button:first-of-type { 133 | margin-left: var(--size-4-2); 134 | } 135 | 136 | .nv-command-pill button.nv-command-selected { 137 | margin-left: var(--size-2-2); 138 | padding: 0 var(--size-2-1); 139 | } 140 | 141 | .nv-command-pill button.nv-command-selected span { 142 | color: var(--text-accent); 143 | display: inline-block; 144 | font-size: 0.9em; 145 | vertical-align: top; 146 | position: relative; 147 | top: -1px; 148 | } 149 | 150 | .nv-command-pill > .svg-icon, .nv-command-pill button .svg-icon { 151 | height: 1em; 152 | width: 1em; 153 | } 154 | 155 | .nv-command-pill > .svg-icon { 156 | vertical-align: text-bottom; 157 | position: relative; 158 | margin: 0 var(--size-2-1) 0 0; 159 | } 160 | 161 | .nv-command-pill.nv-dragging { 162 | background-color: transparent; 163 | } 164 | 165 | .nv-command-add-button { 166 | font-size: var(--font-ui-small); 167 | padding: var(--size-2-2) var(--size-4-2); 168 | height: auto; 169 | } 170 | 171 | #nv-main-setting + .setting-item, .nv-command-desc + .setting-item { 172 | padding-top: 20px; 173 | border-top: none !important; 174 | } 175 | 176 | .nv-debug-button { 177 | margin: 3em 0 -0.2em; 178 | font-size: var(--font-ui-smaller); 179 | padding: 0; 180 | height: auto; 181 | float: right; 182 | box-shadow: none !important; 183 | background: none !important; 184 | color: var(--text-accent); 185 | font-weight: 600; 186 | cursor: pointer; 187 | } 188 | 189 | .nv-debug-button:hover, .nv-debug-button:active { 190 | text-decoration: underline; 191 | } 192 | 193 | .is-phone #nv-main-setting .setting-item-control { 194 | flex-wrap: wrap; 195 | justify-content: flex-start; 196 | } 197 | 198 | .is-phone #nv-main-setting .setting-item-control select { 199 | width: auto; 200 | max-width: auto; 201 | } 202 | 203 | .is-phone .nv-mobile-setting { 204 | row-gap: var(--size-4-2); 205 | } 206 | 207 | .is-phone .nv-mobile-setting .setting-item-info { 208 | max-width: calc(100% - 100px); 209 | } 210 | 211 | .is-phone .nv-mobile-setting { 212 | row-gap: var(--size-4-2); 213 | } 214 | 215 | .is-phone .nv-mobile-setting .setting-item-info { 216 | max-width: calc(100% - 100px); 217 | } 218 | 219 | .is-phone .nv-command-pill { 220 | width: 100%; 221 | border: none; 222 | background: none; 223 | padding: 0 0 var(--size-4-2); 224 | display: flex; 225 | gap: var(--size-4-4); 226 | align-items: baseline; 227 | } 228 | 229 | .is-phone .nv-command-pill .nv-command-text { 230 | flex-grow: 1; 231 | overflow: hidden; 232 | text-overflow: ellipsis; 233 | } 234 | 235 | .is-phone .nv-command-pill, .is-phone .nv-command-add-button { 236 | font-size: var(--font-ui-medium); 237 | justify-content: space-between; 238 | } 239 | 240 | .is-phone .nv-command-pill button { 241 | line-height: var(--font-ui-medium); 242 | height: 100%; 243 | margin: 0 !important; 244 | } 245 | -------------------------------------------------------------------------------- /tests/harness.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, Modal, getIcon } from "obsidian"; 2 | import { HomepageData } from "src/homepage"; 3 | import HomepagePlugin from "src/main"; 4 | import { DEFAULT_DATA } from "src/settings"; 5 | import { detachAllLeaves, sleep } from "src/utils"; 6 | 7 | type Result = { 8 | name: string, 9 | error: string | null, 10 | passed: boolean 11 | } 12 | 13 | const TEST_SUITES = [ 14 | import("./opening-tests"), 15 | import("./plugin-tests"), 16 | import("./setting-tests"), 17 | import("./view-tests") 18 | ]; 19 | 20 | const PLUGINS = ["dataview", "journals", "obsidian-kanban", "periodic-notes"]; 21 | 22 | export default class HomepageTestPlugin extends HomepagePlugin { 23 | testResults: Record = {}; 24 | test = false; 25 | 26 | async onload(): Promise { 27 | this.registerObsidianProtocolHandler("nv-testing-restart", async () => { 28 | window.electron.remote.app.quit(); 29 | }); 30 | 31 | super.onload(); 32 | this.app.workspace.onLayoutReady(async () => { 33 | await (this as unknown as HomepageDebugPlugin).ensurePlugins(PLUGINS, false); 34 | await this.execute(); 35 | }); 36 | } 37 | 38 | async execute(): Promise { 39 | await sleep(100); 40 | 41 | for (const suite of TEST_SUITES) { 42 | await this.runTests((await suite).default); 43 | } 44 | 45 | const modal = new TestResultModal(this); 46 | modal.open(); 47 | } 48 | 49 | async runTests(suite: any): Promise { 50 | const tests = new suite(); 51 | const className = tests.constructor.name; 52 | 53 | this.testResults[className] = []; 54 | 55 | for (const name of Object.getOwnPropertyNames(suite.prototype)) { 56 | if (name == "constructor") continue; 57 | const result: Result = { name: name, error: null, passed: true }; 58 | 59 | //reset state 60 | this.homepage.data = { ...DEFAULT_DATA }; 61 | await this.homepage.save(); 62 | detachAllLeaves(this.app); 63 | await sleep(50); 64 | 65 | try { 66 | await tests[name].call(this); 67 | } 68 | catch (e) { 69 | if (!(e instanceof TestAssertionError)) console.error(e); 70 | result.error = e as string; 71 | result.passed = false; 72 | } 73 | 74 | this.testResults[className].push(result); 75 | } 76 | 77 | this.homepage.data = {} as unknown as HomepageData; 78 | await this.homepage.save(); 79 | } 80 | 81 | assert(cond: boolean, ...args: unknown[]) { 82 | if (!cond) { 83 | const e = new TestAssertionError(args.toString()); 84 | console.error("Assertion failed: ", args.length ? args : null); 85 | throw e; 86 | } 87 | } 88 | } 89 | 90 | class TestResultModal extends Modal { 91 | plugin: HomepageTestPlugin; 92 | results: Record; 93 | 94 | constructor(plugin: HomepageTestPlugin) { 95 | super(plugin.app); 96 | this.plugin = plugin; 97 | } 98 | 99 | async onOpen() { 100 | this.modalEl.addClass("nv-modal"); 101 | let success = 0, failure = 0; 102 | 103 | this.contentEl.addClass("nv-results"); 104 | 105 | for (const [name, suite] of Object.entries(this.plugin.testResults)) { 106 | this.contentEl.createEl("h1", { text: name }); 107 | 108 | for (const result of suite) { 109 | const row = this.contentEl.createDiv(); 110 | row.append( 111 | getIcon(result.passed ? "check" : "cross")!, 112 | result.name 113 | ); 114 | 115 | if (result.passed) { 116 | success += 1; 117 | } 118 | else { 119 | row.createEl("code", { text: `${result.error}` }); 120 | failure += 1; 121 | } 122 | } 123 | } 124 | 125 | this.titleEl.classList.add("nv-result-summary"); 126 | this.titleEl.append( 127 | getIcon("check")!, 128 | ` ${success} Passed\xa0\xa0\xa0`, 129 | getIcon("cross")!, 130 | ` ${failure} Failed` 131 | ); 132 | 133 | new ButtonComponent(this.modalEl) 134 | .setIcon("terminal-square") 135 | .setClass("clickable-icon") 136 | .setClass("nv-devtools") 137 | .onClick(() => { 138 | const ew = window.electronWindow; 139 | !ew.isDevToolsOpened() ? ew.openDevTools() : ew.closeDevTools(); 140 | }) 141 | } 142 | 143 | onClose() { 144 | this.contentEl.empty(); 145 | } 146 | } 147 | 148 | class TestAssertionError extends Error { 149 | constructor(message: string) { 150 | super(message); 151 | this.name = "TestAssertionError"; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /tests/opening-tests.ts: -------------------------------------------------------------------------------- 1 | import { TAbstractFile } from "obsidian"; 2 | import { Kind, Mode, Period } from "src/homepage"; 3 | import { sleep } from "src/utils"; 4 | import HomepageTestPlugin from "./harness"; 5 | 6 | export default class OpeningTests { 7 | async replaceAll(this: HomepageTestPlugin) { 8 | await this.app.workspace.openLinkText("Note A", "", false); 9 | await this.app.workspace.openLinkText("Note B", "", true); 10 | 11 | this.homepage.data.manualOpenMode = Mode.ReplaceAll; 12 | await this.homepage.save(); 13 | await this.homepage.open(); 14 | 15 | const file = this.app.workspace.getActiveFile(); 16 | const leaves = this.app.workspace.getLeavesOfType("markdown"); 17 | this.assert(file?.name == "Home.md" && leaves.length == 1, file, leaves); 18 | } 19 | 20 | async replaceAllImage(this: HomepageTestPlugin) { 21 | await this.app.workspace.openLinkText("Note A", "", false); 22 | await this.app.workspace.openLinkText("Image.png", "", true); 23 | 24 | this.homepage.data.manualOpenMode = Mode.ReplaceAll; 25 | await this.homepage.save(); 26 | await this.homepage.open(); 27 | 28 | const leaves = this.app.workspace.getLeavesOfType("image"); 29 | this.assert(leaves.length == 0, leaves); 30 | } 31 | 32 | async replaceLast(this: HomepageTestPlugin) { 33 | await this.app.workspace.openLinkText("Note A", "", false); 34 | await this.app.workspace.openLinkText("Note B", "", true); 35 | 36 | this.homepage.data.manualOpenMode = Mode.ReplaceLast; 37 | await this.homepage.save(); 38 | await this.homepage.open(); 39 | 40 | const file = this.app.workspace.getActiveFile(); 41 | const leaves = this.app.workspace.getLeavesOfType("markdown"); 42 | this.assert(file?.name == "Home.md" && leaves.length == 2, file, leaves); 43 | } 44 | 45 | async retain(this: HomepageTestPlugin) { 46 | await this.app.workspace.openLinkText("Note A", "", false); 47 | await this.app.workspace.openLinkText("Note B", "", true); 48 | 49 | this.homepage.data.manualOpenMode = Mode.Retain; 50 | await this.homepage.save(); 51 | await this.homepage.open(); 52 | 53 | const file = this.app.workspace.getActiveFile(); 54 | const leaves = this.app.workspace.getLeavesOfType("markdown"); 55 | this.assert(file?.name == "Home.md" && leaves.length == 3, file, leaves); 56 | } 57 | 58 | async commands(this: HomepageTestPlugin) { 59 | this.addCommand({ 60 | id: "nv-test-command", 61 | name: "Test", 62 | callback: () => this.test = true 63 | }); 64 | 65 | this.homepage.data.commands = [{ 66 | id: "homepage:nv-test-command", 67 | period: Period.Both 68 | }]; 69 | 70 | await this.homepage.save(); 71 | await this.homepage.open(); 72 | 73 | this.assert(this.test, this); 74 | } 75 | 76 | async autoCreate(this: HomepageTestPlugin) { 77 | this.homepage.data.value = "temp"; 78 | this.homepage.data.autoCreate = true; 79 | await this.homepage.save(); 80 | await this.homepage.open(); 81 | 82 | let file = this.app.workspace.getActiveFile(); 83 | this.assert(file?.name == "temp.md", file); 84 | 85 | this.app.vault.delete(file as TAbstractFile); 86 | 87 | this.homepage.data.autoCreate = false; 88 | await this.homepage.save(); 89 | await this.homepage.open(); 90 | 91 | file = this.app.workspace.getActiveFile(); 92 | this.assert(file?.name != "temp.md", file); 93 | } 94 | 95 | async random(this: HomepageTestPlugin) { 96 | this.homepage.data.kind = Kind.Random; 97 | await this.homepage.save(); 98 | 99 | //check that the files are different at least 1/10 times 100 | let name = null, newname; 101 | 102 | for (let i = 0; i < 10; i++) { 103 | await this.homepage.open(); 104 | newname = this.app.workspace.getActiveFile()?.name; 105 | 106 | if (i > 0 && newname !== name) return; 107 | name = newname; 108 | } 109 | this.assert(false); 110 | } 111 | 112 | async randomFolder(this: HomepageTestPlugin) { 113 | this.homepage.data.kind = Kind.RandomFolder; 114 | this.homepage.data.value = "TestFolder"; 115 | await this.homepage.save(); 116 | 117 | //check that the files are in the correct folder for 10 runs 118 | let path; 119 | 120 | for (let i = 0; i < 10; i++) { 121 | await this.homepage.open(); 122 | path = this.app.workspace.getActiveFile()?.path; 123 | this.assert(path?.startsWith(this.homepage.data.value)!); 124 | } 125 | } 126 | 127 | async openWhenEmpty(this: HomepageTestPlugin) { 128 | this.homepage.data.openWhenEmpty = true; 129 | await this.homepage.save(); 130 | 131 | this.app.workspace.iterateRootLeaves(l => l.detach()); 132 | await sleep(500); 133 | 134 | const file = this.app.workspace.getActiveFile(); 135 | const leaves = this.app.workspace.getLeavesOfType("markdown"); 136 | this.assert(file?.name == "Home.md" && leaves.length == 1, file, leaves); 137 | } 138 | 139 | async openWhenEmptyReplaceAll(this: HomepageTestPlugin) { 140 | this.homepage.data.openWhenEmpty = true; 141 | this.homepage.data.manualOpenMode = Mode.ReplaceAll; 142 | await this.homepage.save(); 143 | 144 | this.app.workspace.iterateRootLeaves(l => l.detach()); 145 | await sleep(500); 146 | 147 | const file = this.app.workspace.getActiveFile(); 148 | const leaves = this.app.workspace.getLeavesOfType("markdown"); 149 | this.assert(file?.name == "Home.md" && leaves.length == 1, file, leaves); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /tests/plugin-tests.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownView, TFile, WorkspaceLeaf, WorkspaceSplit, moment } from "obsidian"; 2 | import { Kind, Period, View } from "src/homepage"; 3 | import { sleep } from "src/utils"; 4 | import HomepageTestPlugin from "./harness"; 5 | 6 | export default class PluginTests { 7 | async workspaces(this: HomepageTestPlugin) { 8 | await this.app.workspace.openLinkText("Note A", "", false); 9 | 10 | const bottom = this.app.workspace.getLeaf("split", "horizontal"); 11 | this.app.workspace.setActiveLeaf(bottom, { focus: true }); 12 | 13 | await this.app.workspace.openLinkText("Note B", "", false); 14 | this.internalPlugins.workspaces.instance.saveWorkspace("Home"); 15 | 16 | this.app.workspace.iterateRootLeaves(l => l.detach()); 17 | this.homepage.data.kind = Kind.Workspace; 18 | await this.homepage.open(); 19 | 20 | const split = this.app.workspace.rootSplit.children[0] as WorkspaceSplit; 21 | const upper = (split.children[0].children[0] as WorkspaceLeaf).view as MarkdownView; 22 | const lower = (split.children[1].children[0] as WorkspaceLeaf).view as MarkdownView; 23 | 24 | this.assert( 25 | split.direction == "horizontal" && 26 | upper.file!.name == "Note A.md" && lower.file!.name == "Note B.md", 27 | split, upper, lower 28 | ); 29 | } 30 | 31 | async openCanvas(this: HomepageTestPlugin) { 32 | this.homepage.data.value = "Canvas.canvas"; 33 | await this.homepage.save(); 34 | await this.homepage.open(); 35 | 36 | const file = this.app.workspace.getActiveFile(); 37 | const leaves = this.app.workspace.getLeavesOfType("canvas"); 38 | this.assert(file?.name == "Canvas.canvas" && leaves.length == 1, file, leaves); 39 | } 40 | 41 | async dailyNote(this: HomepageTestPlugin) { 42 | this.homepage.data.kind = Kind.DailyNote; 43 | await this.homepage.save(); 44 | await this.homepage.open(); 45 | 46 | const dailyNote = await this.internalPlugins["daily-notes"].instance.getDailyNote(); 47 | const file = this.app.workspace.getActiveFile(); 48 | 49 | this.assert(file?.name == dailyNote.name, file, dailyNote); 50 | this.app.vault.delete(dailyNote); 51 | } 52 | 53 | async workspacesDailyNote(this: HomepageTestPlugin) { 54 | await this.app.workspace.openLinkText("Note A", "", false); 55 | 56 | const bottom = this.app.workspace.getLeaf("split", "horizontal"); 57 | this.app.workspace.setActiveLeaf(bottom, { focus: true }); 58 | 59 | await this.app.workspace.openLinkText("Note B", "", false); 60 | this.internalPlugins.workspaces.instance.saveWorkspace("Home"); 61 | 62 | this.homepage.data.commands = [{ 63 | id: "daily-notes", 64 | period: Period.Both 65 | }]; 66 | await this.homepage.save(); 67 | 68 | this.app.workspace.iterateRootLeaves(l => l.detach()); 69 | this.homepage.data.kind = Kind.Workspace; 70 | await this.homepage.open(); 71 | 72 | const dailyNote = await this.internalPlugins["daily-notes"].instance.getDailyNote(); 73 | const file = this.app.workspace.getActiveFile(); 74 | 75 | this.assert(file?.name == dailyNote.name, file, dailyNote); 76 | await sleep(100); 77 | this.app.vault.delete(dailyNote); 78 | } 79 | 80 | async periodicDailyNote(this: HomepageTestPlugin) { 81 | await this.app.plugins.enablePluginAndSave("periodic-notes"); 82 | 83 | this.homepage.data.kind = Kind.DailyNote; 84 | await this.homepage.save(); 85 | await this.homepage.open(); 86 | 87 | const file = this.app.workspace.getActiveFile(); 88 | const name = moment().format("YYYY-MM-DD") + ".md"; 89 | 90 | this.assert(file?.name == name, file, name); 91 | this.app.vault.delete(this.app.vault.getAbstractFileByPath(name) as TFile); 92 | 93 | await this.app.plugins.disablePluginAndSave("periodic-notes"); 94 | } 95 | 96 | async periodicNoteExtant(this: HomepageTestPlugin) { 97 | await this.app.plugins.enablePluginAndSave("periodic-notes"); 98 | 99 | this.homepage.data.kind = Kind.DailyNote; 100 | await this.homepage.save(); 101 | 102 | const name = moment().format("YYYY-MM-DD") + ".md"; 103 | this.app.vault.create(name, "test"); 104 | 105 | await this.homepage.open(); 106 | 107 | const file = this.app.workspace.getActiveFile(); 108 | 109 | this.assert(file?.name == name, file, name); 110 | this.app.vault.delete(this.app.vault.getAbstractFileByPath(name) as TFile); 111 | 112 | await this.app.plugins.disablePluginAndSave("periodic-notes"); 113 | } 114 | 115 | async periodicWeeklyNote(this: HomepageTestPlugin) { 116 | await this.app.plugins.enablePluginAndSave("periodic-notes"); 117 | await sleep(100); 118 | 119 | this.homepage.data.kind = Kind.WeeklyNote; 120 | await this.homepage.save(); 121 | await this.homepage.open(); 122 | 123 | const file = this.app.workspace.getActiveFile(); 124 | const name = moment().format("gggg-[W]ww") + ".md"; 125 | 126 | this.assert(file?.name == name, file, name); 127 | this.app.vault.delete(this.app.vault.getAbstractFileByPath(name) as TFile); 128 | 129 | await this.app.plugins.disablePluginAndSave("periodic-notes"); 130 | } 131 | 132 | async dataviewRefresh(this: HomepageTestPlugin) { 133 | await this.app.plugins.enablePluginAndSave("dataview"); 134 | 135 | this.homepage.data.view = View.Reading; 136 | this.homepage.data.refreshDataview = true; 137 | this.homepage.data.value = "Dataview"; 138 | await sleep(100); 139 | 140 | let previous = ""; 141 | 142 | for (let i = 0; i < 5; i++) { 143 | await this.homepage.open(); 144 | await sleep(100); 145 | 146 | const current = document.getElementsByClassName( 147 | "block-language-dataviewjs" 148 | )[0].getElementsByTagName("span")[0].textContent; 149 | 150 | this.app.workspace.getActiveViewOfType(MarkdownView)?.leaf.detach(); 151 | 152 | if (current !== previous && i > 0) { 153 | this.app.plugins.disablePluginAndSave("dataview"); 154 | return; 155 | } 156 | 157 | previous = current!; 158 | } 159 | 160 | this.assert(false); 161 | 162 | await this.app.plugins.disablePluginAndSave("dataview"); 163 | } 164 | 165 | async openKanban(this: HomepageTestPlugin) { 166 | await this.app.plugins.enablePluginAndSave("obsidian-kanban"); 167 | await sleep(200); 168 | 169 | this.homepage.data.value = "Kanban.md"; 170 | await this.homepage.save(); 171 | await this.homepage.open(); 172 | 173 | const file = this.app.workspace.getActiveFile(); 174 | const leaves = this.app.workspace.getLeavesOfType("kanban"); 175 | this.assert(file?.name == "Kanban.md" && leaves.length == 1, file, leaves); 176 | 177 | await this.app.plugins.disablePluginAndSave("obsidian-kanban"); 178 | await sleep(100); 179 | } 180 | 181 | async openGraph(this: HomepageTestPlugin) { 182 | this.homepage.data.kind = Kind.Graph; 183 | await this.homepage.save(); 184 | await this.homepage.open(); 185 | await sleep(100); 186 | 187 | const leaves = this.app.workspace.getLeavesOfType("graph"); 188 | this.assert(leaves.length == 1, leaves); 189 | } 190 | 191 | async openJournal(this: HomepageTestPlugin) { 192 | await this.app.plugins.enablePluginAndSave("journals"); 193 | await sleep(200); 194 | 195 | this.homepage.data.kind = Kind.Journal; 196 | this.homepage.data.value = "Test"; 197 | await this.homepage.save(); 198 | await this.homepage.open(); 199 | 200 | const file = this.app.workspace.getActiveFile(); 201 | const name = "j-" + moment().format("YYYY-MM-DD") + ".md"; 202 | 203 | this.assert(file?.name == name, file, name); 204 | this.app.vault.delete(this.app.vault.getAbstractFileByPath(name) as TFile); 205 | 206 | await this.app.plugins.disablePluginAndSave("journals"); 207 | await sleep(100); 208 | } 209 | 210 | async openBase(this: HomepageTestPlugin) { 211 | this.homepage.data.kind = Kind.File; 212 | this.homepage.data.value = "Base.base"; 213 | await this.homepage.save(); 214 | await this.homepage.open(); 215 | 216 | const file = this.app.workspace.getActiveFile(); 217 | const leaves = this.app.workspace.getLeavesOfType("bases"); 218 | this.assert(file?.name == "Base.base" && leaves.length == 1, file, leaves); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /tests/setting-tests.ts: -------------------------------------------------------------------------------- 1 | import { Kind, Mode, View } from "src/homepage"; 2 | import { LEGACY_MOMENT_KIND } from "src/periodic"; 3 | import { HomepageSettings, DEFAULT_SETTINGS } from "src/settings"; 4 | import { sleep } from "src/utils"; 5 | import HomepageTestPlugin from "./harness"; 6 | 7 | export default class SettingTests { 8 | async loadEmptySettings(this: HomepageTestPlugin) { 9 | this.settings = {} as HomepageSettings; 10 | this.saveSettings(); 11 | this.settings = await this.loadSettings(); 12 | this.homepage = this.getHomepage(); 13 | 14 | const actual = JSON.stringify(this.settings); 15 | const expected = JSON.stringify(DEFAULT_SETTINGS); 16 | 17 | this.assert(actual == expected); 18 | } 19 | 20 | async upgradeSettings(this: HomepageTestPlugin) { 21 | this.settings = { 22 | version: 2, 23 | defaultNote: "Home", 24 | useMoment: false, 25 | momentFormat: "YYYY-MM-DD", 26 | workspace: "Default", 27 | workspaceEnabled: true, 28 | openOnStartup: true, 29 | hasRibbonIcon: true, 30 | openMode: Mode.ReplaceAll, 31 | manualOpenMode: Mode.Retain, 32 | view: View.Default, 33 | revertView: true, 34 | refreshDataview: false, 35 | autoCreate: true, 36 | autoScroll: false, 37 | pin: false 38 | } as unknown as HomepageSettings; 39 | 40 | this.saveSettings(); 41 | this.settings = await this.loadSettings(); 42 | this.homepage = this.getHomepage(); 43 | 44 | this.assert( 45 | this.homepage.data.commands.length == 0 && 46 | this.homepage.data.value == "Default" && 47 | this.homepage.data.kind == Kind.Workspace 48 | ); 49 | 50 | //check that the settings tab isn't broken upon upgrade 51 | const { setting } = this.app; 52 | 53 | setting.open(); 54 | setting.openTabById("homepage"); 55 | await sleep(100); 56 | this.assert(document.getElementsByClassName("nv-debug-button").length > 0); 57 | setting.close(); 58 | } 59 | 60 | async upgradeMomentSettings(this: HomepageTestPlugin) { 61 | this.homepage.data.kind = LEGACY_MOMENT_KIND; 62 | this.settings.version = 3; 63 | this.homepage.save(); 64 | 65 | this.settings = await this.loadSettings(); 66 | this.homepage = this.getHomepage(); 67 | 68 | await this.homepage.open(); 69 | 70 | const dailyNote = await this.internalPlugins["daily-notes"].instance.getDailyNote(); 71 | const file = this.app.workspace.getActiveFile(); 72 | 73 | this.assert( 74 | file?.name == dailyNote.name && 75 | this.homepage.data.kind == Kind.DailyNote, 76 | file, 77 | this.homepage.data.kind 78 | ); 79 | 80 | this.app.vault.delete(dailyNote); 81 | } 82 | 83 | async setToActiveFile(this: HomepageTestPlugin) { 84 | await this.app.workspace.openLinkText("Note A", "", false); 85 | 86 | this.app.commands.executeCommandById("homepage:set-to-active-file"); 87 | await sleep(100); 88 | 89 | this.assert(this.homepage.data.value == "Note A", this.homepage.data.value); 90 | 91 | await this.homepage.open(); 92 | 93 | const file = this.app.workspace.getActiveFile(); 94 | this.assert(file?.name == "Note A.md", file); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/vault/.obsidian/appearance.json: -------------------------------------------------------------------------------- 1 | { 2 | "accentColor": "", 3 | "enabledCssSnippets": [ 4 | "modal" 5 | ] 6 | } -------------------------------------------------------------------------------- /tests/vault/.obsidian/community-plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | "homepage" 3 | ] -------------------------------------------------------------------------------- /tests/vault/.obsidian/core-plugins-migration.json: -------------------------------------------------------------------------------- 1 | { 2 | "file-explorer": true, 3 | "global-search": true, 4 | "switcher": true, 5 | "graph": true, 6 | "backlink": true, 7 | "canvas": true, 8 | "outgoing-link": true, 9 | "tag-pane": true, 10 | "page-preview": true, 11 | "daily-notes": true, 12 | "templates": true, 13 | "note-composer": true, 14 | "command-palette": true, 15 | "slash-command": false, 16 | "editor-status": true, 17 | "bookmarks": true, 18 | "markdown-importer": false, 19 | "zk-prefixer": false, 20 | "random-note": false, 21 | "outline": true, 22 | "word-count": true, 23 | "slides": false, 24 | "starred": true, 25 | "audio-recorder": false, 26 | "workspaces": true, 27 | "file-recovery": true, 28 | "publish": false, 29 | "sync": false, 30 | "properties": true 31 | } -------------------------------------------------------------------------------- /tests/vault/.obsidian/core-plugins.json: -------------------------------------------------------------------------------- 1 | { 2 | "file-explorer": true, 3 | "global-search": true, 4 | "switcher": true, 5 | "graph": true, 6 | "backlink": true, 7 | "canvas": true, 8 | "outgoing-link": true, 9 | "tag-pane": true, 10 | "page-preview": true, 11 | "daily-notes": true, 12 | "templates": true, 13 | "note-composer": true, 14 | "command-palette": true, 15 | "slash-command": false, 16 | "editor-status": true, 17 | "bookmarks": true, 18 | "markdown-importer": false, 19 | "zk-prefixer": false, 20 | "random-note": false, 21 | "outline": true, 22 | "word-count": true, 23 | "slides": false, 24 | "starred": true, 25 | "audio-recorder": false, 26 | "workspaces": true, 27 | "file-recovery": true, 28 | "publish": false, 29 | "sync": false, 30 | "properties": true, 31 | "footnotes": false, 32 | "bases": true, 33 | "webviewer": false 34 | } -------------------------------------------------------------------------------- /tests/vault/.obsidian/hotkeys.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /tests/vault/.obsidian/plugins/dataview/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "renderNullAs": "\\-", 3 | "taskCompletionTracking": false, 4 | "taskCompletionUseEmojiShorthand": false, 5 | "taskCompletionText": "completion", 6 | "taskCompletionDateFormat": "yyyy-MM-dd", 7 | "recursiveSubTaskCompletion": false, 8 | "warnOnEmptyResult": true, 9 | "refreshEnabled": true, 10 | "refreshInterval": 2500, 11 | "defaultDateFormat": "MMMM dd, yyyy", 12 | "defaultDateTimeFormat": "h:mm a - MMMM dd, yyyy", 13 | "maxRecursiveRenderDepth": 4, 14 | "tableIdColumnName": "File", 15 | "tableGroupColumnName": "Group", 16 | "showResultCount": true, 17 | "allowHtml": true, 18 | "inlineQueryPrefix": "=", 19 | "inlineJsQueryPrefix": "$=", 20 | "inlineQueriesInCodeblocks": true, 21 | "enableInlineDataview": true, 22 | "enableDataviewJs": true, 23 | "enableInlineDataviewJs": false, 24 | "prettyRenderInlineFields": true, 25 | "dataviewJsKeyword": "dataviewjs" 26 | } -------------------------------------------------------------------------------- /tests/vault/.obsidian/plugins/homepage/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "homepages": { 4 | "Main Homepage": {} 5 | }, 6 | "separateMobile": false 7 | } -------------------------------------------------------------------------------- /tests/vault/.obsidian/plugins/journals/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "ui": { 4 | "calendarShelf": null 5 | }, 6 | "pendingMigrations": [], 7 | "dismissedNotifications": [ 8 | "v2-commands-change" 9 | ], 10 | "useShelves": false, 11 | "showReloadHint": false, 12 | "openOnStartup": "", 13 | "journals": { 14 | "Test": { 15 | "name": "Test", 16 | "shelves": [], 17 | "write": { 18 | "type": "day" 19 | }, 20 | "confirmCreation": false, 21 | "nameTemplate": "{{date}}", 22 | "dateFormat": "j-YYYY-MM-DD", 23 | "folder": "", 24 | "templates": [], 25 | "start": "2025-03-25", 26 | "end": { 27 | "type": "never" 28 | }, 29 | "index": { 30 | "enabled": false, 31 | "anchorDate": "2025-03-25", 32 | "anchorIndex": 1, 33 | "allowBefore": false, 34 | "type": "increment", 35 | "resetAfter": 2 36 | }, 37 | "autoCreate": false, 38 | "commands": [], 39 | "decorations": [], 40 | "navBlock": { 41 | "type": "create", 42 | "decorateWholeBlock": false, 43 | "rows": [] 44 | }, 45 | "calendarViewBlock": { 46 | "rows": [], 47 | "decorateWholeBlock": false 48 | }, 49 | "frontmatter": { 50 | "dateField": "", 51 | "addStartDate": false, 52 | "startDateField": "", 53 | "addEndDate": false, 54 | "endDateField": "", 55 | "indexField": "" 56 | } 57 | } 58 | }, 59 | "shelves": {}, 60 | "commands": [ 61 | { 62 | "name": "Open today's note", 63 | "writeType": "day", 64 | "type": "same", 65 | "openMode": "tab", 66 | "showInRibbon": false, 67 | "icon": "" 68 | } 69 | ], 70 | "calendar": { 71 | "dow": -1, 72 | "doy": 1, 73 | "global": false 74 | }, 75 | "calendarView": { 76 | "display": "month", 77 | "leaf": "right", 78 | "weeks": "left", 79 | "todayMode": "navigate", 80 | "pickMode": "create", 81 | "todayStyle": { 82 | "color": { 83 | "type": "theme", 84 | "name": "text-accent" 85 | }, 86 | "background": { 87 | "type": "transparent" 88 | } 89 | }, 90 | "activeStyle": { 91 | "color": { 92 | "type": "theme", 93 | "name": "text-on-accent" 94 | }, 95 | "background": { 96 | "type": "theme", 97 | "name": "interactive-accent" 98 | } 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /tests/vault/.obsidian/plugins/periodic-notes/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "showGettingStartedBanner": false, 3 | "hasMigratedDailyNoteSettings": false, 4 | "hasMigratedWeeklyNoteSettings": false, 5 | "daily": { 6 | "format": "", 7 | "template": "", 8 | "folder": "", 9 | "enabled": true 10 | }, 11 | "weekly": { 12 | "format": "", 13 | "template": "", 14 | "folder": "", 15 | "enabled": true 16 | }, 17 | "monthly": { 18 | "format": "", 19 | "template": "", 20 | "folder": "" 21 | }, 22 | "quarterly": { 23 | "format": "", 24 | "template": "", 25 | "folder": "" 26 | }, 27 | "yearly": { 28 | "format": "", 29 | "template": "", 30 | "folder": "" 31 | } 32 | } -------------------------------------------------------------------------------- /tests/vault/.obsidian/snippets/modal.css: -------------------------------------------------------------------------------- 1 | .nv-modal { 2 | overflow: hidden; 3 | } 4 | 5 | .nv-results { 6 | overflow: scroll; 7 | max-height: 70vh; 8 | } 9 | 10 | .nv-results h1 { 11 | font-weight: var(--font-bold); 12 | font-size: var(--font-smallest); 13 | color: var(--text-muted); 14 | padding: 20px 0 6px; 15 | margin: 0; 16 | } 17 | 18 | .nv-results h1:first-child { 19 | padding-top: 4px; 20 | } 21 | 22 | .nv-results div { 23 | border-top: 1px solid var(--hr-color); 24 | padding: 4px 0; 25 | } 26 | 27 | .nv-results .svg-icon, .nv-result-summary .svg-icon { 28 | stroke: var(--color-green); 29 | stroke-width: 4px; 30 | width: 1em; 31 | height: 1em; 32 | vertical-align: text-bottom; 33 | } 34 | 35 | .nv-results .svg-icon { 36 | margin-right: 5px; 37 | } 38 | 39 | .nv-results .lucide-x, .nv-result-summary .lucide-x { 40 | stroke: var(--color-red); 41 | } 42 | 43 | .nv-results code { 44 | display: block; 45 | color: var(--text-muted); 46 | font-family: var(--font-monospace); 47 | font-size: 0.8em; 48 | padding: 3px 0 0; 49 | } 50 | 51 | .nv-devtools { 52 | align-self: flex-end; 53 | padding: 0; 54 | margin: 5px 0 0; 55 | } 56 | -------------------------------------------------------------------------------- /tests/vault/Base.base: -------------------------------------------------------------------------------- 1 | views: 2 | - type: table 3 | name: Table 4 | -------------------------------------------------------------------------------- /tests/vault/Canvas.canvas: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /tests/vault/Dataview.md: -------------------------------------------------------------------------------- 1 | ```dataviewjs 2 | const a = Math.random(); 3 | 4 | dv.paragraph(a); 5 | ``` -------------------------------------------------------------------------------- /tests/vault/Home.md: -------------------------------------------------------------------------------- 1 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. 2 | Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? [[Note B]] -------------------------------------------------------------------------------- /tests/vault/Image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirnovov/obsidian-homepage/9ba3eaf10dce9574353ad391d0452875b3016779/tests/vault/Image.png -------------------------------------------------------------------------------- /tests/vault/Kanban.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | kanban-plugin: basic 4 | 5 | --- 6 | 7 | ## Lorem ipsum 8 | 9 | - [ ] Dolor amet 10 | 11 | 12 | 13 | 14 | %% kanban:settings 15 | ``` 16 | {"kanban-plugin":"basic"} 17 | ``` 18 | %% -------------------------------------------------------------------------------- /tests/vault/Note A.md: -------------------------------------------------------------------------------- 1 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? -------------------------------------------------------------------------------- /tests/vault/Note B.md: -------------------------------------------------------------------------------- 1 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae abd illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? -------------------------------------------------------------------------------- /tests/vault/TestFolder/Note C.md: -------------------------------------------------------------------------------- 1 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? -------------------------------------------------------------------------------- /tests/vault/TestFolder/Note D.md: -------------------------------------------------------------------------------- 1 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? -------------------------------------------------------------------------------- /tests/view-tests.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownView } from "obsidian"; 2 | import { View } from "src/homepage"; 3 | import { sleep } from "src/utils"; 4 | import HomepageTestPlugin from "./harness"; 5 | 6 | export default class ViewTests { 7 | async autoScroll(this: HomepageTestPlugin) { 8 | this.homepage.data.autoScroll = true; 9 | await this.homepage.save(); 10 | 11 | await this.homepage.open(); 12 | 13 | const view = this.app.workspace.getActiveViewOfType(MarkdownView)!; 14 | const count = view.editor.lineCount() - 1; 15 | const pos = view.editor.getCursor().line; 16 | 17 | this.assert(count == pos, view, count, pos); 18 | } 19 | 20 | async isPinned(this: HomepageTestPlugin) { 21 | this.homepage.data.pin = true; 22 | await this.homepage.save(); 23 | 24 | await this.homepage.open(); 25 | const leaf = this.app.workspace.getActiveViewOfType(MarkdownView)?.leaf; 26 | 27 | this.assert(leaf! && leaf.getViewState()!.pinned!, leaf); 28 | } 29 | 30 | async hasView(this: HomepageTestPlugin) { 31 | this.homepage.data.view = View.Reading; 32 | await this.homepage.save(); 33 | 34 | await this.homepage.open(); 35 | let state = this.app.workspace.getActiveViewOfType(MarkdownView)?.getState(); 36 | 37 | this.assert(state?.mode == "preview", state); 38 | 39 | this.homepage.data.view = View.Source; 40 | await this.homepage.save(); 41 | 42 | await this.homepage.open(); 43 | state = this.app.workspace.getActiveViewOfType(MarkdownView)?.getState(); 44 | 45 | this.assert(state?.mode == "source" && state.source as boolean, state); 46 | } 47 | 48 | async alwaysApply(this: HomepageTestPlugin) { 49 | this.homepage.data.view = View.Source; 50 | this.homepage.data.alwaysApply = true; 51 | this.homepage.save(); 52 | 53 | this.app.workspace.openLinkText("Home", "", false); 54 | await sleep(500); 55 | 56 | const state = this.app.workspace.getActiveViewOfType(MarkdownView)?.getState(); 57 | this.assert(state?.mode == "source" && state.source == true, state); 58 | } 59 | 60 | async reversion(this: HomepageTestPlugin) { 61 | this.assert(this.app.vault?.config.livePreview === undefined); 62 | this.homepage.data.view = View.Reading; 63 | this.homepage.save(); 64 | 65 | this.homepage.open(); 66 | await sleep(200); 67 | let mode = this.app.workspace.getActiveViewOfType(MarkdownView)?.getMode(); 68 | this.assert(mode == "preview", mode); 69 | 70 | await this.app.workspace.openLinkText("Note B", "", false); 71 | await sleep(200); 72 | mode = this.app.workspace.getActiveViewOfType(MarkdownView)?.getMode(); 73 | this.assert(mode == "source", mode); 74 | } 75 | 76 | async reversionThenViewChange(this: HomepageTestPlugin) { 77 | this.homepage.data.view = View.Reading; 78 | this.homepage.save(); 79 | 80 | this.homepage.open(); 81 | await sleep(200); 82 | const mode = this.app.workspace.getActiveViewOfType(MarkdownView)?.getMode(); 83 | this.assert(mode == "preview", mode); 84 | 85 | await this.app.workspace.openLinkText("Note B", "", false); 86 | const view = this.app.workspace.getActiveViewOfType(MarkdownView), 87 | state = view?.getState() || {}; 88 | state.mode = "source"; 89 | await view?.leaf.setViewState({type: "markdown", state: state}); 90 | await sleep(200); 91 | this.assert(state.source == false, state); 92 | } 93 | 94 | async reversionCaseInsensitive(this: HomepageTestPlugin) { 95 | this.homepage.data.view = View.Reading; 96 | this.homepage.data.value = "home"; 97 | this.homepage.save(); 98 | 99 | this.homepage.open(); 100 | await sleep(200); 101 | const mode = this.app.workspace.getActiveViewOfType(MarkdownView)?.getMode(); 102 | this.assert(mode == "preview", mode); 103 | } 104 | 105 | async reversionWithoutDefaults(this: HomepageTestPlugin) { 106 | const config = this.app.vault?.config; 107 | if (!config) this.app.vault.config = {}; 108 | 109 | config.livePreview = true; 110 | config.defaultViewMode = "preview"; 111 | this.homepage.data.view = View.Source; 112 | this.homepage.save(); 113 | 114 | this.homepage.open(); 115 | await sleep(200); 116 | let mode = this.app.workspace.getActiveViewOfType(MarkdownView)?.getMode(); 117 | this.assert(mode == "source", mode); 118 | 119 | await this.app.workspace.openLinkText("Note B", "", false); 120 | await sleep(200); 121 | mode = this.app.workspace.getActiveViewOfType(MarkdownView)?.getMode(); 122 | this.assert(mode == "preview", mode); 123 | 124 | this.app.vault.config = {}; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es2021", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "bundler", 11 | "importHelpers": true, 12 | "strict": true, 13 | "strictPropertyInitialization": false, 14 | "skipLibCheck": true, 15 | "isolatedModules": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "lib": [ 18 | "dom", 19 | "es5", 20 | "es6", 21 | "scripthost", 22 | "es2017", 23 | "es2018", 24 | "es2019", 25 | "es2020", 26 | "es2021" 27 | ] 28 | }, 29 | "include": ["**/*.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "3.6.0": "1.4.10", 3 | "3.5.2": "1.0.0" 4 | } --------------------------------------------------------------------------------