├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ └── releases.yml ├── screenshots ├── plugin-panel.png └── plugin-settings.png ├── src ├── state │ ├── input.ts │ ├── visible.ts │ ├── filter.ts │ ├── settings.ts │ ├── theme.ts │ ├── user-configs.ts │ └── tasks.ts ├── hooks │ ├── useRefreshAll.ts │ └── useHotKey.ts ├── style.css ├── querys │ ├── next-n-days.ts │ ├── scheduled.ts │ ├── anytime.ts │ └── today.ts ├── utils.ts ├── components │ ├── TaskInput.tsx │ ├── TaskSection.tsx │ ├── TaskFilter.tsx │ └── TaskItem.tsx ├── main.tsx ├── models │ └── TaskEntity.ts ├── settings.ts ├── api.ts └── App.tsx ├── windi.config.ts ├── .envrc ├── devbox.json ├── renovate.json ├── index.html ├── vite.config.ts ├── tsconfig.json ├── .eslintrc.json ├── release.config.js ├── LICENSE ├── logo.svg ├── readme.md ├── package.json ├── devbox.lock └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ahonn] 4 | 5 | -------------------------------------------------------------------------------- /screenshots/plugin-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahonn/logseq-plugin-todo/HEAD/screenshots/plugin-panel.png -------------------------------------------------------------------------------- /screenshots/plugin-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahonn/logseq-plugin-todo/HEAD/screenshots/plugin-settings.png -------------------------------------------------------------------------------- /src/state/input.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil"; 2 | 3 | export const inputState = atom({ 4 | key: 'input', 5 | default: '', 6 | }); 7 | -------------------------------------------------------------------------------- /windi.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | darkMode: 'class', 3 | plugins: [ 4 | require('windicss/plugin/line-clamp'), 5 | require('windicss/plugin/scroll-snap'), 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Automatically sets up your devbox environment whenever you cd into this 4 | # directory via our direnv integration: 5 | 6 | eval "$(devbox generate direnv --print-envrc)" 7 | 8 | # check out https://www.jetpack.io/devbox/docs/ide_configuration/direnv/ 9 | # for more details 10 | -------------------------------------------------------------------------------- /devbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "nodejs@18.17.1" 4 | ], 5 | "env": { 6 | "DEVBOX_COREPACK_ENABLED": "true" 7 | }, 8 | "shell": { 9 | "init_hook": [ 10 | "echo 'Welcome to devbox!' > /dev/null" 11 | ], 12 | "scripts": { 13 | "test": [ 14 | "echo \"Error: no test specified\" && exit 1" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/useRefreshAll.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilCallback } from "recoil"; 2 | 3 | export function useRefreshAll() { 4 | const refreshAll = useRecoilCallback( 5 | ({ snapshot, refresh }) => 6 | () => { 7 | for (const node of snapshot.getNodes_UNSTABLE()) { 8 | refresh(node); 9 | } 10 | }, 11 | [], 12 | ); 13 | 14 | return refreshAll; 15 | } 16 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "packageRules": [ 4 | { 5 | "matchUpdateTypes": ["minor", "patch"], 6 | "automerge": true, 7 | "requiredStatusChecks": null 8 | }, 9 | { 10 | "matchPackageNames": ["@logseq/libs"], 11 | "ignoreUnstable": false, 12 | "automerge": false, 13 | "requiredStatusChecks": null 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Logseq Plugin 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .priority-a { 2 | .rc-checkbox-inner { 3 | @apply border-red-500 !important; 4 | } 5 | } 6 | 7 | .priority-b { 8 | .rc-checkbox-inner { 9 | @apply border-yellow-500 !important; 10 | } 11 | } 12 | 13 | .priority-c { 14 | .rc-checkbox-inner { 15 | @apply border-blue-500 !important; 16 | } 17 | } 18 | 19 | .priority-none { 20 | .rc-checkbox-inner { 21 | @apply border-gray-500 !important; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import reactPlugin from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vite'; 3 | import logseqDevPlugin from 'vite-plugin-logseq'; 4 | import WindiCSS from 'vite-plugin-windicss'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | logseqDevPlugin(), 10 | reactPlugin(), 11 | WindiCSS(), 12 | ], 13 | // Makes HMR available for development 14 | build: { 15 | target: 'esnext', 16 | minify: 'esbuild', 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/state/visible.ts: -------------------------------------------------------------------------------- 1 | import { atom, AtomEffect } from 'recoil'; 2 | 3 | const visibleChangedEffect: AtomEffect = ({ setSelf }) => { 4 | const eventName = 'ui:visible:changed'; 5 | const listener = ({ visible }: { visible: boolean }) => { 6 | setSelf(visible); 7 | }; 8 | logseq.on(eventName, listener); 9 | return () => { 10 | logseq.off(eventName, listener); 11 | }; 12 | }; 13 | 14 | export const visibleState = atom({ 15 | key: 'visible', 16 | default: logseq.isMainUIVisible, 17 | effects: [visibleChangedEffect], 18 | }); 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "types": ["vite/client"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react" 18 | }, 19 | "include": ["./src"] 20 | } 21 | -------------------------------------------------------------------------------- /src/querys/next-n-days.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export default function getNextNDaysTaskQuery(days: number) { 4 | const start = dayjs().format('YYYYMMDD'); 5 | const next = dayjs().add(days, 'd').format('YYYYMMDD'); 6 | 7 | const query = ` 8 | [:find (pull ?b [*]) 9 | :where 10 | [?b :block/marker ?marker] 11 | [(contains? #{"NOW" "LATER" "TODO" "DOING"} ?marker)] 12 | [?b :block/page ?p] 13 | (or 14 | [?b :block/scheduled ?d] 15 | [?b :block/deadline ?d]) 16 | [(> ?d ${start})]] 17 | [(> ?d ${next})]] 18 | `; 19 | return query; 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:@typescript-eslint/eslint-recommended", 7 | "plugin:@typescript-eslint/recommended" 8 | ], 9 | "plugins": ["@typescript-eslint", "react-hooks"], 10 | "parser": "@typescript-eslint/parser", 11 | "rules": { 12 | "react-hooks/rules-of-hooks": "error", 13 | "react-hooks/exhaustive-deps": "warn", 14 | "import/prefer-default-export": "off", 15 | "@typescript-eslint/ban-ts-comment": "off", 16 | "@typescript-eslint/no-non-null-assertion": "off", 17 | "@typescript-eslint/explicit-module-boundary-types": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/useHotKey.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import Mousetrap from 'mousetrap'; 3 | import 'mousetrap-global-bind'; 4 | 5 | export function useHotKey(hotkey: string) { 6 | useEffect(() => { 7 | if (!hotkey) { 8 | return; 9 | } 10 | 11 | // @ts-ignore 12 | Mousetrap.bindGlobal( 13 | hotkey, 14 | () => window.logseq.hideMainUI(), 15 | 'keydown', 16 | ); 17 | 18 | // @ts-ignore 19 | Mousetrap.bindGlobal( 20 | 'Esc', 21 | () => window.logseq.hideMainUI(), 22 | 'keydown', 23 | ); 24 | 25 | return () => { 26 | // @ts-ignore 27 | Mousetrap.unbindGlobal(hotkey, 'keydown'); 28 | // @ts-ignore 29 | Mousetrap.unbindGlobal('Esc', 'keydown'); 30 | }; 31 | }, [hotkey]); 32 | } 33 | -------------------------------------------------------------------------------- /src/state/filter.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil"; 2 | import { TaskPriority } from "../models/TaskEntity"; 3 | 4 | export const DEFAULT_OPTION = { 5 | label: 'ALL', 6 | value: '', 7 | } 8 | 9 | export const markerState = atom({ 10 | key: 'filter/marker', 11 | default: DEFAULT_OPTION, 12 | }); 13 | 14 | 15 | export const PRIORITY_OPTIONS = [ 16 | TaskPriority.HIGH, 17 | TaskPriority.MEDIUM, 18 | TaskPriority.LOW, 19 | TaskPriority.NONE, 20 | ]; 21 | 22 | export const priorityState = atom({ 23 | key: 'filter/priority', 24 | default: DEFAULT_OPTION, 25 | }); 26 | 27 | export enum SortType { 28 | Asc = 'ASC', 29 | Desc = 'DESC', 30 | } 31 | 32 | export const sortState = atom({ 33 | key: 'filter/sort', 34 | default: { 35 | label: 'DESC', 36 | value: SortType.Desc, 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ["master"], 3 | plugins: [ 4 | [ 5 | "@semantic-release/commit-analyzer", 6 | { 7 | preset: "conventionalcommits", 8 | }, 9 | ], 10 | "@semantic-release/release-notes-generator", 11 | "@semantic-release/changelog", 12 | [ 13 | "@semantic-release/npm", 14 | { 15 | npmPublish: false, 16 | }, 17 | ], 18 | "@semantic-release/git", 19 | [ 20 | "@semantic-release/exec", 21 | { 22 | prepareCmd: 23 | "zip -qq -r logseq-plugin-todo-${nextRelease.version}.zip dist readme.md LICENSE package.json logo.svg", 24 | }, 25 | ], 26 | [ 27 | "@semantic-release/github", 28 | { 29 | assets: "logseq-plugin-todo-*.zip", 30 | }, 31 | ], 32 | ], 33 | }; 34 | -------------------------------------------------------------------------------- /src/querys/scheduled.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { Dayjs } from 'dayjs'; 2 | 3 | export default function getScheduledTaskQuery( 4 | treatJournalEntriesAsScheduled = true, 5 | startDate: Dayjs | Date = new Date(), 6 | ) { 7 | const start = dayjs(startDate).format('YYYYMMDD'); 8 | 9 | const journalEntryCond = treatJournalEntriesAsScheduled ? ` 10 | (and 11 | [?p :block/journal? true] 12 | [?p :block/journal-day ?d])` : ''; 13 | 14 | const query = ` 15 | [:find (pull ?b [*]) 16 | :where 17 | [?b :block/marker ?marker] 18 | [(contains? #{"NOW" "LATER" "TODO" "DOING"} ?marker)] 19 | [?b :block/page ?p] 20 | (or 21 | (or 22 | [?b :block/scheduled ?d] 23 | [?b :block/deadline ?d]) 24 | ${journalEntryCond}) 25 | [(> ?d ${start})]] 26 | `; 27 | 28 | return query; 29 | } 30 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { BlockEntity } from '@logseq/libs/dist/LSPlugin'; 2 | 3 | export function getBlockUUID(block: BlockEntity) { 4 | if (typeof block.uuid === 'string') { 5 | return block.uuid; 6 | } 7 | // @ts-ignore 8 | return block.uuid.$uuid$; 9 | } 10 | 11 | export function fixPreferredDateFormat(preferredDateFormat: string) { 12 | const format = preferredDateFormat 13 | .replace('yyyy', 'YYYY') 14 | .replace('dd', 'DD') 15 | .replace('do', 'Do') 16 | .replace('EEEE', 'dddd') 17 | .replace('EEE', 'ddd') 18 | .replace('EE', 'dd') 19 | .replace('E', 'dd'); 20 | return format; 21 | } 22 | 23 | // https://github.com/logseq/logseq/blob/master/libs/src/helpers.ts#L122 24 | export function isValidUUID(s: string) { 25 | return ( 26 | typeof s === 'string' && 27 | s.length === 36 && 28 | /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi.test( 29 | s, 30 | ) 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-current 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 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/workflows/releases.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Releases 4 | 5 | env: 6 | PLUGIN_NAME: logseq-plugin-todo 7 | 8 | # Controls when the action will run. 9 | on: 10 | # push: 11 | # branches: 12 | # - "master" 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | release: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | 22 | # Steps represent a sequence of tasks that will be executed as part of the job 23 | steps: 24 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 25 | - uses: actions/checkout@v3 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: "22" 29 | - uses: pnpm/action-setup@v4 30 | with: 31 | version: 9 32 | - run: pnpm install 33 | - run: pnpm build 34 | - name: Install zip 35 | uses: montudor/action-zip@v1 36 | - name: Release 37 | run: npx semantic-release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /src/querys/anytime.ts: -------------------------------------------------------------------------------- 1 | export default function getAnytimeTaskQuery( 2 | customMarkers: string[] = [], 3 | treatJournalEntriesAsScheduled = true, 4 | ) { 5 | const markers = customMarkers.map((m) => '"' + m + '"').join(' '); 6 | const excludeJournalEntries = treatJournalEntriesAsScheduled ? ` 7 | (not [?p :block/journal? true]) 8 | (not [?p :block/journalDay]) 9 | ` : ''; 10 | const cond = 11 | customMarkers.length > 0 12 | ? ` 13 | (or 14 | (and 15 | [(contains? #{"NOW" "LATER" "TODO" "DOING"} ?marker)] 16 | [?b :block/page ?p] 17 | ${excludeJournalEntries} 18 | (not [?b :block/scheduled]) 19 | (not [?b :block/deadline])) 20 | (and 21 | [(contains? #{${markers}} ?marker)] 22 | [?b :block/page ?p] 23 | ${excludeJournalEntries} 24 | (not [?b :block/scheduled]) 25 | (not [?b :block/deadline]))) 26 | ` 27 | : ` 28 | [(contains? #{"NOW" "LATER" "TODO" "DOING"} ?marker)] 29 | [?b :block/page ?p] 30 | ${excludeJournalEntries} 31 | (not [?b :block/scheduled]) 32 | (not [?b :block/deadline]) 33 | `; 34 | 35 | const query = ` 36 | [:find (pull ?b [*]) 37 | :where 38 | [?b :block/marker ?marker] 39 | ${cond}] 40 | `; 41 | return query; 42 | } 43 | -------------------------------------------------------------------------------- /src/state/settings.ts: -------------------------------------------------------------------------------- 1 | import { atom, AtomEffect } from 'recoil'; 2 | import { TaskMarker, TaskPriority } from '../models/TaskEntity'; 3 | import settings from '../settings'; 4 | 5 | interface IPluginSettings { 6 | hotkey: string; 7 | defaultMarker: TaskMarker; 8 | customMarkers: string; 9 | defaultPriority: TaskPriority; 10 | showNextNDaysTask: boolean; 11 | numberOfNextNDays: number; 12 | lightPrimaryBackgroundColor: string; 13 | lightSecondaryBackgroundColor: string; 14 | darkPrimaryBackgroundColor: string; 15 | darkSecondaryBackgroundColor: string; 16 | useDefaultColors: boolean; 17 | sectionTitleColor: string; 18 | openInRightSidebar: boolean; 19 | whereToPlaceNewTask: string; 20 | treatJournalEntriesAsScheduled: boolean; 21 | } 22 | 23 | const settingsChangedEffect: AtomEffect = ({ setSelf }) => { 24 | setSelf({ ...logseq.settings } as unknown as IPluginSettings); 25 | const unlisten = logseq.onSettingsChanged((newSettings) => { 26 | setSelf(newSettings); 27 | }); 28 | return () => unlisten(); 29 | }; 30 | 31 | export const settingsState = atom({ 32 | key: 'settings', 33 | default: settings.reduce((result, item) => ({ ...result, [item.key]: item.default }), {}) as IPluginSettings, 34 | effects: [settingsChangedEffect], 35 | }); 36 | -------------------------------------------------------------------------------- /src/querys/today.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export default function getTodayTaskQuery( 4 | customMarkers: string[] = [], 5 | treatJournalEntriesAsScheduled = true, 6 | ) { 7 | const today = dayjs().format('YYYYMMDD'); 8 | const markers = customMarkers.map((m) => '"' + m + '"').join(' '); 9 | 10 | const journalEntryCond = treatJournalEntriesAsScheduled ? ` 11 | (and 12 | [(contains? #{"NOW" "LATER" "TODO" "DOING"} ?marker)] 13 | [?p :block/journal? true] 14 | [?p :block/journal-day ?d] 15 | (not [?b :block/scheduled]) 16 | (not [?b :block/deadline]) 17 | [(<= ?d ${today})]) 18 | ` : ''; 19 | 20 | const customMarkerCond = customMarkers.length > 0 ? ` 21 | (and 22 | [(contains? #{${markers}} ?marker)] 23 | (or 24 | [?b :block/scheduled ?d] 25 | [?b :block/deadline ?d]) 26 | [(<= ?d ${today})]) 27 | ` : ''; 28 | 29 | const query = ` 30 | [:find (pull ?b [*]) 31 | :where 32 | [?b :block/marker ?marker] 33 | [?b :block/page ?p] 34 | (or 35 | (and 36 | [(contains? #{"NOW" "LATER" "TODO" "DOING"} ?marker)] 37 | (or 38 | [?b :block/scheduled ?d] 39 | [?b :block/deadline ?d]) 40 | [(<= ?d ${today})]) 41 | ${journalEntryCond} 42 | ${customMarkerCond})] 43 | `; 44 | 45 | return query; 46 | } 47 | 48 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Logseq Todo Plugin 2 | 3 | A simple to-do list plugin for logseq 4 | 5 | > This plugin relies solely on the Logseq Plugin API to access local data, and does not store it externally. 6 | 7 | 8 | 9 | ### Features 10 | - Quickly add new to-do items to today's journal page. 11 | - View all of today's to-do items (include scheduled & today's journal page). 12 | - View all to-do items without a schedule. 13 | - Ignore to-do items on a specified page. 14 | 15 | ![](./screenshots/plugin-panel.png) 16 | 17 | ![](./screenshots/plugin-settings.png) 18 | 19 | ## Install 20 | 21 | ### Option 1: directly install via Marketplace 22 | 23 | ### Option 2: manually load 24 | 25 | - turn on Logseq developer mode 26 | - [download the prebuilt package here](https://github.com/ahonn/logseq-plugin-todo/releases) 27 | - unzip the zip file and load from Logseq plugins page 28 | 29 | ## How to use 30 | 31 | - Pin the plugin to the top bar 32 | - Now To-do items can be easily created or edited from the menu bar through the dedicated icon. 33 | - To set task priority (options are `A`=HIGH, `B`=MEDIUM, `C`=LOW), add `[#A]` to your marker. For example, `TODO [#C] text`. 34 | 35 | ## Page Properties 36 | 37 | - `todo-ignore`: Whether to hide the todo task in the current page. see [How to use todo-ignore #8](https://github.com/ahonn/logseq-plugin-todo/issues/8) 38 | 39 | ## Contribution 40 | Issues and PRs are welcome! 41 | 42 | ## Licence 43 | MIT 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logseq-plugin-todo", 3 | "version": "1.22.0", 4 | "main": "dist/index.html", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preinstall": "npx only-allow pnpm" 9 | }, 10 | "license": "MIT", 11 | "dependencies": { 12 | "@logseq/libs": "^0.0.17", 13 | "classnames": "^2.3.2", 14 | "dayjs": "^1.11.6", 15 | "less": "^4.1.3", 16 | "lodash-es": "^4.17.21", 17 | "mousetrap": "^1.6.5", 18 | "mousetrap-global-bind": "^1.1.0", 19 | "rc-checkbox": "^2.3.2", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-error-boundary": "^3.1.4", 23 | "react-select": "^5.7.0", 24 | "react-use": "^17.4.0", 25 | "recoil": "^0.7.6", 26 | "remove-markdown": "^0.5.0", 27 | "swr": "^1.3.0", 28 | "tabler-icons-react": "^1.55.0" 29 | }, 30 | "devDependencies": { 31 | "@semantic-release/changelog": "6.0.1", 32 | "@semantic-release/exec": "6.0.3", 33 | "@semantic-release/git": "10.0.1", 34 | "@semantic-release/npm": "9.0.1", 35 | "@types/lodash-es": "^4.17.6", 36 | "@types/mousetrap": "^1.6.10", 37 | "@types/node": "18.11.9", 38 | "@types/react": "18.0.25", 39 | "@types/react-dom": "18.0.8", 40 | "@types/remove-markdown": "^0.3.1", 41 | "@typescript-eslint/eslint-plugin": "5.42.1", 42 | "@typescript-eslint/parser": "5.42.1", 43 | "@vitejs/plugin-react": "2.2.0", 44 | "conventional-changelog-conventionalcommits": "5.0.0", 45 | "eslint": "8.27.0", 46 | "eslint-plugin-react": "7.31.10", 47 | "eslint-plugin-react-hooks": "^4.6.0", 48 | "node-fetch": "3.3.0", 49 | "semantic-release": "19.0.5", 50 | "typescript": "4.8.4", 51 | "vite": "3.2.3", 52 | "vite-plugin-logseq": "1.1.2", 53 | "vite-plugin-windicss": "1.8.8", 54 | "windicss": "3.5.6" 55 | }, 56 | "logseq": { 57 | "id": "logseq-plugin-todo", 58 | "title": "Todo list", 59 | "icon": "./logo.svg" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/state/theme.ts: -------------------------------------------------------------------------------- 1 | import { selector } from 'recoil'; 2 | import { settingsState } from './settings'; 3 | import { userConfigsState } from './user-configs'; 4 | 5 | export const themeModeState = selector({ 6 | key: 'themeMode', 7 | get: ({ get }) => { 8 | const userConfigs = get(userConfigsState); 9 | return userConfigs.preferredThemeMode; 10 | } 11 | }); 12 | 13 | const getStyleVariable = (variableName: string) => { 14 | const bodyElement = window.parent.document.body; 15 | if (bodyElement) { 16 | return getComputedStyle(bodyElement).getPropertyValue(variableName); 17 | } else { 18 | return null; 19 | } 20 | } 21 | 22 | export const themeColorsState = selector({ 23 | key: 'themeColors', 24 | get: () => { 25 | const themeColors = { 26 | primaryBackgroundColor: getStyleVariable('--ls-primary-background-color')!, 27 | secondaryBackgroundColor: getStyleVariable('--ls-secondary-background-color')!, 28 | sectionTitleColor: getStyleVariable('--ls-link-text-color')!, 29 | } 30 | 31 | if (Object.values(themeColors).some((value) => value === null)) return null 32 | return themeColors 33 | } 34 | }); 35 | 36 | export const themeStyleState = selector({ 37 | key: 'themeStyle', 38 | get: ({ get }) => { 39 | const settings = get(settingsState); 40 | const themeMode = get(themeModeState); 41 | const themeColors = get(themeColorsState); 42 | 43 | const isLightMode = themeMode === 'light'; 44 | 45 | if (settings.useDefaultColors && themeColors) return themeColors; 46 | 47 | const primaryBackgroundColor = isLightMode 48 | ? settings.lightPrimaryBackgroundColor 49 | : settings.darkPrimaryBackgroundColor; 50 | 51 | const secondaryBackgroundColor = isLightMode 52 | ? settings.lightSecondaryBackgroundColor 53 | : settings.darkSecondaryBackgroundColor; 54 | 55 | return { 56 | primaryBackgroundColor, 57 | secondaryBackgroundColor, 58 | sectionTitleColor: settings.sectionTitleColor, 59 | }; 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /src/state/user-configs.ts: -------------------------------------------------------------------------------- 1 | import { AppUserConfigs } from '@logseq/libs/dist/LSPlugin'; 2 | import { atom, AtomEffect, selector } from 'recoil'; 3 | import { logseq as plugin } from '../../package.json'; 4 | import { TaskMarker } from '../models/TaskEntity'; 5 | import { settingsState } from './settings'; 6 | 7 | export const USER_CONFIGS_KEY = `${plugin.id}#userConfigs`; 8 | 9 | export const DEFAULT_USER_CONFIGS: Partial = { 10 | preferredLanguage: 'en', 11 | preferredThemeMode: 'light', 12 | preferredFormat: 'markdown', 13 | preferredWorkflow: 'now', 14 | preferredTodo: 'LATER', 15 | preferredDateFormat: 'MMM do, yyyy', 16 | }; 17 | 18 | const themeModeChangeEffect: AtomEffect = ({ onSet }) => { 19 | onSet(({ preferredThemeMode }) => { 20 | if (preferredThemeMode === 'dark') { 21 | document.documentElement.classList.add('dark'); 22 | document.documentElement.classList.remove('light'); 23 | } else { 24 | document.documentElement.classList.add('light'); 25 | document.documentElement.classList.remove('dark'); 26 | } 27 | }); 28 | }; 29 | 30 | const localStorageEffect: AtomEffect = ({ setSelf, onSet }) => { 31 | const savedValue = localStorage.getItem(USER_CONFIGS_KEY); 32 | if (savedValue != null) { 33 | setSelf(JSON.parse(savedValue)); 34 | } 35 | 36 | onSet((newValue, _, isReset) => { 37 | isReset 38 | ? localStorage.removeItem(USER_CONFIGS_KEY) 39 | : localStorage.setItem(USER_CONFIGS_KEY, JSON.stringify(newValue)); 40 | }); 41 | }; 42 | 43 | export const userConfigsState = atom({ 44 | key: 'userConfigs', 45 | default: DEFAULT_USER_CONFIGS as AppUserConfigs, 46 | effects: [localStorageEffect, themeModeChangeEffect], 47 | }); 48 | 49 | export const taskMarkersState = selector<(TaskMarker | string)[]>({ 50 | key: 'taskMarkers', 51 | get: ({ get }) => { 52 | const { preferredWorkflow } = get(userConfigsState); 53 | const settings = get(settingsState); 54 | const customMarkers = 55 | settings.customMarkers === '' ? [] : settings.customMarkers.split(','); 56 | if (preferredWorkflow === 'now') { 57 | return [TaskMarker.LATER, TaskMarker.NOW, ...customMarkers]; 58 | } 59 | return [TaskMarker.TODO, TaskMarker.DOING, ...customMarkers]; 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/TaskInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useImperativeHandle, useRef } from 'react'; 2 | import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; 3 | import { CirclePlus } from 'tabler-icons-react'; 4 | import { inputState } from '../state/input'; 5 | import { themeStyleState } from '../state/theme'; 6 | import { visibleState } from '../state/visible'; 7 | 8 | export interface ITaskInputRef { 9 | focus: () => void; 10 | } 11 | 12 | export interface ITaskInputProps { 13 | onCreateTask(content: string): Promise; 14 | } 15 | 16 | const TaskInput: React.ForwardRefRenderFunction< 17 | ITaskInputRef, 18 | ITaskInputProps 19 | > = (props, ref) => { 20 | const [input, setInput] = useRecoilState(inputState); 21 | const setVisible = useSetRecoilState(visibleState); 22 | const inputRef = useRef(null); 23 | const themeStyle = useRecoilValue(themeStyleState); 24 | 25 | const focus = () => { 26 | if (inputRef.current) { 27 | inputRef.current.focus(); 28 | } 29 | }; 30 | 31 | useImperativeHandle(ref, () => ({ 32 | focus, 33 | })); 34 | 35 | return ( 36 |
37 |
43 | 44 | setInput(e.target.value)} 50 | placeholder="Type to search, enter to create" 51 | onKeyPress={async (e) => { 52 | if (e.key === 'Enter' && input.trim() !== '') { 53 | e.preventDefault(); 54 | await props.onCreateTask(input); 55 | setInput(''); 56 | // HACK: Force focus to solve the problem that the focus cannot be 57 | // focused after creating the task on current page 58 | setVisible(false); 59 | setTimeout(() => { 60 | setVisible(true); 61 | }); 62 | } 63 | }} 64 | /> 65 |
66 |
67 | ); 68 | }; 69 | 70 | export default React.forwardRef(TaskInput); 71 | -------------------------------------------------------------------------------- /devbox.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfile_version": "1", 3 | "packages": { 4 | "nodejs@18.17.1": { 5 | "last_modified": "2023-09-15T06:49:28Z", 6 | "plugin_version": "0.0.2", 7 | "resolved": "github:NixOS/nixpkgs/46688f8eb5cd6f1298d873d4d2b9cf245e09e88e#nodejs_18", 8 | "source": "devbox-search", 9 | "version": "18.17.1", 10 | "systems": { 11 | "aarch64-darwin": { 12 | "outputs": [ 13 | { 14 | "name": "out", 15 | "path": "/nix/store/nk77k4d3fj6i2fbd9nmqm5bqqdjy3knb-nodejs-18.17.1", 16 | "default": true 17 | }, 18 | { 19 | "name": "libv8", 20 | "path": "/nix/store/495wci9zkck1q1v5akdkh1pljghmqmb3-nodejs-18.17.1-libv8" 21 | } 22 | ], 23 | "store_path": "/nix/store/nk77k4d3fj6i2fbd9nmqm5bqqdjy3knb-nodejs-18.17.1" 24 | }, 25 | "aarch64-linux": { 26 | "outputs": [ 27 | { 28 | "name": "out", 29 | "path": "/nix/store/kgzwqvksqjms49jlfz9nzz2cjxsh8ani-nodejs-18.17.1", 30 | "default": true 31 | }, 32 | { 33 | "name": "libv8", 34 | "path": "/nix/store/r4yp4071a23wcjni822ppw89fac7q5wf-nodejs-18.17.1-libv8" 35 | } 36 | ], 37 | "store_path": "/nix/store/kgzwqvksqjms49jlfz9nzz2cjxsh8ani-nodejs-18.17.1" 38 | }, 39 | "x86_64-darwin": { 40 | "outputs": [ 41 | { 42 | "name": "out", 43 | "path": "/nix/store/x01bx6r10w0835xmhxyjqn9khamdh9pj-nodejs-18.17.1", 44 | "default": true 45 | }, 46 | { 47 | "name": "libv8", 48 | "path": "/nix/store/vad99zm2ila2lgidx478j3xjvi861v03-nodejs-18.17.1-libv8" 49 | } 50 | ], 51 | "store_path": "/nix/store/x01bx6r10w0835xmhxyjqn9khamdh9pj-nodejs-18.17.1" 52 | }, 53 | "x86_64-linux": { 54 | "outputs": [ 55 | { 56 | "name": "out", 57 | "path": "/nix/store/fg86njc0q2djbyfaqvnaq7x0khpc6sf4-nodejs-18.17.1", 58 | "default": true 59 | }, 60 | { 61 | "name": "libv8", 62 | "path": "/nix/store/0zm665zwnqhk6wjpypl77cbwbhdzwi5x-nodejs-18.17.1-libv8" 63 | } 64 | ], 65 | "store_path": "/nix/store/fg86njc0q2djbyfaqvnaq7x0khpc6sf4-nodejs-18.17.1" 66 | } 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@logseq/libs'; 2 | import React from 'react'; 3 | import * as ReactDOM from 'react-dom/client'; 4 | import { RecoilRoot } from 'recoil'; 5 | import { logseq as plugin } from '../package.json'; 6 | import App from './App'; 7 | import settings from './settings'; 8 | 9 | type Rect = { 10 | top: number; 11 | right: number; 12 | left: number; 13 | bottom: number; 14 | }; 15 | 16 | let cachedRect: Rect | undefined = undefined; 17 | 18 | async function openTaskPanel(e?: { rect: Rect }) { 19 | const taskPanel = document.querySelector('#' + plugin.id)!; 20 | let rect = e?.rect ?? cachedRect; 21 | 22 | if (!rect) { 23 | try { 24 | const elRect = await logseq.UI.queryElementRect('#' + plugin.id); 25 | if (elRect) { 26 | rect = elRect; 27 | cachedRect = elRect; 28 | } 29 | } catch (e) { 30 | console.error(e); 31 | } 32 | } 33 | 34 | if (rect) { 35 | cachedRect = rect; 36 | } 37 | 38 | const position = rect 39 | ? { 40 | top: `${rect.top + 40}px`, 41 | left: rect.left + 'px', 42 | } 43 | : { 44 | top: '40px', 45 | right: '200px', 46 | }; 47 | 48 | // @ts-ignore 49 | Object.assign(taskPanel.style, { 50 | position: 'fixed', 51 | ...position, 52 | }); 53 | 54 | logseq.showMainUI(); 55 | } 56 | 57 | function createModel() { 58 | return { 59 | openTaskPanel, 60 | }; 61 | } 62 | 63 | const registeredHotKeySet = new Set(); 64 | function registerHotKey(binding: string) { 65 | if (!binding || registeredHotKeySet.has(binding)) { 66 | return; 67 | } 68 | 69 | logseq.App.registerCommandShortcut({ binding }, openTaskPanel); 70 | registeredHotKeySet.add(binding); 71 | } 72 | 73 | function main() { 74 | logseq.setMainUIInlineStyle({ 75 | position: 'fixed', 76 | zIndex: 11, 77 | }); 78 | 79 | logseq.App.registerUIItem('toolbar', { 80 | key: plugin.id, 81 | template: ` 82 | 83 | 84 | 85 | `, 86 | }); 87 | 88 | if (logseq.settings?.hotkey) { 89 | registerHotKey(logseq.settings?.hotkey as string); 90 | } 91 | logseq.onSettingsChanged((settings) => { 92 | registerHotKey(settings?.hotkey); 93 | }); 94 | 95 | logseq.App.registerCommandPalette( 96 | { 97 | key: 'logseq-plugin-todo', 98 | label: 'Open todo list', 99 | }, 100 | () => { 101 | openTaskPanel(); 102 | }, 103 | ); 104 | 105 | const root = ReactDOM.createRoot(document.getElementById('app')!); 106 | root.render( 107 | 108 | 109 | 110 | 111 | , 112 | ); 113 | } 114 | 115 | logseq.useSettingsSchema(settings).ready(createModel()).then(main).catch(console.error); 116 | -------------------------------------------------------------------------------- /src/models/TaskEntity.ts: -------------------------------------------------------------------------------- 1 | import removeMarkdown from 'remove-markdown'; 2 | import { BlockEntity, PageEntity } from '@logseq/libs/dist/LSPlugin.user'; 3 | import { getBlockUUID } from '../utils'; 4 | 5 | export enum TaskMarker { 6 | LATER = 'LATER', 7 | NOW = 'NOW', 8 | TODO = 'TODO', 9 | DOING = 'DOING', 10 | DONE = 'DONE', 11 | WAITING = 'WAITING', 12 | } 13 | 14 | export enum TaskPriority { 15 | HIGH = 'A', 16 | MEDIUM = 'B', 17 | LOW = 'C', 18 | NONE = 'NONE' 19 | } 20 | 21 | export const TASK_PRIORITY_WEIGHT = { 22 | [TaskPriority.HIGH]: 100, 23 | [TaskPriority.MEDIUM]: 50, 24 | [TaskPriority.LOW]: 10, 25 | [TaskPriority.NONE]: 0, 26 | }; 27 | 28 | export interface TaskEntityObject { 29 | uuid: string; 30 | content: string; 31 | rawContent: string; 32 | marker: TaskMarker; 33 | priority: TaskPriority; 34 | scheduled: number | undefined; 35 | repeated: boolean; 36 | completed: boolean; 37 | page: { 38 | name: string; 39 | uuid: string; 40 | journalDay: number | undefined; 41 | updatedAt: number | undefined; 42 | } 43 | } 44 | 45 | class TaskEntity { 46 | private block: BlockEntity; 47 | private page: PageEntity; 48 | private _content: string; 49 | 50 | constructor(block: BlockEntity, page: PageEntity) { 51 | this.block = block; 52 | this.page = page; 53 | this._content = this.trimContent(block.content); 54 | } 55 | 56 | public get uuid(): string { 57 | return getBlockUUID(this.block); 58 | } 59 | 60 | public get content(): string { 61 | return this._content; 62 | } 63 | 64 | public set content(value: string) { 65 | this._content = this.trimContent(value); 66 | } 67 | 68 | public trimContent(rawContent: string): string { 69 | let content = rawContent; 70 | content = content.replace(this.block.marker as string, ''); 71 | content = content.replace(`[#${this.block.priority}]`, ''); 72 | content = content.replace(/SCHEDULED: <[^>]+>/, ''); 73 | content = content.replace(/DEADLINE: <[^>]+>/, ''); 74 | content = content.replace(/(:LOGBOOK:)|(\*\s.*)|(:END:)|(CLOCK:.*)/gm, ''); 75 | content = content.replace(/id::[^:]+/, ''); 76 | content = removeMarkdown(content); 77 | return content.trim(); 78 | } 79 | 80 | public get rawContent(): string { 81 | return this.block.content; 82 | } 83 | 84 | public get marker(): TaskMarker { 85 | return this.block.marker as TaskMarker; 86 | } 87 | 88 | public get priority(): TaskPriority { 89 | return (this.block.priority ?? TaskPriority.NONE) as TaskPriority; 90 | } 91 | 92 | public get scheduled(): number | undefined { 93 | return (this.block.scheduled ?? this.block.deadline ?? this.page.journalDay) as number; 94 | } 95 | 96 | public get repeated(): boolean { 97 | return !!this.block.repeated; 98 | } 99 | 100 | public get completed(): boolean { 101 | return this.marker === TaskMarker.DONE; 102 | } 103 | 104 | public getPageProperty(key: string): T { 105 | // @ts-ignore 106 | return this.page.properties?.[key]; 107 | } 108 | 109 | public toObject(): TaskEntityObject { 110 | return { 111 | uuid: this.uuid, 112 | content: this._content, 113 | rawContent: this.rawContent, 114 | marker: this.marker, 115 | priority: this.priority, 116 | scheduled: this.scheduled, 117 | repeated: this.repeated, 118 | completed: this.completed, 119 | page: { 120 | name: this.page.name, 121 | uuid: this.page.uuid, 122 | journalDay: this.page['journalDay'], 123 | updatedAt: this.page.updatedAt, 124 | }, 125 | }; 126 | } 127 | } 128 | 129 | export default TaskEntity; 130 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { SettingSchemaDesc } from '@logseq/libs/dist/LSPlugin'; 2 | 3 | const settings: SettingSchemaDesc[] = [ 4 | { 5 | key: 'hotkey', 6 | type: 'string', 7 | title: 'Quick Open Hotkey', 8 | description: 'Use this hotkey to quickly open the task panel', 9 | default: 'mod+shift+t', 10 | }, 11 | { 12 | key: 'defaultMarker', 13 | type: 'string', 14 | title: 'Default Marker', 15 | description: 16 | 'Assign a default marker to new to-do items and filter your to-do list by markers', 17 | default: '', 18 | }, 19 | { 20 | key: 'customMarkers', 21 | type: 'string', 22 | title: 'Custom Markers', 23 | description: 'Custom Markers, separate multiple tags with commas', 24 | default: 'WAITING', 25 | }, 26 | { 27 | key: 'defaultPriority', 28 | type: 'string', 29 | title: 'Default Priority', 30 | description: 31 | 'Assign a default priority to new to-do items and filter your to-do list by priority', 32 | default: '', 33 | }, 34 | { 35 | key: 'whereToPlaceNewTask', 36 | type: 'string', 37 | title: 'Where to Place New Tasks', 38 | description: 'Choose where new task will be placed on the journal page', 39 | default: '', 40 | }, 41 | { 42 | key: 'showNextNDaysTask', 43 | type: 'boolean', 44 | title: 'Show Next N Days Task Section', 45 | description: 'Display a section for the next N days of tasks after today', 46 | default: false, 47 | }, 48 | { 49 | key: 'numberOfNextNDays', 50 | type: 'number', 51 | title: 'Number of Next N Days Tasks', 52 | description: 53 | 'Set the number of days in the "Next N Days" section of the task list', 54 | default: 14, 55 | }, 56 | { 57 | key: 'treatJournalEntriesAsScheduled', 58 | type: 'boolean', 59 | title: 'Treat journal entries as scheduled', 60 | description: 'Treat entries an a journal page as scheduled on that day', 61 | default: true, 62 | }, 63 | { 64 | key: 'openInRightSidebar', 65 | type: 'boolean', 66 | title: 'Open Task in Right Sidebar', 67 | description: 'Open task in the right sidebar', 68 | default: false, 69 | }, 70 | { 71 | key: 'useDefaultColors', 72 | type: 'boolean', 73 | title: 'Use Default Colors', 74 | description: 'Use the colors from your current Logseq theme', 75 | default: false, 76 | }, 77 | { 78 | key: 'sectionTitleColor', 79 | type: 'string', 80 | title: 'Section Title Color', 81 | description: 'Set the color of task section titles', 82 | default: '#106ba3', 83 | inputAs: 'color', 84 | }, 85 | { 86 | key: 'lightPrimaryBackgroundColor', 87 | type: 'string', 88 | title: 'Light Mode Primary Background Color', 89 | description: 'Set the primary background color for light mode', 90 | default: '#ffffff', 91 | inputAs: 'color', 92 | }, 93 | { 94 | key: 'lightSecondaryBackgroundColor', 95 | type: 'string', 96 | title: 'Light Mode Secondary Background Color', 97 | description: 'Set the secondary background color for light mode', 98 | default: '#f7f7f7', 99 | inputAs: 'color', 100 | }, 101 | { 102 | key: 'darkPrimaryBackgroundColor', 103 | type: 'string', 104 | title: 'Dark Mode Primary Background Color', 105 | description: 'Set the primary background color for dark mode', 106 | default: '#023643', 107 | inputAs: 'color', 108 | }, 109 | { 110 | key: 'darkSecondaryBackgroundColor', 111 | type: 'string', 112 | title: 'Dark Mode Secondary Background Color', 113 | description: 'Set the secondary background color for dark mode', 114 | default: '#002B37', 115 | inputAs: 'color', 116 | }, 117 | ]; 118 | 119 | export default settings; 120 | -------------------------------------------------------------------------------- /src/state/tasks.ts: -------------------------------------------------------------------------------- 1 | import { BlockEntity, PageEntity } from '@logseq/libs/dist/LSPlugin'; 2 | import { selectorFamily } from 'recoil'; 3 | import TaskEntity, { 4 | TaskEntityObject, 5 | TASK_PRIORITY_WEIGHT, 6 | } from '../models/TaskEntity'; 7 | import { getBlockUUID, isValidUUID } from '../utils'; 8 | import { markerState, priorityState, sortState, SortType } from './filter'; 9 | 10 | async function getTaskEntitiesByQuery(query: string) { 11 | const collections = await window.logseq.DB.datascriptQuery( 12 | query, 13 | ); 14 | const tasks = await Promise.all( 15 | (collections ?? []).map(async ([item]) => { 16 | const uuid = getBlockUUID(item); 17 | const block = await window.logseq.Editor.getBlock(uuid, { 18 | includeChildren: true, 19 | }); 20 | if (block === undefined) { 21 | return null; 22 | } 23 | 24 | const page = await window.logseq.Editor.getPage( 25 | (block?.page as PageEntity).name, 26 | ); 27 | if (page === undefined) { 28 | return null; 29 | } 30 | 31 | if (page?.journalDay) { 32 | const blocksTree = await window.logseq.Editor.getPageBlocksTree(page!.uuid); 33 | page.properties = Object.assign(page.properties ?? {}, blocksTree[0].properties) 34 | } 35 | 36 | const taskEntity = new TaskEntity(block!, page!); 37 | if ( 38 | taskEntity.content.startsWith('((') && 39 | taskEntity.content.endsWith('))') 40 | ) { 41 | const uuid = taskEntity.content.slice(2, -2); 42 | if (isValidUUID(uuid)) { 43 | const block = await window.logseq.Editor.getBlock(uuid, { 44 | includeChildren: true, 45 | }); 46 | if (block) { 47 | taskEntity.content = block.content; 48 | } 49 | } 50 | } 51 | return taskEntity; 52 | }), 53 | ); 54 | 55 | return ( 56 | tasks 57 | // @ts-ignore 58 | .filter((task) => { 59 | return task && !task.getPageProperty('todoIgnore'); 60 | }) 61 | .map((task) => task!.toObject()) 62 | .sort((a, b) => { 63 | if (a.scheduled !== undefined || b.scheduled !== undefined) { 64 | if (a.scheduled === b.scheduled) { 65 | return ( 66 | TASK_PRIORITY_WEIGHT[b.priority] - 67 | TASK_PRIORITY_WEIGHT[a.priority] 68 | ); 69 | } 70 | return (b.scheduled ?? 0) - (a.scheduled ?? 0); 71 | } 72 | 73 | if (a.page.updatedAt !== undefined || b.page.updatedAt !== undefined) { 74 | if (a.page.updatedAt === b.page.updatedAt) { 75 | return ( 76 | TASK_PRIORITY_WEIGHT[b.priority] - 77 | TASK_PRIORITY_WEIGHT[a.priority] 78 | ); 79 | } 80 | return (b.page.updatedAt ?? 0) - (a.page.updatedAt ?? 0); 81 | } 82 | 83 | return 0; 84 | }) 85 | ); 86 | } 87 | 88 | export const tasksState = selectorFamily({ 89 | key: 'tasks', 90 | get: (query: string) => () => getTaskEntitiesByQuery(query), 91 | cachePolicy_UNSTABLE: { 92 | eviction: 'most-recent', 93 | }, 94 | }); 95 | 96 | export const filterdTasksState = selectorFamily({ 97 | key: 'filterd-tasks', 98 | get: 99 | (query: string) => 100 | ({ get }) => { 101 | const tasks = get(tasksState(query)); 102 | const marker = get(markerState); 103 | const priority = get(priorityState); 104 | const sort = get(sortState); 105 | 106 | return tasks.filter((task: TaskEntityObject) => { 107 | if (marker.value && task.marker !== marker.value) { 108 | return false; 109 | } 110 | 111 | if (priority.value && task.priority !== priority.value) { 112 | return false; 113 | } 114 | 115 | return true; 116 | }).sort((a, b) => { 117 | if (a.scheduled === undefined || b.scheduled === undefined) { 118 | return 0; 119 | } 120 | if (sort.value === SortType.Asc) { 121 | return a.scheduled - b.scheduled; 122 | } 123 | return b.scheduled - a.scheduled; 124 | }); 125 | }, 126 | }); 127 | -------------------------------------------------------------------------------- /src/components/TaskSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from 'react'; 2 | import { 3 | useRecoilCallback, 4 | useRecoilValue, 5 | useRecoilValueLoadable, 6 | } from 'recoil'; 7 | import { groupBy } from 'lodash-es'; 8 | import { TaskEntityObject } from '../models/TaskEntity'; 9 | import { filterdTasksState } from '../state/tasks'; 10 | import { themeStyleState } from '../state/theme'; 11 | import { visibleState } from '../state/visible'; 12 | import TaskItem from './TaskItem'; 13 | import { settingsState } from '../state/settings'; 14 | import { openTask, openTaskPage } from '../api'; 15 | import { inputState } from '../state/input'; 16 | import { ChevronsRight } from 'tabler-icons-react'; 17 | 18 | export enum GroupBy { 19 | Page, 20 | Tag, 21 | Namespace, 22 | } 23 | 24 | export interface ITaskSectionProps { 25 | title: string; 26 | query: string; 27 | groupBy?: GroupBy; 28 | } 29 | 30 | const TaskSection: React.FC = (props) => { 31 | const { title, query } = props; 32 | const [tasks, setTasks] = useState([]); 33 | const visible = useRecoilValue(visibleState); 34 | const tasksLoadable = useRecoilValueLoadable(filterdTasksState(query)); 35 | const themeStyle = useRecoilValue(themeStyleState); 36 | const { openInRightSidebar } = useRecoilValue(settingsState); 37 | const input = useRecoilValue(inputState); 38 | 39 | const refreshAll = useRecoilCallback( 40 | ({ snapshot, refresh }) => 41 | () => { 42 | for (const node of snapshot.getNodes_UNSTABLE()) { 43 | refresh(node); 44 | } 45 | }, 46 | [], 47 | ); 48 | 49 | useEffect(() => { 50 | switch (tasksLoadable.state) { 51 | case 'hasValue': { 52 | const tasks = tasksLoadable.contents.filter( 53 | (task: TaskEntityObject) => { 54 | return task.content.toLowerCase().includes(input.toLowerCase()); 55 | }, 56 | ); 57 | setTasks(tasks); 58 | break; 59 | } 60 | case 'hasError': 61 | throw tasksLoadable.contents; 62 | } 63 | }, [tasksLoadable.state, tasksLoadable.contents, input]); 64 | 65 | useEffect(() => { 66 | if (visible) { 67 | refreshAll(); 68 | } 69 | }, [visible, refreshAll]); 70 | 71 | const taskGroups = useMemo(() => { 72 | switch (props.groupBy) { 73 | case GroupBy.Page: 74 | return groupBy(tasks, (task: TaskEntityObject) => task.page.name); 75 | default: 76 | return { '': tasks }; 77 | } 78 | }, [props.groupBy, tasks]); 79 | 80 | const openTaskGroups = React.useCallback(() => { 81 | const tasksCopy = [...tasks]; 82 | tasksCopy.reverse(); 83 | 84 | tasksCopy.forEach((task) => { 85 | openTask(task, { 86 | openInRightSidebar: true, 87 | }); 88 | }); 89 | window.logseq.hideMainUI(); 90 | }, [tasks]); 91 | 92 | if (tasks.length === 0) { 93 | return null; 94 | } 95 | 96 | return ( 97 |
98 |
99 |

103 | {title} 104 |

105 |
106 | 110 |
111 |
112 |
113 | {(Object.entries(taskGroups) ?? []).map(([name, tasks]) => { 114 | const [{ page }] = tasks; 115 | return ( 116 |
117 | {name && ( 118 |

openTaskPage(page, { openInRightSidebar })} 121 | > 122 | {name} 123 |

124 | )} 125 | {(tasks ?? []).map((task) => ( 126 | 127 | ))} 128 |
129 | ); 130 | })} 131 |
132 |
133 | ); 134 | }; 135 | 136 | export default TaskSection; 137 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { BlockEntity } from '@logseq/libs/dist/LSPlugin.user'; 2 | import dayjs from 'dayjs'; 3 | import { TaskEntityObject, TaskMarker, TaskPriority } from './models/TaskEntity'; 4 | 5 | export const MARKER_GROUPS: Record = { 6 | [TaskMarker.TODO]: [TaskMarker.TODO, TaskMarker.DOING], 7 | [TaskMarker.LATER]: [TaskMarker.LATER, TaskMarker.NOW], 8 | }; 9 | 10 | export interface ITaskOptions { 11 | marker?: TaskMarker | string; 12 | markerGroup?: (TaskMarker | string)[]; 13 | priority?: TaskPriority; 14 | whereToPlaceNewTask?: string; 15 | } 16 | 17 | export function isTodayTask(task: TaskEntityObject) { 18 | const { scheduled } = task; 19 | if (!scheduled) return false; 20 | return dayjs(new Date()).format('YYYYMMDD') === scheduled.toString(); 21 | } 22 | 23 | export async function createNewTask(date: string, content: string, opts: ITaskOptions) { 24 | const { marker, priority, whereToPlaceNewTask } = opts; 25 | const rawContent = `${marker} ${priority ? `[#${priority}]` : ''} ${content}`; 26 | let page = await window.logseq.Editor.getPage(date); 27 | if (page === null) { 28 | page = await window.logseq.Editor.createPage(date, { 29 | journal: true, 30 | redirect: false, 31 | }); 32 | } 33 | const blocksTree = await window.logseq.Editor.getPageBlocksTree(date); 34 | 35 | if (whereToPlaceNewTask) { 36 | let parentBlock = blocksTree.find( 37 | (block: BlockEntity) => block.content === whereToPlaceNewTask, 38 | ); 39 | if (parentBlock === undefined) { 40 | parentBlock = (await window.logseq.Editor.appendBlockInPage( 41 | page!.name, 42 | whereToPlaceNewTask, 43 | )) as BlockEntity; 44 | } 45 | await window.logseq.Editor.insertBlock(parentBlock!.uuid, rawContent); 46 | } else { 47 | await window.logseq.Editor.appendBlockInPage(page!.name, rawContent); 48 | } 49 | 50 | if (blocksTree.length === 1 && blocksTree[0].content === '') { 51 | await window.logseq.Editor.removeBlock(blocksTree[0].uuid); 52 | } 53 | } 54 | 55 | export async function toggleTaskStatus( 56 | task: TaskEntityObject, 57 | options: ITaskOptions & Required>, 58 | ) { 59 | const { uuid, completed, marker } = task; 60 | const nextMarker = completed ? options.marker : TaskMarker.DONE; 61 | await window.logseq.Editor.updateBlock(uuid, task.rawContent.replace(marker, nextMarker)); 62 | } 63 | 64 | interface IOpenTaskOptions { 65 | openInRightSidebar?: boolean; 66 | } 67 | 68 | export function openTask(task: TaskEntityObject, opts?: IOpenTaskOptions) { 69 | const { uuid } = task; 70 | if (opts?.openInRightSidebar) { 71 | return window.logseq.Editor.openInRightSidebar(uuid); 72 | } 73 | return window.logseq.Editor.scrollToBlockInPage(task.page.name, uuid); 74 | } 75 | 76 | export function openTaskPage(page: TaskEntityObject['page'], opts?: IOpenTaskOptions) { 77 | if (opts?.openInRightSidebar) { 78 | return window.logseq.Editor.openInRightSidebar(page.uuid); 79 | } 80 | return window.logseq.Editor.scrollToBlockInPage(page.name, page.uuid); 81 | } 82 | 83 | export async function toggleTaskMarker( 84 | task: TaskEntityObject, 85 | options: ITaskOptions & Required>, 86 | ) { 87 | const { uuid, rawContent, marker } = task; 88 | const { markerGroup } = options; 89 | const currentMarkIndex = markerGroup.findIndex((m) => m === marker); 90 | const newMarker = markerGroup[(currentMarkIndex + 1) % markerGroup.length]; 91 | 92 | const newRawContent = rawContent.replace(new RegExp(`^${marker}`), newMarker); 93 | await window.logseq.Editor.updateBlock(uuid, newRawContent); 94 | } 95 | 96 | export async function setTaskScheduled(task: TaskEntityObject, date: Date | null) { 97 | const { uuid, rawContent } = task; 98 | let newRawContent = task.rawContent; 99 | if (date === null) { 100 | newRawContent = rawContent.replace(/SCHEDULED: <[^>]+>/, ''); 101 | await window.logseq.Editor.updateBlock(uuid, newRawContent); 102 | return; 103 | } 104 | 105 | const scheduledString = `SCHEDULED: <${dayjs(date).format('YYYY-MM-DD ddd')}>`; 106 | if (rawContent.includes('SCHEDULED')) { 107 | newRawContent = rawContent.replace(/SCHEDULED: <[^>]+>/, scheduledString); 108 | } else { 109 | const lines = rawContent.split('\n'); 110 | lines.splice(1, 0, scheduledString); 111 | newRawContent = lines.join('\n'); 112 | } 113 | 114 | await window.logseq.Editor.updateBlock(uuid, newRawContent); 115 | } 116 | -------------------------------------------------------------------------------- /src/components/TaskFilter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Select, { Theme } from 'react-select'; 3 | import { useRecoilState, useRecoilValue } from 'recoil'; 4 | import { CircleOff } from 'tabler-icons-react'; 5 | import { taskMarkersState } from '../state/user-configs'; 6 | import { 7 | DEFAULT_OPTION, 8 | markerState, 9 | priorityState, 10 | PRIORITY_OPTIONS, 11 | sortState, 12 | SortType, 13 | } from '../state/filter'; 14 | import { themeStyleState } from '../state/theme'; 15 | import { settingsState } from '../state/settings'; 16 | 17 | const TaskFilter: React.FC = () => { 18 | const [marker, setMarker] = useRecoilState(markerState); 19 | const [priority, setPriority] = useRecoilState(priorityState); 20 | const [sort, setSort] = useRecoilState(sortState); 21 | const taskMarkers = useRecoilValue(taskMarkersState); 22 | const themeStyle = useRecoilValue(themeStyleState); 23 | const settings = useRecoilValue(settingsState); 24 | 25 | const markerOptions = React.useMemo(() => { 26 | return taskMarkers.reduce( 27 | (options, marker) => { 28 | return [...options, { label: marker, value: marker }]; 29 | }, 30 | [DEFAULT_OPTION], 31 | ); 32 | }, [taskMarkers]); 33 | 34 | const priorityOptions = React.useMemo(() => { 35 | return PRIORITY_OPTIONS.reduce( 36 | (options, marker) => { 37 | return [...options, { label: marker, value: marker }]; 38 | }, 39 | [DEFAULT_OPTION], 40 | ); 41 | }, []); 42 | 43 | const selectClassNames = React.useMemo( 44 | () => ({ 45 | container: () => 'text-xs', 46 | control: () => '!h-6 !min-h-6 w-12 !border-none !shadow-none !bg-transparent ', 47 | valueContainer: () => '!py-0 !px-1 cursor-pointer bg-transparent', 48 | singleValue: () => `!text-gray-500 !dark:text-gray-300`, 49 | indicatorsContainer: () => '!hidden', 50 | menu: () => `!-mt-0.5`, 51 | option: () => `!py-1 !px-2`, 52 | }), 53 | [], 54 | ); 55 | 56 | const selectTheme = React.useCallback( 57 | (theme: Theme) => ({ 58 | ...theme, 59 | colors: { 60 | ...theme.colors, 61 | primary: themeStyle.sectionTitleColor, 62 | primary25: themeStyle.secondaryBackgroundColor, 63 | neutral0: themeStyle.primaryBackgroundColor, 64 | }, 65 | }), 66 | [themeStyle], 67 | ); 68 | 69 | React.useEffect(() => { 70 | const marker = markerOptions.find((marker) => marker.value === settings.defaultMarker); 71 | if (marker) { 72 | setMarker(marker); 73 | } 74 | 75 | const priority = priorityOptions.find( 76 | (priority) => priority.value === settings.defaultPriority, 77 | ); 78 | if (priority) { 79 | setPriority(priority); 80 | } 81 | }, [settings, markerOptions, priorityOptions, setMarker, setPriority]); 82 | 83 | const handleReset = () => { 84 | setMarker(DEFAULT_OPTION); 85 | setPriority(DEFAULT_OPTION); 86 | }; 87 | 88 | return ( 89 |
95 |
96 |
97 | Marker: 98 | setPriority(option!)} 116 | /> 117 |
118 |
119 | Sort: 120 |