├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .npmrc ├── LICENSE.md ├── README.md ├── esbuild.config.mjs ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── api.ts ├── commandSuggest.ts ├── globals.d.ts ├── job.ts ├── lockManager.ts ├── main.ts ├── settings.ts ├── suggest.ts └── syncChecker.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI/CD 5 | 6 | on: [push, workflow_dispatch] 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest] 15 | node: [18.x] 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node }} 24 | - run: npm ci 25 | - run: npm run lint 26 | - run: npm run build 27 | 28 | deploy: 29 | if: ${{ github.event_name == 'workflow_dispatch' }} 30 | runs-on: ${{ matrix.os }} 31 | strategy: 32 | matrix: 33 | os: [ubuntu-latest] 34 | node: [18.x] 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v3 38 | 39 | - name: Use Node.js ${{ matrix.node }} 40 | uses: actions/setup-node@v3 41 | with: 42 | node-version: 18.x 43 | 44 | - run: npm ci 45 | - run: npm run build 46 | - run: npm run lint 47 | - run: npm run semantic-release 48 | env: 49 | GH_TOKEN: "${{ secrets.GH_TOKEN }}" 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Callum Loh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Cron Plugin 2 | 3 | Obsidian Cron is a plugin for Obsidian.md that allows users to schedule Obsidian commands or custom user scripts to run automatically on a schedule. 4 | 5 | # Installation 6 | To install Obsidian Cron you can download it through the community packages within Obsidian, or download the latest release and add it manually. 7 | 8 | # Usage 9 | 10 | Add jobs in the plugin settings page. 11 | 12 | Each job requires 13 | 14 | 1. a name 15 | 2. an Obsidian command to run 16 | 3. a cron schedule syntax expression 17 | * This will be the frequency your job runs. If you need help writing a cron schedule [crontab.guru](https://crontab.guru/) can help 18 | 19 | Each job also has three toggable options 20 | 21 | 1. Enable job to run on mobile 22 | * By default all jobs do not run on mobile 23 | 2. Toggle job lock 24 | * If your job gets stuck with a bad log you can toggle the status here. Check [locking](#locking) for more details 25 | 3. Toggle sync check 26 | * Toggles the sync check ability on a per job basis. Check [sync](#sync) for more details 27 | 28 | # Functionality 29 | 30 | ## API / UserScripts 31 | 32 | An API is exposed to add user functions via Javascript. The name is treated as a UUID for the job ensure that this is consistent across reloads of Obsidian to ensure that locks / last run data is usable. 33 | 34 | An instance of the Obsidian app is passed to all user function as the first and only paramater. 35 | 36 | To clear locks for jobs added via the API you can add a job with the corrosponding name and then pass the name to the `clearJobLock(name: string)` function also in the API. 37 | 38 | Example of a user function 39 | 40 | ```javascript 41 | 42 | const cron = app.plugins.plugins.cron.api; 43 | 44 | cron.addCronJob('addCronJob', "* * * * 3", {"enableMobile": true}, function(app){console.log('Job has ran!')}); 45 | 46 | ``` 47 | 48 | ## Sync 49 | 50 | Obsidian cron has the ability to hook into the native Obsidian Sync plugin. When enabled all locks, cron runs & commands will wait for Obsidian Sync to be fully completed before running any cron jobs. 51 | 52 | This is useful if you have multiple instances of Obsidian running and want to ensure that cron jobs only run on one device or once per Obsidian vault. 53 | 54 | ## Locking 55 | 56 | At the start of each cron job a lock is saved into the plugin settings that stops multiple instances of the same jobs running. Sometimes if jobs don't finish cleanly they can be left with locks still in place. They can be unlocked in the settings page of the plugin. 57 | 58 | # License 59 | Obsidian Cron is released under the MIT License. See the LICENSE file for more information. 60 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } 49 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "cron", 3 | "name": "Cron", 4 | "version": "1.1.2", 5 | "minAppVersion": "0.15.0", 6 | "description": "Simple CRON / schedular plugin to regularly run user scripts or Obsidian commands.", 7 | "author": "Callum Loh", 8 | "authorUrl": "https://github.com/cdloh", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-cron-plugin", 3 | "version": "1.1.2", 4 | "description": "Simple CRON / Schedular plugin to regularly run user scripts or Obsidian commands for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "lint": "eslint ./src/*", 11 | "semantic-release": "semantic-release" 12 | }, 13 | "keywords": [], 14 | "author": "Callum Loh", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@semantic-release/git": "^10.0.1", 18 | "@types/node": "^16.11.6", 19 | "@types/uuid": "^9.0.1", 20 | "@typescript-eslint/eslint-plugin": "5.29.0", 21 | "@typescript-eslint/parser": "5.29.0", 22 | "builtin-modules": "3.3.0", 23 | "esbuild": "0.17.3", 24 | "obsidian": "latest", 25 | "semantic-release": "^20.1.3", 26 | "tslib": "2.4.0", 27 | "typescript": "4.7.4" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/cdloh/obsidian-cron.git" 32 | }, 33 | "dependencies": { 34 | "@popperjs/core": "^2.11.6", 35 | "cron-parser": "^4.8.1", 36 | "eslint": "^8.36.0", 37 | "uuid": "^9.0.0" 38 | }, 39 | "release": { 40 | "tagFormat": "${version}", 41 | "plugins": [ 42 | "@semantic-release/release-notes-generator", 43 | [ 44 | "@semantic-release/npm", 45 | { 46 | "npmPublish": false 47 | } 48 | ], 49 | [ 50 | "@semantic-release/git", 51 | { 52 | "assets": [ 53 | "package.json", 54 | "package-lock.json", 55 | "manifest.json", 56 | "versions.json" 57 | ], 58 | "message": "release(version): Release ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 59 | } 60 | ], 61 | [ 62 | "@semantic-release/github", 63 | { 64 | "assets": [ 65 | { 66 | "path": "main.js", 67 | "label": "main.js" 68 | }, 69 | { 70 | "path": "manifest.json", 71 | "label": "manifest.json" 72 | }, 73 | { 74 | "path": "styles.css", 75 | "label": "styles.css" 76 | } 77 | ] 78 | } 79 | ] 80 | ] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { CronJobFunc, CronJobSettings } from "./job" 2 | import Cron from "./main" 3 | 4 | 5 | export default class CronAPI { 6 | static instance: CronAPI 7 | 8 | public static get(plugin: Cron) { 9 | return { 10 | addCronJob(name: string, frequency: string, settings: CronJobSettings, job: CronJobFunc) { 11 | return plugin.addCronJob(name, frequency, settings, job) 12 | }, 13 | runJob(name: string) { 14 | return plugin.runJob(name) 15 | }, 16 | clearJobLock(name: string) { 17 | return plugin.clearJobLock(name) 18 | }, 19 | getJob(name: string) { 20 | return plugin.getJob(name) 21 | }, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/commandSuggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { Command } from "obsidian"; 4 | import { TextInputSuggest } from "./suggest"; 5 | 6 | export class CommandSuggest extends TextInputSuggest { 7 | getSuggestions(inputStr: string): Command[] { 8 | const abstractCommands = app.commands.commands; 9 | const commands: Command[] = []; 10 | const lowerCaseInputStr = inputStr.toLowerCase(); 11 | 12 | for (const [, command] of Object.entries(abstractCommands)) { 13 | if ( 14 | command.name.toLowerCase().contains(lowerCaseInputStr) 15 | ) { 16 | commands.push(command) 17 | } 18 | } 19 | 20 | return commands; 21 | } 22 | 23 | renderSuggestion(command: Command, el: HTMLElement): void { 24 | el.setText(command.name); 25 | } 26 | 27 | selectSuggestion(command: Command): void { 28 | this.inputEl.value = command.id; 29 | this.inputEl.trigger("input"); 30 | this.close(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | declare module "obsidian" { 4 | interface App { 5 | isMobile: boolean 6 | commands: { 7 | executeCommand: (command: Command) => Promise; 8 | commands: { [key: string]: Command; } 9 | } 10 | plugins: { 11 | plugins: { 12 | [pluginId: string]: Plugin & { 13 | [pluginImplementations: string]: unknown; 14 | }; 15 | }; 16 | enablePlugin: (id: string) => Promise; 17 | disablePlugin: (id: string) => Promise; 18 | }; 19 | internalPlugins: { 20 | plugins: { 21 | [pluginId: string]: Plugin & { 22 | [pluginImplementations: string]: unknown; 23 | }; 24 | }; 25 | enablePlugin: (id: string) => Promise; 26 | disablePlugin: (id: string) => Promise; 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/job.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian'; 2 | import CronLockManager from './lockManager'; 3 | import Cron from './main'; 4 | import SyncChecker from './syncChecker'; 5 | import { parseExpression } from 'cron-parser'; 6 | 7 | export interface CronJobFunc {(app:App): Promise | void} 8 | 9 | export interface CronJobSettings { 10 | enableMobile?: boolean 11 | disableSyncCheck?: boolean 12 | disableJobLock?: boolean 13 | } 14 | 15 | export default class Job { 16 | syncChecker: SyncChecker; 17 | plugin: Cron 18 | app: App; 19 | 20 | lockManager: CronLockManager; 21 | frequency: string 22 | settings: CronJobSettings 23 | job: CronJobFunc | string 24 | name: string 25 | id: string 26 | noRunReason: string 27 | 28 | public constructor(id: string, name: string, job: CronJobFunc | string, frequency: string, settings: CronJobSettings, app: App, plugin: Cron, syncChecker: SyncChecker) { 29 | this.syncChecker = syncChecker; 30 | this.plugin = plugin; 31 | this.app = app; 32 | 33 | this.lockManager = new CronLockManager(id, settings, plugin, syncChecker) 34 | this.name = name; 35 | this.id = id; 36 | this.job = job; 37 | this.frequency = frequency; 38 | this.settings = settings; 39 | 40 | } 41 | 42 | public async runJob(): Promise { 43 | 44 | console.log(`Running ${this.name}`); 45 | 46 | await this.lockManager.lockJob() 47 | 48 | typeof this.job == "string" ? await this.runJobCommand() : await this.runJobFunction(); 49 | 50 | await this.lockManager.updateLastrun() 51 | await this.lockManager.unlockJob() 52 | } 53 | 54 | public canRunJob(): boolean { 55 | if(this.lockManager.jobLocked() && !this.settings.disableJobLock) { 56 | this.noRunReason = "job locked" 57 | return false 58 | } 59 | 60 | if(this.app.isMobile && !this.settings.enableMobile){ 61 | this.noRunReason = "disabled on mobile" 62 | return false 63 | } 64 | 65 | if(!this.jobIntervalPassed()) { 66 | this.noRunReason = "job interval hasnt passed" 67 | return false 68 | } 69 | 70 | return true 71 | } 72 | 73 | public clearJobLock(): void { 74 | this.lockManager.clearLock() 75 | } 76 | 77 | private jobIntervalPassed(): boolean { 78 | // job never ran 79 | const lastRun = this.lockManager.lastRun() 80 | if(!lastRun) return true 81 | 82 | const prevRun = window.moment(parseExpression(this.frequency).prev().toDate()) 83 | return prevRun.isAfter(lastRun) 84 | } 85 | 86 | private async runJobFunction(): Promise { 87 | if(typeof this.job !== 'function') { return } 88 | 89 | try { 90 | await this.job(this.app) 91 | console.log(`${this.name} completed`) 92 | } catch (error) { 93 | console.log(`${this.name} failed to run`) 94 | console.log(error) 95 | } 96 | } 97 | 98 | private async runJobCommand(): Promise { 99 | if(typeof this.job !== 'string') { return } 100 | 101 | const jobCommand = this.app.commands.commands[this.job]; 102 | 103 | if(!jobCommand) { 104 | console.log(`${this.name} failed to run: Command unknown`) 105 | } 106 | 107 | await this.app.commands.executeCommand(jobCommand) 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /src/lockManager.ts: -------------------------------------------------------------------------------- 1 | import { CronJobSettings } from './job'; 2 | import Cron from './main'; 3 | import SyncChecker from './syncChecker'; 4 | 5 | export interface CronLock { 6 | lockedDeviceName?: string 7 | locked?: boolean 8 | lockDate?: string 9 | lastRun?: string 10 | } 11 | 12 | export default class CronLockManager { 13 | syncChecker: SyncChecker; 14 | plugin: Cron 15 | job: string 16 | settings: CronJobSettings 17 | 18 | public constructor(job: string, jobSettings: CronJobSettings, plugin: Cron, syncChecker: SyncChecker) { 19 | this.syncChecker = syncChecker; 20 | this.job = job; 21 | this.settings = jobSettings; 22 | this.plugin = plugin; 23 | 24 | if(!this.plugin.settings.locks[this.job]) { 25 | this.plugin.settings.locks[this.job] = {} 26 | } 27 | this.plugin.saveSettings() 28 | } 29 | 30 | updateLastrun(): Promise { 31 | this.plugin.settings.locks[this.job].lastRun = window.moment().format() 32 | return this.plugin.saveSettings() 33 | } 34 | 35 | private async updateLockJob(status: boolean): Promise { 36 | this.plugin.settings.locks[this.job].locked = status 37 | this.plugin.settings.locks[this.job].lockedDeviceName = this.syncChecker.deviceName(); 38 | await this.plugin.saveSettings() 39 | return this.syncChecker.waitForSync(this.settings) 40 | } 41 | 42 | lockJob(): Promise { 43 | return this.updateLockJob(true) 44 | } 45 | 46 | unlockJob(): Promise { 47 | return this.updateLockJob(false) 48 | } 49 | 50 | clearLock(): void { 51 | this.plugin.settings.locks[this.job].locked = false 52 | } 53 | 54 | lastRun(): string | undefined { 55 | return this.plugin.settings.locks[this.job].lastRun; 56 | } 57 | 58 | resetLastRun(): void { 59 | this.plugin.settings.locks[this.job].lastRun = undefined 60 | } 61 | 62 | jobLocked(): boolean { 63 | return this.plugin.settings.locks[this.job].locked || false 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'obsidian'; 2 | import Job, { CronJobFunc, CronJobSettings } from './job'; 3 | import { CronLock } from './lockManager'; 4 | import CronLockManager from './lockManager'; 5 | import CronSettingTab from './settings'; 6 | import SyncChecker from './syncChecker'; 7 | import CronAPI from './api'; 8 | 9 | export interface CronSettings { 10 | cronInterval: number; 11 | runOnStartup: boolean 12 | enableMobile: boolean 13 | watchObsidianSync: boolean 14 | crons: Array, 15 | locks: { [key: string]: CronLock } 16 | } 17 | 18 | export interface CRONJob { 19 | id: string 20 | name: string 21 | job: string 22 | frequency: string 23 | settings: CronJobSettings 24 | } 25 | 26 | const DEFAULT_SETTINGS: CronSettings = { 27 | cronInterval: 15, 28 | runOnStartup: true, 29 | enableMobile: true, 30 | watchObsidianSync: true, 31 | crons: [], 32 | locks: {} 33 | } 34 | 35 | export default class Cron extends Plugin { 36 | static instance: Cron 37 | interval: number; 38 | settings: CronSettings; 39 | syncChecker: SyncChecker 40 | lockManager: CronLockManager 41 | jobs: { [key: string]: Job } 42 | api: CronAPI 43 | 44 | async onload() { 45 | console.log("Loading Obsidian CRON!"); 46 | Cron.instance = this; 47 | await this.loadSettings(); 48 | 49 | this.addSettingTab(new CronSettingTab(this.app, this)); 50 | this.syncChecker = new SyncChecker(this.app, this); 51 | 52 | this.jobs = {} 53 | 54 | // load our cronjobs 55 | this.loadCrons() 56 | this.loadInterval() 57 | this.api = CronAPI.get(this) 58 | this.app.workspace.onLayoutReady(() => { 59 | if(this.settings.runOnStartup) { 60 | if(this.app.isMobile && !this.settings.enableMobile) { return } 61 | this.runCron() 62 | } 63 | }) 64 | } 65 | 66 | public async runCron() { 67 | // console.log("Running Obsidian Cron!") 68 | for (const [, job] of Object.entries(this.jobs)) { 69 | await this.syncChecker.waitForSync(job.settings) 70 | 71 | // reload the settings incase we've had a new lock come in via sync 72 | await this.loadSettings() 73 | 74 | if(!job.canRunJob()) { 75 | // console.log(`Can't run job: ${job.noRunReason}`) 76 | continue 77 | } 78 | 79 | await job.runJob() 80 | } 81 | } 82 | 83 | public addCronJob(name: string, frequency: string, settings: CronJobSettings, job: CronJobFunc) { 84 | const existingJob = this.getJob(name) 85 | if(existingJob) throw new Error("CRON Job already exists") 86 | 87 | this.jobs[name] = new Job(name, name, job, frequency, settings, this.app, this, this.syncChecker) 88 | } 89 | 90 | public async runJob(name: string) { 91 | const job = this.getJob(name) 92 | if(!job) throw new Error("CRON Job doesn't exist") 93 | await job.runJob() 94 | } 95 | 96 | public clearJobLock(name: string) { 97 | const job = this.getJob(name) 98 | if(!job) throw new Error("CRON Job doesn't exist") 99 | job.clearJobLock() 100 | } 101 | 102 | public getJob(name: string): Job | null { 103 | for (const [, job] of Object.entries(this.jobs)) { 104 | if(job.name == name) return job 105 | } 106 | return null 107 | } 108 | 109 | public onunload() { 110 | if(this.settings.watchObsidianSync) this.syncChecker.handleUnload() 111 | console.log("Cron unloaded") 112 | } 113 | 114 | public loadCrons() { 115 | this.settings.crons.forEach(cronjob => { 116 | if(cronjob.frequency === "" || cronjob.job === "") { 117 | return; 118 | } 119 | 120 | this.jobs[cronjob.id] = new Job(cronjob.id, cronjob.name, cronjob.job, cronjob.frequency, cronjob.settings, this.app, this, this.syncChecker) 121 | }); 122 | } 123 | 124 | public loadInterval() { 125 | clearInterval(this.interval) 126 | if(this.app.isMobile && !this.settings.enableMobile) { return } 127 | this.interval = window.setInterval(async () => { await this.runCron() }, this.settings.cronInterval * 60 * 1000) 128 | this.registerInterval(this.interval) 129 | } 130 | 131 | async loadSettings() { 132 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 133 | } 134 | 135 | async saveSettings() { 136 | await this.saveData(this.settings); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from 'obsidian'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { CommandSuggest } from './commandSuggest'; 4 | import type Cron from './main'; 5 | 6 | export default class CronSettingTab extends PluginSettingTab { 7 | plugin: Cron; 8 | 9 | constructor(app: App, plugin: Cron) { 10 | super(app, plugin); 11 | this.plugin = plugin; 12 | } 13 | 14 | display(): void { 15 | const { containerEl } = this; 16 | 17 | containerEl.empty(); 18 | containerEl.createEl('h2', { text: 'Settings for Cron.' }); 19 | 20 | new Setting(containerEl) 21 | .setName('Cron Interval') 22 | .setDesc('The interval the cron will run in minutes') 23 | .addText(text => text 24 | .setValue(this.plugin.settings.cronInterval.toString()) 25 | .onChange(async (value) => { 26 | if (value == "") { return } 27 | this.plugin.settings.cronInterval = parseInt(value); 28 | await this.plugin.saveSettings(); 29 | this.plugin.loadInterval(); 30 | }) 31 | ); 32 | 33 | new Setting(containerEl) 34 | .setName('Run cron on startup') 35 | .setDesc('Do a cron run on startup instead of waiting for the first interval to pass') 36 | .addToggle(toggle => toggle 37 | .setValue(this.plugin.settings.runOnStartup) 38 | .onChange(async (value) => { 39 | this.plugin.settings.runOnStartup = value; 40 | await this.plugin.saveSettings(); 41 | }) 42 | ); 43 | 44 | new Setting(containerEl) 45 | .setName('Enable Obsidian Sync Checker') 46 | .setDesc('Whether or not to wait for Obsidian sync before running any CRONs globally.') 47 | .addToggle(toggle => toggle 48 | .setValue(this.plugin.settings.watchObsidianSync) 49 | .onChange(async (value) => { 50 | this.plugin.settings.watchObsidianSync = value; 51 | await this.plugin.saveSettings(); 52 | }) 53 | ); 54 | 55 | new Setting(containerEl) 56 | .setName('Enable Obsidian on Mobile') 57 | .setDesc('Whether or not to load jobs at all on Mobile devices. If disabled even jobs with mobile enabled will not run.') 58 | .addToggle(toggle => toggle 59 | .setValue(this.plugin.settings.enableMobile) 60 | .onChange(async (value) => { 61 | this.plugin.settings.enableMobile = value; 62 | await this.plugin.saveSettings(); 63 | }) 64 | ); 65 | 66 | const desc = document.createDocumentFragment(); 67 | desc.append( 68 | "List of CRON Jobs to run. Jobs will not be ran until all 3 fields have been filled", 69 | desc.createEl("br"), 70 | "Cron Frequency is a cron schedule expression. Use ", 71 | desc.createEl("a", { 72 | href: "https://crontab.guru/", 73 | text: "crontab guru", 74 | }), 75 | " for help with creating cron schedule expressions." 76 | ); 77 | 78 | new Setting(containerEl) 79 | .setName("Cron Jobs") 80 | .setDesc(desc) 81 | 82 | this.addCommandSearch() 83 | } 84 | 85 | addCommandSearch(): void { 86 | 87 | this.plugin.settings.crons.forEach((cronjob, index) => { 88 | const jobSetting = new Setting(this.containerEl) 89 | .addText(text => text 90 | .setValue(cronjob.name) 91 | .setPlaceholder("Job name") 92 | .onChange(async (value) => { 93 | this.plugin.settings.crons[index].name = value; 94 | await this.plugin.saveSettings(); 95 | this.plugin.loadCrons(); 96 | }) 97 | .inputEl.addClass('cron-plugin-text-input') 98 | ) 99 | .addSearch((cb) => { 100 | new CommandSuggest(cb.inputEl); 101 | cb.setPlaceholder("Command") 102 | .setValue(cronjob.job) 103 | .onChange(async (command) => { 104 | if (!command) { return } 105 | 106 | this.plugin.settings.crons[index].job = command; 107 | await this.plugin.saveSettings(); 108 | this.plugin.loadCrons(); 109 | }) 110 | .inputEl.addClass('cron-plugin-text-input') 111 | }) 112 | .addText(text => text 113 | .setPlaceholder("CronJob frequency") 114 | .setValue(cronjob.frequency) 115 | .onChange(async (value) => { 116 | this.plugin.settings.crons[index].frequency = value; 117 | await this.plugin.saveSettings(); 118 | this.plugin.loadCrons(); 119 | }) 120 | .inputEl.addClass('cron-plugin-text-input') 121 | ) 122 | .addExtraButton((button) => { 123 | button.setIcon(cronjob.settings.enableMobile ? "lucide-phone" : "lucide-phone-off") 124 | .setTooltip("Toggle job on mobile") 125 | .onClick(async () => { 126 | this.plugin.settings.crons[index].settings.enableMobile = !cronjob.settings.enableMobile; 127 | await this.plugin.saveSettings(); 128 | // refresh 129 | this.display() 130 | }) 131 | }) 132 | 133 | const jobLocked = this.plugin.settings.locks[cronjob.id] && this.plugin.settings.locks[cronjob.id].locked 134 | jobSetting.addExtraButton((button) => { 135 | button.setIcon(jobLocked ? "lucide-lock" : "lucide-unlock") 136 | .setTooltip("Toggle job lock (clear lock if accidentally left locked)") 137 | .onClick(() => { 138 | this.plugin.settings.locks[cronjob.id].locked = !jobLocked; 139 | this.plugin.saveSettings(); 140 | // refresh 141 | this.display() 142 | }) 143 | }) 144 | 145 | jobSetting.addExtraButton((button) => { 146 | button.setIcon(cronjob.settings.disableSyncCheck ? "paused" : "lucide-check-circle-2") 147 | .setTooltip("Toggle Sync check for this job. Presently: " + (cronjob.settings.disableSyncCheck ? "disabled" : "enabled")) 148 | .onClick(() => { 149 | this.plugin.settings.crons[index].settings.disableSyncCheck = !cronjob.settings.disableSyncCheck; 150 | this.plugin.saveSettings(); 151 | // Force refresh 152 | this.display(); 153 | }); 154 | }) 155 | .addExtraButton((button) => { 156 | button.setIcon("cross") 157 | .setTooltip("Delete Job") 158 | .onClick(() => { 159 | this.plugin.settings.crons.splice(index, 1) 160 | delete this.plugin.jobs[cronjob.id] 161 | delete this.plugin.settings.locks[cronjob.id] 162 | this.plugin.saveSettings(); 163 | // Force refresh 164 | this.display(); 165 | }); 166 | }); 167 | 168 | jobSetting.controlEl.addClass("cron-plugin-job") 169 | }); 170 | 171 | new Setting(this.containerEl).addButton((cb) => { 172 | cb.setButtonText("Add cron job") 173 | .setCta() 174 | .onClick(() => { 175 | this.plugin.settings.crons.push({ 176 | id: uuidv4(), 177 | name: "", 178 | job: "", 179 | frequency: "", 180 | settings: { 181 | enableMobile: false 182 | } 183 | }) 184 | this.plugin.saveSettings(); 185 | // Force refresh 186 | this.display(); 187 | }); 188 | }); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/suggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { ISuggestOwner, Scope } from "obsidian"; 4 | import { createPopper, Instance as PopperInstance } from "@popperjs/core"; 5 | 6 | const wrapAround = (value: number, size: number): number => { 7 | return ((value % size) + size) % size; 8 | }; 9 | 10 | class Suggest { 11 | private owner: ISuggestOwner; 12 | private values: T[]; 13 | private suggestions: HTMLDivElement[]; 14 | private selectedItem: number; 15 | private containerEl: HTMLElement; 16 | 17 | constructor( 18 | owner: ISuggestOwner, 19 | containerEl: HTMLElement, 20 | scope: Scope 21 | ) { 22 | this.owner = owner; 23 | this.containerEl = containerEl; 24 | 25 | containerEl.on( 26 | "click", 27 | ".suggestion-item", 28 | this.onSuggestionClick.bind(this) 29 | ); 30 | containerEl.on( 31 | "mousemove", 32 | ".suggestion-item", 33 | this.onSuggestionMouseover.bind(this) 34 | ); 35 | 36 | scope.register([], "ArrowUp", (event) => { 37 | if (!event.isComposing) { 38 | this.setSelectedItem(this.selectedItem - 1, true); 39 | return false; 40 | } 41 | }); 42 | 43 | scope.register([], "ArrowDown", (event) => { 44 | if (!event.isComposing) { 45 | this.setSelectedItem(this.selectedItem + 1, true); 46 | return false; 47 | } 48 | }); 49 | 50 | scope.register([], "Enter", (event) => { 51 | if (!event.isComposing) { 52 | this.useSelectedItem(event); 53 | return false; 54 | } 55 | }); 56 | } 57 | 58 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 59 | event.preventDefault(); 60 | 61 | const item = this.suggestions.indexOf(el); 62 | this.setSelectedItem(item, false); 63 | this.useSelectedItem(event); 64 | } 65 | 66 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 67 | const item = this.suggestions.indexOf(el); 68 | this.setSelectedItem(item, false); 69 | } 70 | 71 | setSuggestions(values: T[]) { 72 | this.containerEl.empty(); 73 | const suggestionEls: HTMLDivElement[] = []; 74 | 75 | values.forEach((value) => { 76 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 77 | this.owner.renderSuggestion(value, suggestionEl); 78 | suggestionEls.push(suggestionEl); 79 | }); 80 | 81 | this.values = values; 82 | this.suggestions = suggestionEls; 83 | this.setSelectedItem(0, false); 84 | } 85 | 86 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 87 | const currentValue = this.values[this.selectedItem]; 88 | if (currentValue) { 89 | this.owner.selectSuggestion(currentValue, event); 90 | } 91 | } 92 | 93 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 94 | const normalizedIndex = wrapAround( 95 | selectedIndex, 96 | this.suggestions.length 97 | ); 98 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 99 | const selectedSuggestion = this.suggestions[normalizedIndex]; 100 | 101 | prevSelectedSuggestion?.removeClass("is-selected"); 102 | selectedSuggestion?.addClass("is-selected"); 103 | 104 | this.selectedItem = normalizedIndex; 105 | 106 | if (scrollIntoView) { 107 | selectedSuggestion.scrollIntoView(false); 108 | } 109 | } 110 | } 111 | 112 | export abstract class TextInputSuggest implements ISuggestOwner { 113 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 114 | 115 | private popper: PopperInstance; 116 | private scope: Scope; 117 | private suggestEl: HTMLElement; 118 | private suggest: Suggest; 119 | 120 | constructor(inputEl: HTMLInputElement | HTMLTextAreaElement) { 121 | this.inputEl = inputEl; 122 | this.scope = new Scope(); 123 | 124 | this.suggestEl = createDiv("suggestion-container"); 125 | const suggestion = this.suggestEl.createDiv("suggestion"); 126 | this.suggest = new Suggest(this, suggestion, this.scope); 127 | 128 | this.scope.register([], "Escape", this.close.bind(this)); 129 | 130 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 131 | this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); 132 | this.inputEl.addEventListener("blur", this.close.bind(this)); 133 | this.suggestEl.on( 134 | "mousedown", 135 | ".suggestion-container", 136 | (event: MouseEvent) => { 137 | event.preventDefault(); 138 | } 139 | ); 140 | } 141 | 142 | onInputChanged(): void { 143 | const inputStr = this.inputEl.value; 144 | const suggestions = this.getSuggestions(inputStr); 145 | 146 | if (!suggestions) { 147 | this.close(); 148 | return; 149 | } 150 | 151 | if (suggestions.length > 0) { 152 | this.suggest.setSuggestions(suggestions); 153 | // @ts-ignore 154 | this.open(app.dom.appContainerEl, this.inputEl); 155 | } else { 156 | this.close(); 157 | } 158 | } 159 | 160 | open(container: HTMLElement, inputEl: HTMLElement): void { 161 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 162 | app.keymap.pushScope(this.scope); 163 | 164 | container.appendChild(this.suggestEl); 165 | this.popper = createPopper(inputEl, this.suggestEl, { 166 | placement: "bottom-start", 167 | modifiers: [ 168 | { 169 | name: "sameWidth", 170 | enabled: true, 171 | fn: ({ state, instance }) => { 172 | // Note: positioning needs to be calculated twice - 173 | // first pass - positioning it according to the width of the popper 174 | // second pass - position it with the width bound to the reference element 175 | // we need to early exit to avoid an infinite loop 176 | const targetWidth = "20000px" //`${state.rects.reference.width}px`; 177 | if (state.styles.popper.width === targetWidth) { 178 | return; 179 | } 180 | state.styles.popper.width = targetWidth; 181 | instance.update(); 182 | }, 183 | phase: "beforeWrite", 184 | requires: ["computeStyles"], 185 | }, 186 | ], 187 | }); 188 | } 189 | 190 | close(): void { 191 | app.keymap.popScope(this.scope); 192 | 193 | this.suggest.setSuggestions([]); 194 | if (this.popper) this.popper.destroy(); 195 | this.suggestEl.detach(); 196 | } 197 | 198 | abstract getSuggestions(inputStr: string): T[]; 199 | abstract renderSuggestion(item: T, el: HTMLElement): void; 200 | abstract selectSuggestion(item: T): void; 201 | } 202 | -------------------------------------------------------------------------------- /src/syncChecker.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian'; 2 | import { CronJobSettings } from './job'; 3 | import Cron from './main'; 4 | 5 | const syncEmitterName = 'status-change'; 6 | const syncCompletedStatus = 'synced'; 7 | const syncWaiterCtxID = "syncWaiter"; 8 | 9 | type promiseEntry = { 10 | resolve: () => void 11 | reject: (value?: string) => void 12 | } 13 | 14 | export default class SyncChecker { 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | syncInstance: any 17 | plugin: Cron 18 | syncWaiters: Array 19 | 20 | public constructor(app: App, plugin: Cron) { 21 | this.syncInstance = app.internalPlugins.plugins['sync'].instance; 22 | this.plugin = plugin; 23 | this.syncWaiters = []; 24 | 25 | this.syncInstance.on(syncEmitterName, this.handleSyncStatusChange.bind(this), syncWaiterCtxID) 26 | } 27 | 28 | public deviceName(): string { 29 | return this.syncInstance.deviceName ? this.syncInstance.deviceName : this.syncInstance.getDefaultDeviceName() 30 | } 31 | 32 | private handleSyncStatusChange(): void { 33 | if(this.syncInstance.getStatus() === syncCompletedStatus) { 34 | this.clearSyncWaiters() 35 | } 36 | } 37 | 38 | private clearSyncWaiters(): void { 39 | this.syncWaiters.forEach(waiter => { 40 | waiter.resolve() 41 | }); 42 | } 43 | 44 | public handleUnload():void { 45 | this.syncWaiters.forEach(waiter => { 46 | waiter.reject("Unloading plugin") 47 | }); 48 | 49 | // Unload the listener 50 | this.syncInstance._[syncEmitterName] = this.syncInstance._[syncEmitterName].filter((listener: { ctx: string; }) => { 51 | if(listener.ctx === syncWaiterCtxID) return false 52 | return true 53 | }) 54 | } 55 | 56 | public waitForSync(settings: CronJobSettings): Promise { 57 | return new Promise((resolve, reject) => { 58 | if(settings.disableSyncCheck) resolve(); 59 | if(!this.plugin.settings.watchObsidianSync) resolve(); 60 | if(this.syncInstance.getStatus() === syncCompletedStatus) { resolve() } 61 | this.syncWaiters.push({resolve: resolve, reject: reject}) 62 | }) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | 10 | .cron-plugin-text-input { 11 | width: 10vw; 12 | min-width: 180px; 13 | } 14 | 15 | .cron-plugin-job { 16 | flex-wrap: wrap; 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.15.0", 3 | "1.0.0": "0.15.0", 4 | "1.1.0": "0.15.0", 5 | "1.1.1": "0.15.0", 6 | "1.1.2": "0.15.0" 7 | } --------------------------------------------------------------------------------