├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── TODOs.md ├── archive └── babel.config.js ├── assets ├── Add A Query.gif ├── Add A Task.gif ├── Modify A Task.gif └── Quick Start.gif ├── docs └── google-calendar-sync-setup.md ├── esbuild.config.mjs ├── jest.config.js ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── api │ ├── customNotice.ts │ ├── externalAPIManager.ts │ └── googleCalendarAPI │ │ ├── authentication.ts │ │ ├── calendarAPI.ts │ │ ├── googleAPIError.ts │ │ ├── localStorage.ts │ │ ├── requestWrapper.ts │ │ └── types.ts ├── autoSuggestions │ ├── EditorSuggestions.ts │ ├── Suggester.ts │ └── index.ts ├── components │ ├── CircularProgressBar.svelte │ ├── LinearProgressBar.svelte │ └── icons │ │ ├── AlertTriangle.svelte │ │ ├── CalendarCheck.svelte │ │ ├── CalendarClock.svelte │ │ ├── ChevronsDownUp.svelte │ │ ├── ChevronsUpDown.svelte │ │ ├── Collapse.svelte │ │ ├── GoogleCalendarLogo.svelte │ │ ├── History.svelte │ │ ├── LucideIcon.svelte │ │ ├── MonoColorSVG.svelte │ │ ├── MoreVertical.svelte │ │ └── Plus.svelte ├── index.ts ├── modal │ └── createProjectModal.ts ├── query │ ├── cache.ts │ ├── index.ts │ ├── indexMapDatabase.ts │ └── querySyncManager.ts ├── renderer │ ├── StaticTaskListRenderer.ts │ ├── TaskCardRenderer.ts │ ├── fileOperator.ts │ ├── filters.ts │ ├── index.ts │ ├── postProcessor.ts │ ├── queryAndTaskListSvelteAdapter.ts │ ├── store.ts │ └── taskItemSvelteAdapter.ts ├── settings.ts ├── settings │ ├── displaySettings.ts │ └── syncSettings │ │ └── googleCalendarSettings.ts ├── taskModule │ ├── description │ │ ├── descriptionParser.ts │ │ └── index.ts │ ├── labels │ │ └── index.ts │ ├── project │ │ └── index.ts │ ├── task.ts │ ├── taskAPI.ts │ ├── taskFormatter.ts │ ├── taskMonitor.ts │ ├── taskParser.ts │ ├── taskSyncManager.ts │ └── taskValidator.ts ├── ui │ ├── Content.svelte │ ├── Description.svelte │ ├── Due.svelte │ ├── Duration.svelte │ ├── LabelInput.svelte │ ├── Labels.svelte │ ├── Project.svelte │ ├── QueryDisplay.svelte │ ├── QueryEditor.svelte │ ├── Schedule.svelte │ ├── StaticTaskCard.svelte │ ├── StaticTaskItem.svelte │ ├── StaticTaskList.svelte │ ├── StaticTaskMatrix.svelte │ ├── SyncLogos.svelte │ ├── TaskCard.svelte │ ├── TaskItem.svelte │ ├── calendar │ │ └── Calendar.svelte │ └── selections │ │ ├── FixedOptionsMultiSelect.svelte │ │ ├── FixedOptionsSelect.svelte │ │ ├── ProjectSelection.svelte │ │ └── TagSelect.svelte └── utils │ ├── colorConverter.ts │ ├── colorPalette.ts │ ├── dateTimeFormatter.ts │ ├── filePathSuggester.ts │ ├── log.ts │ ├── markdownToHTML.ts │ ├── regexUtils.ts │ ├── stringCaseConverter.ts │ └── typeConversion.ts ├── styles.css ├── tests ├── colorConverter.test.ts ├── dateTimeFormatter.test.ts ├── filter.test.ts ├── indexedMapDatabase.test.ts ├── labelModule.test.ts ├── regexUtils.test.ts ├── stringCaseConverter.test.ts ├── suggester.test.ts ├── taskFormatter.test.ts ├── taskParser.test.ts ├── taskValidator.test.ts └── typeConversion.test.ts └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | branches: 8 | - main 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | with: 18 | ref: main 19 | fetch-depth: 0 20 | 21 | - name: Use Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: '18.x' 25 | 26 | - name: Build plugin 27 | run: | 28 | npm install 29 | npm run build 30 | 31 | - name: Zip files 32 | run: | 33 | zip plugin-release.zip main.js manifest.json styles.css 34 | 35 | - name: Create release 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | run: | 39 | tag="${GITHUB_REF#refs/tags/}" 40 | gh release create "$tag" \ 41 | --title="$tag" \ 42 | --draft \ 43 | main.js manifest.json styles.css plugin-release.zip 44 | 45 | - name: Commit to releases branch 46 | run: | 47 | git config --global user.name "GitHub Actions" 48 | git config --global user.email "actions@github.com" 49 | 50 | # Conditionally stash changes to prevent conflicts when switching branches 51 | if ! git diff-index --quiet HEAD --; then 52 | git stash 53 | fi 54 | 55 | git checkout releases 56 | git add -f main.js manifest.json styles.css 57 | if git diff-index --quiet HEAD --; then 58 | echo "No changes to commit" 59 | else 60 | git commit -m "Release ${{ github.ref }}" 61 | git push origin releases 62 | fi 63 | 64 | # Conditionally drop the stash 65 | if [ "$(git stash list)" ]; then 66 | git stash drop 67 | fi 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | /node_modules 3 | 4 | # vscode 5 | .vscode 6 | 7 | # intellJ 8 | .idea 9 | 10 | # Don't include the compiled main.js file in the repo. 11 | # They should be uploaded to GitHub releases instead. 12 | main.js 13 | 14 | # Exclude sourcemaps 15 | *.map 16 | 17 | # obsidian 18 | data.json 19 | userData 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 80, 6 | "endOfLine": "auto" 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian-TaskCard 2 | 3 | 4 | 5 | ## Table of Contents 6 | - [Obsidian-TaskCard](#obsidian-taskcard) 7 | - [Table of Contents](#table-of-contents) 8 | - [Highlights](#highlights) 9 | - [Features](#features) 10 | - [Examples](#examples) 11 | - [Usage Preview](#usage-preview) 12 | - [Add a task](#add-a-task) 13 | - [Edit a task](#edit-a-task) 14 | - [Query](#query) 15 | - [Usage](#usage) 16 | - [Task Creation](#task-creation) 17 | - [Create a task](#create-a-task) 18 | - [Add normal attributes to a task](#add-normal-attributes-to-a-task) 19 | - [Add special attributes to a task](#add-special-attributes-to-a-task) 20 | - [Task Modification](#task-modification) 21 | - [Create a query](#create-a-query) 22 | - [Installation](#installation) 23 | - [Obsidian Plugins](#obsidian-plugins) 24 | - [Manual](#manual) 25 | - [Beta Testing](#beta-testing) 26 | - [License](#license) 27 | - [Frequently Asked Questions](#frequently-asked-questions) 28 | - [1. Why do some user interface elements appear incorrect? What steps can be taken to resolve this?](#1-why-do-some-user-interface-elements-appear-incorrect-what-steps-can-be-taken-to-resolve-this) 29 | 30 | ## Highlights 31 | 32 | Obsidian-TaskCard is an Obsidian plugin designed to revolutionize your task management experience within Obsidian. It offers a visually appealing and efficient way to organize and manage your tasks. With two distinct display modes and a plethora of features like tags, projects, and descriptions, Obsidian-TaskCard turns your Obsidian vault into a powerful task management tool. 33 | 34 | ## Features 35 | 36 | - **Intuitive and easy-to-use**: the plugin doesn't deviate you from *normal markdown task workflow*. You can create, modify, delete your tasks very similarly when you are using pure markdown in Obsidian. Just by adding a tag (indicator tag in the settings) you can turn your tasks into a task card, which supports two display modes and that allows you to see and edit all attributes of a task, such as the project, schedule date, and description. 37 | 38 | - **Two Display Modes**: Choose between two display modes for your tasks. 39 | - **Preview Mode**: Designed for quick browsing, this mode displays tasks at the same height as a normal markdown task, showing only the most essential information. 40 | - **Detailed Mode**: This mode provides a comprehensive task card that allows you to see and edit all attributes of a task, such as the project, schedule date, and description. 41 | 42 | - **Schedule Date**: Add a schedule date to your tasks to indicate when the task is schedule. 43 | 44 | - **Tags and Projects**: Easily categorize your tasks with tags and associate them with specific projects. 45 | 46 | - **Task Descriptions**: Add detailed descriptions to your tasks to capture additional information and context. You can also use the description to create sub tasks, the same way you do in normal markdown. The task card will track the progress of the sub tasks. 47 | 48 | 49 | ## Examples 50 | 51 | ### Usage Preview 52 | 53 | 54 | 55 |  56 | 57 | ### Add a task 58 | 59 |  60 | 61 | ### Edit a task 62 | 63 |  64 | 65 | ### Query 66 | 67 |  68 | 69 | 70 | ## Usage 71 | 72 | ### Task Creation 73 | 74 | Attributes | Addition | Example | 75 | --- | --- | --- 76 | Content | Task in markdown | `- [ ] some content` | 77 | Tag | Tag in markdown | `- [ ] some content #tag` | 78 | Description | Description in markdown (change line + indent) | `- [ ] some content \n - some description` | 79 | Schedule Date | Special attribute: `schedule` | `%%* schedule: 2021-01-01 *%%` | 80 | Project | Special attribute: `project` | `%%* project: project name *%%` | 81 | 82 | #### Create a task 83 | - Create a task in the normal way by typing `- [ ] some content`; 84 | - To make it recognizable as a task card, add a tag (indicator tag in the settings, default to "`#TaskCard`") to the task. 85 | 86 | #### Add normal attributes to a task 87 | Some attributes are native for a markdown task, we can add them to the task in the same way as normal markdown. 88 | - Tags: add tags in the content. e.g. `- [ ] some content #tag`; 89 | - Description: Add description to the task in the same way as normal markdown. e.g. 90 | ```markdown 91 | - [ ] some content 92 | - some description 93 | - [ ] sub task 94 | ``` 95 | 96 | #### Add special attributes to a task 97 | Some added ingredients for a task card, we can add them in a special way: `%%* key: value *%%`. this is will show nicely in the editing mode of obsidian, while invisible in the preview mode. 98 | - Schedule Date: Add a schedule date to the task. e.g. `%%* schedule: 2021-01-01 *%%` 99 | - Project: Add a project to the task. e.g. `%%* project: project name *%%` 100 | 101 | ### Task Modification 102 | - Tasks are shown in two view: preview and detailed views. Most attributes are editable in the detailed view. 103 | - Add `description`, `schedule`, and `project`: click the ⋮ button in the bottom right corner. 104 | - Add `tags`: click the + button. 105 | - Add `priority`: right click the checkbox. 106 | - Modify `description`, `schedule`: click on them. 107 | - Modify `tags`: right click on the tag and select `edit`. 108 | - Modify `project`: click on the project color dot. 109 | - Modify `priority`: right click on the checkbox. 110 | 111 | ### Create a query 112 | - Create a query by inserting a code block of `taskcard`. 113 | ```markdown 114 | >>> ```taskcard 115 | >>> 116 | >>> ``` 117 | ``` 118 | - You don't have to create anything, the plugin will parse it and display the query for you. 119 | - Use command (⌘ + p) - "Task Card: Add Query". It will automatically add the query code block at your cursor position. 120 | 121 | 122 | ## Installation 123 | 124 | ### Obsidian Plugins 125 | 126 | The plugin will be available on Obsidian's plugin market when it reaches version 1.0.0. 127 | 128 | ### Manual 129 | 130 | 1. Go to the [releases page](https://github.com/terryli710/Obsidian-TaskCard/releases). 131 | 2. Select the latest stable release. 132 | 3. Download the `plugin-release.zip` file. 133 | 4. Unzip the downloaded file. 134 | 5. Place the unzipped folder under your Obsidian plugins folder. 135 | 136 | ### Beta Testing 137 | 138 | To test some of the features in the pre-release versions, you can use [this plugin](https://tfthacker.com/BRAT). After installation, follow these steps: 139 | 140 | 1. In the plugin setting, click on `Add Beta plugin with frozen version`. 141 | 2. In the popup modal, input the following: 142 | 143 | ``` 144 | url: https://github.com/terryli710/Obsidian-TaskCard 145 | version: x.x.x 146 | ``` 147 | 148 | 151 | 152 | ## License 153 | 154 | This project is licensed under the Apache License - see the [LICENSE.md](LICENSE.md) file for details. 155 | 156 | 157 | ## Frequently Asked Questions 158 | 159 | ### 1. Why do some user interface elements appear incorrect? What steps can be taken to resolve this? 160 | - **Theme Compatibility**: Our plugin has not been exhaustively tested across all available themes. Therefore, compatibility issues related to the active theme could lead to the user interface not displaying as intended. To address this: 161 | - Ensure your theme is up-to-date; 162 | - Use the plugin settings to switch to a different theme for troubleshooting purposes. 163 | 164 | Should the issue persist, we welcome you to report it by opening an [issue](https://github.com/terryli710/Obsidian-TaskCard/issues) on our GitHub repository. 165 | -------------------------------------------------------------------------------- /TODOs.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Finish Eisenhower Matrix 4 | - Fix the subtask display in static task card. 5 | 6 | 7 | 8 | 9 | 10 | # Known issues 11 | - Google calendar requires re-login after some time. 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /archive/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current' 8 | } 9 | } 10 | ] 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /assets/Add A Query.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terryli710/Obsidian-TaskCard/c3a3c04c84094af641ec7490100079406b112bf6/assets/Add A Query.gif -------------------------------------------------------------------------------- /assets/Add A Task.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terryli710/Obsidian-TaskCard/c3a3c04c84094af641ec7490100079406b112bf6/assets/Add A Task.gif -------------------------------------------------------------------------------- /assets/Modify A Task.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terryli710/Obsidian-TaskCard/c3a3c04c84094af641ec7490100079406b112bf6/assets/Modify A Task.gif -------------------------------------------------------------------------------- /assets/Quick Start.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terryli710/Obsidian-TaskCard/c3a3c04c84094af641ec7490100079406b112bf6/assets/Quick Start.gif -------------------------------------------------------------------------------- /docs/google-calendar-sync-setup.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Create a New Client of Google Calendar to Use Google Calendar Sync of Task Card Obsidian 4 | 5 | ## Step 1: Create a new project in Google Cloud Platform 6 | TODO: add msg to encourage users 7 | - Go to [Google Cloud Platform Console](https://console.cloud.google.com/welcome/); 8 | - Create a project: 9 | - Navigate to [Create Project](https://console.cloud.google.com/projectcreate); 10 | - Or just click on the `Create Project` button; 11 | - Give the project a name; 12 | - Click on the `Create` button; 13 | 14 | ## Step 2: Enable Google Calendar API 15 | 16 | - In your new project, search for [Google Calendar API](https://console.cloud.google.com/marketplace/product/google/calendar-json.googleapis.com) and enable it. 17 | 18 | ## Step 3: Set `OAuth Consent Screen` 19 | - Go to the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent) tab of your project. 20 | - **OAuth Consent Screen**: 21 | - Select **User Type** = `External`; 22 | - Click on **Create**; 23 | - Put in your App Information: 24 | - **Application Name** = `Obsidian Task Card`; 25 | - **User Support Email** = Your Email; 26 | - **Developer contact information** = Your Email; 27 | - Click on **Save and Continue**; 28 | - **Scopes**: 29 | - Click on **Add or Remove Scopes**; 30 | - Choose these Scopes: 31 | - **Scope** = `.../auth/userinfo.email`; 32 | - **Scope** = `.../auth/userinfo.profile`; 33 | - **Scope** = `openid`; 34 | - **API** = `Google Calendar API` (you can search for API name in the filter); TODO: more accurate description 35 | - Click on **Update**; 36 | - You should see 3 fields in **Your non-sensitive scopes**: 37 | - `.../auth/userinfo.email` 38 | - `.../auth/userinfo.profile` 39 | - `openid` 40 | - And 1 field in **Your sensitive scopes**: 41 | - `Google Calendar API` 42 | - Click on **Save and Continue**; 43 | - Test Users: 44 | - Click on **ADD USERS**; 45 | - Input your email; 46 | - Click on **ADD**; 47 | - You should see your email showing in the **User information**; 48 | - Click on **Save and Continue**; 49 | - Summary: 50 | - Click on **BACK TO DASHBOARD**; 51 | 52 | ## Step 4: Setup Credentials 53 | 54 | - Go to the [Credentials](https://console.cloud.google.com/apis/credentials) tab of your project. 55 | - Click on **CREATE CREDENTIALS**; 56 | - Select **OAuth Client ID**; 57 | - **Application type** = `Web Application`; 58 | - **Name** = `Obsidian Task Card`; 59 | - **Authorized JavaScript origins** = `http://127.0.0.1:8888`; 60 | - **Authorized redirect URIs** = `http://127.0.0.1:8888/callback`; 61 | - Click on **CREATE**; 62 | 63 | ## Copy and Paste Your Client ID and Secret 64 | 65 | - Go to settings of the plugin and copy-paste the **Client ID** and **Client Secret**. 66 | - Click on **Login**, this will take you to the login page of the plugin. 67 | - Select the correct google account and login. 68 | - "Google hasn’t verified this app": Click on **Continue** because you created the application. 69 | - You should see login successful message. -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import esbuildSvelte from 'esbuild-svelte'; 3 | import sveltePreprocess from 'svelte-preprocess'; 4 | import process from 'process'; 5 | 6 | const prod = process.env.NODE_ENV === 'production'; 7 | 8 | const options = { 9 | entryPoints: ['src/index.ts'], 10 | bundle: true, 11 | outfile: 'main.js', 12 | platform: 'node', 13 | plugins: [ 14 | esbuildSvelte({ 15 | compilerOptions: { css: true }, 16 | preprocess: sveltePreprocess() 17 | }) 18 | ], 19 | define: { 20 | 'process.env.NODE_ENV': `"${process.env.NODE_ENV}"` 21 | }, 22 | external: ['obsidian'], 23 | minify: prod, 24 | sourcemap: prod ? false : 'inline', 25 | logLevel: 'info' 26 | }; 27 | 28 | (async () => { 29 | const context = await esbuild.context(options); 30 | 31 | if (prod) { 32 | await context.rebuild(); 33 | process.exit(0); 34 | } else { 35 | await context.watch(); 36 | } 37 | })().catch(() => process.exit(1)); 38 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 2 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 3 | module.exports = { 4 | preset: 'ts-jest/presets/js-with-ts', 5 | testEnvironment: 'jest-environment-node', 6 | verbose: true, 7 | transform: { 8 | '^.+\\.svelte$': ['svelte-jester', { preprocess: true }], 9 | '^.+\\.ts$': 'ts-jest', 10 | '^.+\\.js$': 'esbuild-jest' 11 | }, 12 | moduleFileExtensions: ['js', 'svelte', 'ts'], 13 | setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], 14 | testPathIgnorePatterns: ['node_modules'], 15 | transformIgnorePatterns: [ 16 | 'node_modules/(?!(svelte)/)' // This will make sure svelte is transformed but other node_modules are not. 17 | ], 18 | clearMocks: true, 19 | extensionsToTreatAsEsm: ['.ts'] 20 | }; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Terry Li", 3 | "description": "Task formatter with card view.", 4 | "id": "obsidian-taskcard", 5 | "isDesktopOnly": false, 6 | "minAppVersion": "0.3.0", 7 | "name": "Task Card", 8 | "version": "0.3.0", 9 | "authorUrl": "https://github.com/terryli710", 10 | "fundingUrl": "https://github.com/sponsors/terryli710" 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-taskcard", 3 | "version": "0.1.0", 4 | "description": "Task formatter with card view.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "dev": "node esbuild.config.mjs", 9 | "build": "NODE_ENV=production node esbuild.config.mjs", 10 | "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/terryli710/Obsidian-TaskCard.git" 15 | }, 16 | "keywords": [ 17 | "obsidian", 18 | "svelte", 19 | "typescript" 20 | ], 21 | "author": "Terry Li", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/terryli710/Obsidian-TaskCard/issues" 25 | }, 26 | "homepage": "https://github.com/terryli710/Obsidian-TaskCard#readme", 27 | "devDependencies": { 28 | "@babel/core": "^7.22.9", 29 | "@babel/preset-env": "^7.22.9", 30 | "@babel/preset-typescript": "^7.22.5", 31 | "@googleapis/docs": "^2.0.5", 32 | "@jest/globals": "^29.6.2", 33 | "@testing-library/jest-dom": "^5.17.0", 34 | "@testing-library/svelte": "^4.0.3", 35 | "@testing-library/user-event": "^14.4.3", 36 | "@tsconfig/svelte": "^5.0.0", 37 | "@types/jest": "^29.5.3", 38 | "@types/winston": "^2.4.4", 39 | "builtin-modules": "^3.3.0", 40 | "date-picker-svelte": "^2.6.0", 41 | "esbuild": "^0.18.17", 42 | "esbuild-jest": "^0.5.0", 43 | "esbuild-svelte": "^0.7.4", 44 | "jest": "^29.6.2", 45 | "jsdom": "^22.1.0", 46 | "prettier": "^3.0.0", 47 | "svelte-jester": "^3.0.0", 48 | "svelte-preprocess": "^5.0.4", 49 | "ts-jest": "^29.1.1" 50 | }, 51 | "dependencies": { 52 | "@ts-stack/markdown": "^1.5.0", 53 | "googleapis": "^126.0.1", 54 | "humanize-duration": "^3.30.0", 55 | "humanized-duration": "^0.0.1", 56 | "lucide-svelte": "^0.268.0", 57 | "markdown-it": "^14.0.0", 58 | "markdown-it-task-lists": "^2.1.1", 59 | "obsidian": "latest", 60 | "obsidian-dataview": "^0.5.56", 61 | "parse-duration": "^1.1.0", 62 | "runtypes": "^6.7.0", 63 | "showdown": "^2.1.0", 64 | "sugar": "^2.0.6", 65 | "svelte": "^4.1.1", 66 | "svelte-markdown": "^0.4.1", 67 | "typescript": "^5.1.6", 68 | "uuid": "^9.0.0", 69 | "winston": "^3.10.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/api/customNotice.ts: -------------------------------------------------------------------------------- 1 | // Create custom notice for each API 2 | 3 | import { Notice } from "obsidian"; 4 | import { logger } from "../utils/log"; 5 | 6 | 7 | class CustomAPINotice { 8 | pluginName: string; 9 | defaultAPIName: string; 10 | defaultIntervalMinutes: number; 11 | noticeMap: Map; 12 | 13 | constructor(pluginName = "TaskCard", defaultAPIName = "googleCalendarAPI", defaultIntervalMinutes = 1) { 14 | this.pluginName = pluginName; 15 | this.defaultAPIName = defaultAPIName; 16 | this.defaultIntervalMinutes = defaultIntervalMinutes; 17 | this.noticeMap = new Map(); 18 | } 19 | 20 | createNotice(text, apiName: string, customIntervalMinutes = null, ignoreTimeout = false) { 21 | const now = window.moment(); 22 | const noticeAPIName = apiName || this.defaultAPIName; 23 | const key = `${this.pluginName}-${noticeAPIName}-${text}`; // Unique key for each notice 24 | 25 | if (this.noticeMap.has(key)) { 26 | const lastDisplay = this.noticeMap.get(key); 27 | // Check if we're still within the cooldown period and if we're not ignoring the timeout 28 | if (!lastDisplay.isBefore(now) && !ignoreTimeout) { 29 | return; // within cooldown period, don't show notice 30 | } 31 | } 32 | 33 | // Show the notice with the customized prefix 34 | new Notice(`[${this.pluginName}: ${noticeAPIName}] ${text}`); 35 | logger.info(`[${this.pluginName}: ${noticeAPIName}] ${text}`); // Also log the notice 36 | 37 | // Update the last display time. Use the custom interval if provided, else use the default 38 | this.noticeMap.set(key, now.add(customIntervalMinutes || this.defaultIntervalMinutes, "minute")); 39 | } 40 | } -------------------------------------------------------------------------------- /src/api/externalAPIManager.ts: -------------------------------------------------------------------------------- 1 | // Manages all external APIs for the plugin 2 | 3 | import { SettingStore, TaskCardSettings } from "../settings"; 4 | import { ObsidianTask } from "../taskModule/task"; 5 | import { TaskChangeEvent, TaskChangeType } from "../taskModule/taskAPI"; 6 | import { GoogleCalendarAPI } from "./googleCalendarAPI/calendarAPI"; 7 | 8 | 9 | export interface SyncMappings { 10 | googleSyncSetting?: { 11 | id: string; 12 | } 13 | } 14 | 15 | export class ExternalAPIManager { 16 | private settings: TaskCardSettings; 17 | public googleCalendarAPI: GoogleCalendarAPI = undefined; 18 | 19 | constructor(settingStore: typeof SettingStore) { 20 | settingStore.subscribe((settings) => { 21 | this.settings = settings; 22 | }); 23 | } 24 | 25 | initAPIs() { 26 | if (this.settings.syncSettings.googleSyncSetting.isLogin) { 27 | this.googleCalendarAPI = new GoogleCalendarAPI(); 28 | } 29 | } 30 | 31 | async createTask(task: ObsidianTask): Promise { 32 | // build task change event 33 | const event: TaskChangeEvent = { 34 | taskId: task.id, 35 | type: TaskChangeType.ADD, 36 | currentState: task, 37 | timestamp: new Date(), 38 | } 39 | const syncMappings = await this.notifyTaskCreations(event); 40 | // logger.debug(`syncMappings: ${JSON.stringify(syncMappings)}`); 41 | return syncMappings; 42 | } 43 | 44 | updateTask(task: ObsidianTask, origTask?: ObsidianTask) { 45 | // build task change event 46 | const event: TaskChangeEvent = { 47 | taskId: task.id, 48 | type: TaskChangeType.UPDATE, 49 | currentState: task, 50 | previousState: origTask, 51 | timestamp: new Date(), 52 | } 53 | const syncMappings = this.notifyTaskUpdates(event); 54 | return syncMappings; 55 | } 56 | 57 | deleteTask(task: ObsidianTask) { 58 | // build task change event 59 | const event: TaskChangeEvent = { 60 | taskId: task.id, 61 | type: TaskChangeType.REMOVE, 62 | previousState: task, 63 | timestamp: new Date(), 64 | } 65 | this.notifyTaskDeletions(event); 66 | } 67 | 68 | async notifyTaskCreations(event: TaskChangeEvent): Promise { 69 | if (event.type !== TaskChangeType.ADD) return; 70 | const oldSyncMappings = event.currentState.metadata.syncMappings || {}; 71 | let syncMappings = oldSyncMappings; 72 | if (this.googleCalendarAPI) { 73 | const id = await this.googleCalendarAPI.handleLocalTaskCreation(event); 74 | syncMappings = { ...syncMappings, googleSyncSetting: { id: id } }; 75 | } 76 | return syncMappings; 77 | } 78 | 79 | async notifyTaskUpdates(event: TaskChangeEvent): Promise { 80 | if (event.type !== TaskChangeType.UPDATE) return; 81 | const oldSyncMappings = event.currentState.metadata.syncMappings || {}; 82 | let syncMappings = oldSyncMappings; 83 | if (this.googleCalendarAPI) { 84 | const id = await this.googleCalendarAPI.handleLocalTaskUpdate(event); 85 | syncMappings = { ...syncMappings, googleSyncSetting: { id: id } }; 86 | } 87 | return syncMappings; 88 | } 89 | 90 | notifyTaskDeletions(event: TaskChangeEvent) { 91 | if (event.type !== TaskChangeType.REMOVE) return; 92 | if (this.googleCalendarAPI) this.googleCalendarAPI.handleLocalTaskDeletion(event); 93 | } 94 | 95 | 96 | } -------------------------------------------------------------------------------- /src/api/googleCalendarAPI/googleAPIError.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "../../utils/log"; 2 | 3 | export const throwGoogleApiError = (errorDetail: string, method: string, url: string, body: any, status: number, response: any) => { 4 | const errorMessage = `Error Google API request: ${errorDetail}`; 5 | logger.error(errorMessage, { method, url, body, status, response }); 6 | throw new GoogleApiError(errorMessage, { method, url, body }, status, response); 7 | }; 8 | 9 | export class GoogleApiError extends Error { 10 | request: any; 11 | status: number; 12 | response: any; 13 | 14 | constructor(message: string, request: any, status: number, response: any) { 15 | super(message); 16 | 17 | // Set the prototype explicitly. 18 | Object.setPrototypeOf(this, GoogleApiError.prototype); 19 | 20 | this.request = request; 21 | this.status = status; 22 | this.response = response; 23 | } 24 | 25 | sayHello() { 26 | return "hello " + this.message; 27 | } 28 | 29 | get detailedMessage() { 30 | return `${this.message} - Request: ${JSON.stringify(this.request)} - Status: ${this.status} - Response: ${JSON.stringify(this.response)}`; 31 | } 32 | } -------------------------------------------------------------------------------- /src/api/googleCalendarAPI/localStorage.ts: -------------------------------------------------------------------------------- 1 | 2 | // Adopted from https://github.com/YukiGasai/obsidian-google-calendar/blob/master/src/helper/LocalStorage.ts 3 | 4 | import { logger } from "../../utils/log"; 5 | 6 | 7 | export class LocalStorageDB { 8 | public name: string; 9 | private data: Record; // In-memory database 10 | private saveInterval: number; // Interval for periodic saving 11 | private readonly SAVE_INTERVAL_MS: number = 10000; // e.g., 10 seconds 12 | 13 | constructor(name: string) { 14 | this.name = name; 15 | this.data = {}; // Initialize the in-memory database 16 | this.loadAllData(); 17 | this.setupPeriodicSave(); 18 | this.setupSaveOnClose(); 19 | } 20 | 21 | // Method to encode data as JSON before saving 22 | private encode(data: any): string { 23 | return JSON.stringify(data); 24 | } 25 | 26 | // Method to decode data back into an object after loading 27 | private decode(data: string): any { 28 | return JSON.parse(data); 29 | } 30 | 31 | // Load all data from localStorage to memory 32 | private loadAllData(): void { 33 | const rawData = localStorage.getItem(this.name); 34 | if (rawData) { 35 | this.data = this.decode(rawData); 36 | } 37 | } 38 | 39 | // Setup interval to save all data periodically 40 | private setupPeriodicSave(): void { 41 | this.saveInterval = window.setInterval(() => { 42 | this.saveAllData(); 43 | }, this.SAVE_INTERVAL_MS); // specify the interval for periodic saving 44 | } 45 | 46 | // Save all data from memory to localStorage 47 | private saveAllData(): void { 48 | localStorage.setItem(this.name, this.encode(this.data)); // encode object to JSON 49 | } 50 | 51 | // Setup saving all data when the window is closed 52 | private setupSaveOnClose(): void { 53 | window.addEventListener("beforeunload", (event) => { 54 | this.saveAllData(); 55 | }); 56 | } 57 | 58 | // Public method to get data by key 59 | public getData(key: string): any { 60 | return this.data[key]; 61 | } 62 | 63 | // Public method to get all data 64 | public getAllData(): Record { 65 | return this.data; 66 | } 67 | 68 | // Public method to set data by key 69 | public setData(key: string, value: any): void { 70 | this.data[key] = value; 71 | } 72 | 73 | public deleteData(key: string): void { 74 | delete this.data[key]; 75 | } 76 | 77 | public clearData(): void { 78 | this.data = {}; 79 | } 80 | 81 | // Method to perform a "hard" save, i.e., immediate save 82 | public hardSave(): void { 83 | this.saveAllData(); 84 | } 85 | } 86 | 87 | 88 | //=================== 89 | //GETTER 90 | //=================== 91 | 92 | /** 93 | * getAccessToken from LocalStorage 94 | * @returns googleAccessToken 95 | */ 96 | export const getAccessToken = (): string => { 97 | return window.localStorage.getItem("googleCalendarAccessToken") ?? ""; 98 | }; 99 | 100 | /** 101 | * getRefreshToken from LocalStorage 102 | * @returns googleRefreshToken 103 | */ 104 | export const getRefreshToken = (): string => { 105 | return window.localStorage.getItem("googleCalendarRefreshToken") ?? ""; 106 | }; 107 | 108 | /** 109 | * getExpirationTime from LocalStorage 110 | * @returns googleExpirationTime 111 | */ 112 | export const getExpirationTime = (): number => { 113 | const expirationTimeString = 114 | window.localStorage.getItem("googleCalendarExpirationTime") ?? "0"; 115 | return parseInt(expirationTimeString, 10); 116 | }; 117 | 118 | 119 | //=================== 120 | //SETTER 121 | //=================== 122 | 123 | /** 124 | * set AccessToken into LocalStorage 125 | * @param googleAccessToken googleAccessToken 126 | * @returns googleAccessToken 127 | */ 128 | export const setAccessToken = (googleAccessToken: string): void => { 129 | window.localStorage.setItem("googleCalendarAccessToken", googleAccessToken); 130 | }; 131 | 132 | /** 133 | * set RefreshToken from LocalStorage 134 | * @param googleRefreshToken googleRefreshToken 135 | * @returns googleRefreshToken 136 | */ 137 | export const setRefreshToken = (googleRefreshToken: string): void => { 138 | if (googleRefreshToken == "undefined") return; 139 | window.localStorage.setItem("googleCalendarRefreshToken", googleRefreshToken); 140 | }; 141 | 142 | /** 143 | * set ExpirationTime from LocalStorage 144 | * @param googleExpirationTime googleExpirationTime 145 | * @returns googleExpirationTime 146 | */ 147 | export const setExpirationTime = (googleExpirationTime: number): void => { 148 | if (isNaN(googleExpirationTime)) return; 149 | window.localStorage.setItem( 150 | "googleCalendarExpirationTime", 151 | googleExpirationTime + "" 152 | ); 153 | }; -------------------------------------------------------------------------------- /src/api/googleCalendarAPI/requestWrapper.ts: -------------------------------------------------------------------------------- 1 | // Adopted from https://github.com/YukiGasai/obsidian-google-calendar/blob/master/src/helper/RequestWrapper.ts 2 | 3 | import { throwGoogleApiError } from './googleAPIError'; 4 | import { requestUrl } from "obsidian"; 5 | import { GoogleCalendarAuthenticator } from './authentication'; 6 | import { logger } from '../../utils/log'; 7 | 8 | export const callRequest = async (url: string, method: string, body: any, noAuth = false, retryCount = 0): Promise => { 9 | const MAX_RETRIES = 3; // HARDCODED VALUE 10 | const requestHeaders: Record = { 'Content-Type': 'application/json' }; 11 | 12 | if (!noAuth) { 13 | const bearer = await new GoogleCalendarAuthenticator().getGoogleAuthToken(); 14 | if (!bearer) { 15 | throwGoogleApiError("Missing Auth Token", method, url, body, 401, {error: "Missing Auth Token"}); 16 | } 17 | requestHeaders['Authorization'] = `Bearer ${bearer}`; 18 | } 19 | 20 | // Send request 21 | let response; 22 | try { 23 | logger.info(`Sending request - url: ${url}, method: ${method}, body: ${JSON.stringify(body)}, headers: ${JSON.stringify(requestHeaders)}`); 24 | response = await requestUrl({ 25 | method, 26 | url, 27 | body: body ? JSON.stringify(body) : null, 28 | headers: requestHeaders, 29 | throw: false, 30 | }); 31 | } catch (error) { 32 | logger.info(`Request failed - ${error}`); 33 | throwGoogleApiError("Request failed", method, url, body, response?.status ?? 500, { error: error.message }); 34 | } 35 | 36 | // If the response indicates unauthorized and retry count is less than the max, refresh token and retry 37 | if (response.status === 401 && retryCount < MAX_RETRIES) { 38 | logger.info("Unauthorized response. Attempting to refresh token and retry."); 39 | await new GoogleCalendarAuthenticator().refreshAccessToken(); // Assuming you have this method. 40 | return callRequest(url, method, body, noAuth, retryCount + 1); 41 | } 42 | 43 | if (response.status >= 300) { 44 | let responseBody; 45 | try { 46 | responseBody = await response.json(); 47 | } catch (error) { 48 | logger.error(`Failed to parse response JSON - ${error}`); 49 | responseBody = { error: "Invalid JSON response" }; 50 | } 51 | throwGoogleApiError("Error in Google API request", method, url, body, response.status, responseBody); 52 | } 53 | 54 | if (method.toLowerCase() === "delete") { 55 | return { status: "success" }; 56 | } 57 | 58 | const responseJson = await processResponse(response); 59 | 60 | return responseJson; 61 | }; 62 | 63 | async function processResponse(response: any) { 64 | // logger.debug(`Received response - status: ${response.status}`); 65 | // logger.debug(`Type of response: ${typeof response}, response properties: ${Object.keys(response)}`); 66 | 67 | let jsonData; 68 | 69 | // If response.json is an object, it's likely the response body already parsed. 70 | // So, we directly assign it to jsonData. 71 | if (response.json && typeof response.json === 'object') { 72 | // logger.debug(`response.json is an object, assigning as jsonData...`); 73 | jsonData = response.json; 74 | } 75 | 76 | // If jsonData wasn't set above, and if response.json is a function, 77 | // we try to parse the response's body as JSON. 78 | if (!jsonData && response.json && typeof response.json === 'function') { 79 | // logger.debug(`response.json is a function, trying to parse...`); 80 | try { 81 | jsonData = await response.json(); 82 | } catch (error) { 83 | logger.error(`Error parsing response as JSON: ${error}`); 84 | // If an error occurs here, it means the JSON parsing method has failed. 85 | // You should handle the error appropriately, maybe re-throwing it or handling it based on your application's logic. 86 | } 87 | } 88 | 89 | // If jsonData is still not set (neither an object nor parsed), 90 | // it indicates an unusual state, possibly an error in the response process. 91 | if (!jsonData) { 92 | logger.error('Unexpected state: jsonData is not set. Response might not contain JSON or parsing might have failed.'); 93 | // Handle or throw error as per your error handling logic. 94 | } else { 95 | // Log the final jsonData after attempted parsing or direct assignment 96 | // logger.debug(`jsonData after processing: ${JSON.stringify(jsonData)}`); 97 | } 98 | 99 | return jsonData; // This will return undefined in case of an error, consider throwing an error if that's not desired. 100 | } 101 | -------------------------------------------------------------------------------- /src/api/googleCalendarAPI/types.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | export interface GoogleCalendar { 5 | kind: "calendar#calendarListEntry"; 6 | etag: string; 7 | id: string; 8 | summary: string; 9 | description: string; 10 | location: string; 11 | timeZone: string; 12 | summaryOverride: string; 13 | colorId: string; 14 | backgroundColor: string; 15 | foregroundColor: string; 16 | hidden: boolean; 17 | selected: boolean; 18 | accessRole: string; 19 | defaultReminders: [ 20 | { 21 | method: string; 22 | minutes: number; 23 | } 24 | ]; 25 | notificationSettings: { 26 | notifications: [ 27 | { 28 | type: string; 29 | method: string; 30 | } 31 | ]; 32 | }; 33 | primary: boolean; 34 | deleted: boolean; 35 | conferenceProperties: { 36 | allowedConferenceSolutionTypes: [string]; 37 | }; 38 | } 39 | 40 | 41 | export interface GoogleCalendarList { 42 | kind: "calendar#calendarList"; 43 | etag: string; 44 | nextPageToken: string; 45 | nextSyncToken: string; 46 | items: [GoogleCalendar]; 47 | } 48 | 49 | 50 | export interface GoogleEvent { 51 | parent?: GoogleCalendar; 52 | kind?: "calendar#event"; 53 | etag?: string; 54 | id?: string; 55 | status?: string; 56 | htmlLink?: string; 57 | created?: string; 58 | updated?: string; 59 | summary?: string; 60 | description?: string; 61 | location?: string; 62 | colorId?: string; 63 | creator?: { 64 | id?: string; 65 | email?: string; 66 | displayName?: string; 67 | self?: boolean; 68 | }; 69 | organizer?: { 70 | id?: string; 71 | email?: string; 72 | displayName?: string; 73 | self?: boolean; 74 | }; 75 | start: { 76 | date?: string; 77 | dateTime?: string; 78 | timeZone?: string; 79 | }; 80 | end: { 81 | date?: string; 82 | dateTime?: string; 83 | timeZone?: string; 84 | }; 85 | endTimeUnspecified?: boolean; 86 | recurrence?: string[]; 87 | recurringEventId?: string; 88 | originalStartTime?: { 89 | date?: string; 90 | dateTime?: string; 91 | timeZone?: string; 92 | }; 93 | transparency?: string; 94 | visibility?: string; 95 | iCalUID?: string; 96 | sequence?: number; 97 | attendees?: [ 98 | { 99 | id?: string; 100 | email?: string; 101 | displayName?: string; 102 | organizer?: boolean; 103 | self?: boolean; 104 | resource?: boolean; 105 | optional?: boolean; 106 | responseStatus?: string; 107 | comment?: string; 108 | additionalGuests?: number; 109 | } 110 | ]; 111 | attendeesOmitted?: boolean; 112 | extendedProperties?: { 113 | private?: { 114 | string?: string; 115 | }; 116 | shared?: { 117 | string?: string; 118 | }; 119 | }; 120 | hangoutLink?: string; 121 | conferenceData?: { 122 | createRequest?: { 123 | requestId?: string; 124 | conferenceSolutionKey?: { 125 | type?: string; 126 | }; 127 | status?: { 128 | statusCode?: string; 129 | }; 130 | }; 131 | entryPoints?: [ 132 | { 133 | entryPointType?: string; 134 | uri?: string; 135 | label?: string; 136 | pin?: string; 137 | accessCode?: string; 138 | meetingCode?: string; 139 | passcode?: string; 140 | password?: string; 141 | } 142 | ]; 143 | conferenceSolution?: { 144 | key?: { 145 | type?: string; 146 | }; 147 | name?: string; 148 | iconUri?: string; 149 | }; 150 | conferenceId?: string; 151 | signature?: string; 152 | notes?: string; 153 | }; 154 | gadget?: { 155 | type?: string; 156 | title?: string; 157 | link?: string; 158 | iconLink?: string; 159 | width?: number; 160 | height?: number; 161 | display?: string; 162 | preferences?: { 163 | string?: string; 164 | }; 165 | }; 166 | anyoneCanAddSelf?: boolean; 167 | guestsCanInviteOthers?: boolean; 168 | guestsCanModify?: boolean; 169 | guestsCanSeeOtherGuests?: boolean; 170 | privateCopy?: boolean; 171 | locked?: boolean; 172 | reminders?: { 173 | useDefault?: boolean; 174 | overrides?: [ 175 | { 176 | method?: string; 177 | minutes?: number; 178 | } 179 | ]; 180 | }; 181 | source?: { 182 | url?: string; 183 | title?: string; 184 | }; 185 | attachments?: [ 186 | { 187 | fileUrl?: string; 188 | title?: string; 189 | mimeType?: string; 190 | iconLink?: string; 191 | fileId?: string; 192 | } 193 | ]; 194 | eventType?: string; 195 | } 196 | 197 | export interface GoogleEventList { 198 | kind: "calendar#events"; 199 | etag: string; 200 | summary: string; 201 | description: string; 202 | updated: string; 203 | timeZone: string; 204 | accessRole: string; 205 | defaultReminders: [ 206 | { 207 | method: string; 208 | minutes: number; 209 | } 210 | ]; 211 | nextPageToken: string; 212 | nextSyncToken: string; 213 | items: GoogleEvent[]; 214 | } 215 | 216 | 217 | export interface GoogleEventTimePoint { 218 | date?: string; 219 | dateTime?: string; 220 | timeZone?: string; 221 | } -------------------------------------------------------------------------------- /src/autoSuggestions/EditorSuggestions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Editor, 4 | EditorPosition, 5 | EditorSuggest, 6 | EditorSuggestContext, 7 | EditorSuggestTriggerInfo, 8 | MarkdownView, 9 | TFile 10 | } from 'obsidian'; 11 | import { logger } from '../utils/log'; 12 | import { SettingStore } from '../settings'; 13 | import { SuggestInformation } from './index'; 14 | import { AttributeSuggester } from './Suggester'; 15 | import { TaskValidator } from '../taskModule/taskValidator'; 16 | 17 | export type SuggestInfoWithContext = SuggestInformation & { 18 | context: EditorSuggestContext; 19 | }; 20 | 21 | export default class AttributeSuggest extends EditorSuggest { 22 | private app: App; 23 | private attributeSuggester: AttributeSuggester; 24 | private taskValidator: TaskValidator; 25 | 26 | constructor(app: App) { 27 | super(app); 28 | this.app = app; 29 | 30 | this.attributeSuggester = new AttributeSuggester(SettingStore); 31 | this.taskValidator = new TaskValidator(SettingStore); 32 | 33 | // Register an event for the Tab key 34 | // @ts-ignore 35 | this.scope.register([], 'Tab', (evt: KeyboardEvent) => { 36 | // @ts-ignore 37 | this.suggestions.useSelectedItem(evt); 38 | return false; // Prevent the default behavior of the Tab key (like indentation) 39 | }); 40 | } 41 | 42 | onTrigger( 43 | cursor: EditorPosition, 44 | editor: Editor, 45 | file: TFile 46 | ): EditorSuggestTriggerInfo { 47 | const line = editor.getLine(cursor.line); 48 | if (!this.taskValidator.isMarkdownTaskWithIndicatorTag(line)) { 49 | return null; 50 | } 51 | return { 52 | start: { line: cursor.line, ch: 0 }, 53 | end: { 54 | line: cursor.line, 55 | ch: line.length 56 | }, 57 | query: line 58 | }; 59 | } 60 | 61 | getSuggestions(context: EditorSuggestContext): SuggestInfoWithContext[] { 62 | const line = context.query; 63 | const currentCursor = context.editor.getCursor(); 64 | const cursorPos = currentCursor.ch; 65 | 66 | const suggestions: SuggestInformation[] = 67 | this.attributeSuggester.buildSuggestions(line, cursorPos); 68 | return suggestions.map((s) => ({ ...s, context })); 69 | } 70 | 71 | renderSuggestion(suggestion: SuggestInfoWithContext, el: HTMLElement): void { 72 | if (!suggestion.innerHTML) { 73 | el.setText(suggestion.displayText); 74 | } else { 75 | // Apply Flexbox styles to vertically center the content 76 | const parentStyle = { 77 | display: 'flex', 78 | alignItems: 'center', 79 | justifyContent: 'space-between' 80 | }; 81 | el.innerHTML = suggestion.innerHTML; 82 | el.setCssStyles(parentStyle); 83 | } 84 | } 85 | 86 | selectSuggestion( 87 | suggestion: SuggestInfoWithContext, 88 | event: KeyboardEvent | MouseEvent 89 | ): void { 90 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 91 | if (!activeView) { 92 | logger.error('No active MarkdownView found.'); 93 | return; 94 | } 95 | const currentCursor = suggestion.context.editor.getCursor(); 96 | const replaceFrom = { 97 | line: currentCursor.line, 98 | ch: suggestion.replaceFrom ?? currentCursor.ch 99 | }; 100 | const replaceTo = { 101 | line: currentCursor.line, 102 | ch: suggestion.replaceTo ?? currentCursor.ch 103 | }; 104 | suggestion.context.editor.replaceRange( 105 | suggestion.replaceText, 106 | replaceFrom, 107 | replaceTo 108 | ); 109 | suggestion.context.editor.setCursor({ 110 | line: currentCursor.line, 111 | ch: suggestion.cursorPosition 112 | }); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/autoSuggestions/index.ts: -------------------------------------------------------------------------------- 1 | export type SuggestInformation = { 2 | displayText: string; // text to display to the user 3 | replaceText: string; // text to replace the original file 4 | replaceFrom: number; // where to replace from in a line. 5 | replaceTo: number; // where to replace to in a line. 6 | cursorPosition: number; // the new cursor position in the line. 7 | innerHTML?: string; // the innerHTML of the suggestion 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/CircularProgressBar.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {#if showDigits} 39 | 40 | {value}/{max} 41 | 42 | {/if} 43 | 44 | 45 | 92 | -------------------------------------------------------------------------------- /src/components/LinearProgressBar.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 22 | 23 | 32 | 41 | 42 | 43 | {#if showDigits} 44 | 45 | {value}/{max} 46 | 47 | {/if} 48 | 49 | 50 | 85 | -------------------------------------------------------------------------------- /src/components/icons/AlertTriangle.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/icons/CalendarCheck.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/icons/CalendarClock.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/icons/ChevronsDownUp.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/icons/ChevronsUpDown.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/icons/Collapse.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 15 | 16 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/icons/History.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/icons/LucideIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 37 | {@html svgPath} 38 | 39 | -------------------------------------------------------------------------------- /src/components/icons/MonoColorSVG.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 27 | -------------------------------------------------------------------------------- /src/components/icons/MoreVertical.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/icons/Plus.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/modal/createProjectModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Notice, Setting } from 'obsidian'; 2 | import { Project } from '../taskModule/project'; 3 | 4 | export class CreateProjectModal extends Modal { 5 | result: Partial = { 6 | id: '', 7 | name: '', 8 | color: '' 9 | }; 10 | onSubmit: (result: Partial) => boolean; 11 | 12 | constructor(app: App, onSubmit: (result: Partial) => boolean) { 13 | super(app); 14 | this.onSubmit = onSubmit; 15 | } 16 | 17 | onOpen() { 18 | const { contentEl } = this; 19 | 20 | contentEl.createEl('h1', { text: "Task Card: Create a Project" }); 21 | 22 | new Setting(contentEl) 23 | .setName('Project Name') 24 | .setDesc('The name of the project. Cannot be empty.') 25 | .addText((text) => 26 | text.onChange((value) => { 27 | this.result.name = value; 28 | }) 29 | ); 30 | 31 | new Setting(contentEl) 32 | .setName('Project Color') 33 | .setDesc('The color of the project. Optional. If not provided, a random color will be assigned.') 34 | .addColorPicker((colorPicker) => 35 | colorPicker.onChange((value) => { 36 | this.result.color = value; 37 | }) 38 | ); 39 | 40 | new Setting(contentEl).addButton((btn) => 41 | btn 42 | .setButtonText('Submit') 43 | .setCta() 44 | .onClick(() => { 45 | this.close(); 46 | }) 47 | ); 48 | } 49 | 50 | checkValidity() { 51 | // check if the project name is valid (not empty) 52 | if (!this.result.name) { 53 | return false; 54 | } else { 55 | return true; 56 | } 57 | } 58 | 59 | onClose() { 60 | // Clear content first 61 | let { contentEl } = this; 62 | contentEl.empty(); 63 | 64 | // Check validity 65 | if (!this.checkValidity()) { 66 | new Notice('[TaskCard] Project name cannot be empty.'); 67 | return; // Exit function early if not valid 68 | } 69 | 70 | // Handle submission 71 | const result = this.onSubmit(this.result); 72 | if (result === true) { 73 | new Notice(`[TaskCard] Project created: ${this.result.name}`); 74 | } else { 75 | new Notice('[TaskCard] Project creation failed. Make sure the project name is unique.'); 76 | } 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /src/query/index.ts: -------------------------------------------------------------------------------- 1 | import TaskCardPlugin from '..'; 2 | import { PositionedTaskCache } from './cache'; 3 | 4 | 5 | export class TaskCardCache { 6 | taskCache: PositionedTaskCache; 7 | 8 | constructor(plugin: TaskCardPlugin) { 9 | this.taskCache = new PositionedTaskCache(plugin); 10 | } 11 | } -------------------------------------------------------------------------------- /src/query/indexMapDatabase.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | export type QueryFunction = (item: T) => boolean; 5 | export type IndexExtractor = (item: T) => any; 6 | export type IndexQueryFunction = (value: T) => boolean; 7 | 8 | export type LogicalOperator = 'AND' | 'OR' | 'NOT'; 9 | export type LogicalExpression = { 10 | operator: LogicalOperator; 11 | operands: Array | IndexQueryFunction>; 12 | }; 13 | 14 | export class IndexedMapDatabase { 15 | protected data: Map = new Map(); 16 | protected indices: { [key: string]: Map> } = {}; 17 | 18 | createIndex(fieldName: string, extractor: IndexExtractor): void { 19 | const index = new Map>(); 20 | this.data.forEach((item, id) => { 21 | const value = extractor(item); 22 | if (!index.has(value)) { 23 | index.set(value, new Set()); 24 | } 25 | index.get(value)?.add(id); 26 | }); 27 | this.indices[fieldName] = index; 28 | } 29 | 30 | 31 | // Method to get all possible values of an index 32 | getAllIndexValues(fieldName: string): any[] | null { 33 | const index = this.indices[fieldName]; 34 | if (!index) { 35 | return null; // Return null if the index does not exist 36 | } 37 | return Array.from(index.keys()); // Convert Set to Array and return 38 | } 39 | 40 | 41 | bulkStore(items: Array<{ id: string, item: T }>): void { 42 | // Add items to the main data map 43 | items.forEach(({ id, item }) => { 44 | this.data.set(id, item); 45 | }); 46 | 47 | // Update indices for all items 48 | items.forEach(({ id, item }) => { 49 | this.updateIndices(id, item); 50 | }); 51 | } 52 | 53 | // Method to update the whole database, clear previous tasks, and update new tasks 54 | updateDatabase(newItems: Array<{ id: string, item: T }>): void { 55 | // Clear existing data and indices 56 | this.clearAllTasks(); 57 | 58 | // Bulk store new items 59 | this.bulkStore(newItems); 60 | } 61 | 62 | // Method to clear all tasks 63 | clearAllTasks(): void { 64 | // Clear the main data map 65 | this.data.clear(); 66 | 67 | // Clear all indices 68 | for (const index of Object.values(this.indices)) { 69 | index.clear(); 70 | } 71 | } 72 | 73 | refreshTasksByAttribute(attribute: string, value: any, newTasks: Array<{ id: string, item: T }>): void { 74 | // Find tasks with the specific attribute and value 75 | const index = this.indices[attribute]; 76 | const idsToDelete = index.get(value) || new Set(); 77 | 78 | // Delete old tasks 79 | idsToDelete.forEach(id => { 80 | this.data.delete(id); 81 | }); 82 | 83 | // Clear the index entry 84 | index.delete(value); 85 | 86 | // Add new tasks 87 | this.bulkStore(newTasks); 88 | } 89 | 90 | store(id: string, item: T): void { 91 | this.data.set(id, item); 92 | this.updateIndices(id, item); 93 | } 94 | 95 | private updateIndices(id: string, item: T): void { 96 | for (const [fieldName, index] of Object.entries(this.indices)) { 97 | const extractor = (item: T) => item[fieldName]; 98 | const value = extractor(item); 99 | if (!index.has(value)) { 100 | index.set(value, new Set()); 101 | } 102 | index.get(value)?.add(id); 103 | } 104 | } 105 | 106 | queryByComplexLogic(expression: LogicalExpression): T[] { 107 | const evaluateExpression = (expr: LogicalExpression | IndexQueryFunction): Set => { 108 | if (typeof expr === 'function') { 109 | const ids = new Set(); 110 | this.data.forEach((item, id) => { 111 | if (expr(item)) { 112 | ids.add(id); 113 | } 114 | }); 115 | return ids; 116 | } 117 | 118 | const { operator, operands } = expr; 119 | let resultIds: Set = new Set(this.data.keys()); // Initialize to all task IDs 120 | 121 | if (operator === 'NOT') { 122 | const ids = evaluateExpression(operands[0]); 123 | this.data.forEach((_, id) => { 124 | if (!ids.has(id)) { 125 | resultIds.add(id); 126 | } 127 | }); 128 | return resultIds; 129 | } 130 | 131 | operands.forEach(operand => { 132 | const ids = evaluateExpression(operand); 133 | if (operator === 'AND') { 134 | resultIds = new Set([...resultIds].filter(id => ids.has(id))); 135 | } else if (operator === 'OR') { 136 | resultIds = new Set([...resultIds, ...ids]); 137 | } 138 | }); 139 | 140 | return resultIds; 141 | }; 142 | 143 | const finalIds = evaluateExpression(expression); 144 | return Array.from(finalIds).map(id => this.data.get(id)!).filter(Boolean); 145 | } 146 | 147 | getLength(): number { 148 | return this.data.size; 149 | } 150 | 151 | } -------------------------------------------------------------------------------- /src/renderer/StaticTaskListRenderer.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownPostProcessorContext } from 'obsidian'; 2 | import { DocPosition, TextPosition } from '../taskModule/task'; 3 | import TaskCardPlugin from ".."; 4 | import { QueryResult } from "obsidian-dataview/lib/api/plugin-api"; 5 | import { logger } from "../utils/log"; 6 | import { SettingStore } from "../settings"; 7 | import { QueryAndTaskListSvelteAdapter } from './queryAndTaskListSvelteAdapter'; 8 | 9 | 10 | export interface MarkdownTaskMetadata { 11 | originalText: string; 12 | docPosition: DocPosition; 13 | } 14 | 15 | export interface CodeBlockProcessor { 16 | (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext): void | Promise; 17 | } 18 | 19 | export enum StaticTaskCardDisplayMode { 20 | normal, 21 | compact, 22 | } 23 | 24 | export class StaticTaskListRenderManager { 25 | plugin: TaskCardPlugin; 26 | constructor(plugin: TaskCardPlugin) { 27 | this.plugin = plugin; 28 | } 29 | 30 | async extractMarkdownTaskMetadata(queryResult: QueryResult): Promise { 31 | const mdTaskMetadataList: MarkdownTaskMetadata[] = []; 32 | 33 | for (const task of queryResult.values) { 34 | const filePath = task.path; 35 | // const lineNumber = task.line; 36 | const startPosition: TextPosition = { line: task.position.start.line, col: task.position.start.col }; 37 | const endPosition: TextPosition = { line: task.position.end.line, col: task.position.end.col }; 38 | const originalText = `- [${task.status}] ` + task.text; 39 | 40 | // Use FileOperator to get the original text 41 | // const originalText = await fileOperator.getLineFromFile(filePath, lineNumber); 42 | 43 | const isValid = this.plugin.taskValidator.isValidFormattedTaskMarkdown(originalText); 44 | if (!isValid) { continue; } 45 | 46 | if (originalText !== null) { 47 | const markdownTaskMetadata: MarkdownTaskMetadata = { 48 | originalText: originalText, 49 | docPosition: { 50 | filePath: filePath, 51 | start: startPosition, 52 | end: endPosition 53 | } 54 | } 55 | 56 | mdTaskMetadataList.push(markdownTaskMetadata); 57 | } 58 | } 59 | 60 | return mdTaskMetadataList; 61 | } 62 | 63 | 64 | getCodeBlockProcessor(): CodeBlockProcessor { 65 | const codeBlockProcessor: CodeBlockProcessor = async (source, el, ctx) => { 66 | let blockLanguage: string; 67 | SettingStore.subscribe((settings) => { 68 | blockLanguage = settings.parsingSettings.blockLanguage; 69 | }) 70 | const processor = new QueryAndTaskListSvelteAdapter(this.plugin, blockLanguage, source, el, ctx); 71 | processor.onload(); 72 | }; 73 | return codeBlockProcessor; 74 | } 75 | } -------------------------------------------------------------------------------- /src/renderer/TaskCardRenderer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MarkdownPostProcessor, 3 | MarkdownPostProcessorContext, 4 | MarkdownSectionInformation, 5 | htmlToMarkdown 6 | } from 'obsidian'; 7 | import TaskCardPlugin from '..'; 8 | import { 9 | getIndicesOfFilter, 10 | isTaskItemEl, 11 | isTaskList 12 | } from './filters'; 13 | import { TaskValidator } from '../taskModule/taskValidator'; 14 | import { TaskItemSvelteAdapter } from './postProcessor'; 15 | import { ObsidianTaskSyncProps } from '../taskModule/taskSyncManager'; 16 | import { logger } from '../utils/log'; 17 | 18 | export interface TaskItemData { 19 | // HTML information about the TaskItem 20 | el: HTMLElement; 21 | origHTML: string; 22 | mdSectionInfo: MarkdownSectionInformation; 23 | lineNumberInSection: number; 24 | lineNumberEndsInSection?: number; 25 | markdown: string; 26 | } 27 | 28 | export class TaskCardRenderManager { 29 | private plugin: TaskCardPlugin; 30 | private taskItemFilter: (elems: HTMLElement) => boolean; 31 | 32 | constructor(plugin: TaskCardPlugin) { 33 | this.plugin = plugin; 34 | 35 | this.taskItemFilter = ( 36 | (taskValidator: TaskValidator) => (elem: HTMLElement) => 37 | isTaskItemEl(elem, taskValidator) 38 | )(this.plugin.taskValidator); 39 | } 40 | 41 | getPostProcessor(): MarkdownPostProcessor { 42 | const postProcessor = async ( 43 | el: HTMLElement, 44 | ctx: MarkdownPostProcessorContext 45 | ) => { 46 | const taskSyncs: ObsidianTaskSyncProps[] = await this.constructTaskSync(el, ctx); 47 | 48 | for (const taskSync of taskSyncs) { 49 | // register taskStore 50 | const processor = new TaskItemSvelteAdapter(taskSync, this.plugin); 51 | processor.onload(); 52 | } 53 | 54 | }; 55 | 56 | return postProcessor; 57 | } 58 | 59 | async constructTaskSync( 60 | sectionDiv: HTMLElement, 61 | ctx: MarkdownPostProcessorContext 62 | ): Promise { 63 | // markdownTask is null (not used here) 64 | const section: HTMLElement = sectionDiv.children[0] as HTMLElement; 65 | if (!isTaskList(section)) return []; 66 | const taskItemsIndices: number[] = getIndicesOfFilter( 67 | Array.from(section.children) as HTMLElement[], 68 | this.taskItemFilter 69 | ); 70 | if (taskItemsIndices.length === 0) return []; 71 | 72 | const mdSectionInfo = ctx.getSectionInfo(section); 73 | const sourcePath = ctx.sourcePath; 74 | const mdSectionContent = 75 | await this.plugin.fileOperator.getMarkdownBetweenLines( 76 | sourcePath, 77 | mdSectionInfo.lineStart, 78 | mdSectionInfo.lineEnd + 1 79 | ); 80 | // const lineNumbers: number[] = taskItemsIndices.map((index) => 81 | // getLineNumberOfListItem(section, index, mdSectionContent) 82 | // ); 83 | 84 | const lineStartEndNumbers: { startLine: number, endLine: number }[] = taskItemsIndices.map((index) => 85 | getLineNumbersOfListItem(section, index, mdSectionContent) 86 | ); 87 | 88 | 89 | const taskSyncs: ObsidianTaskSyncProps[] = taskItemsIndices.map( 90 | (index, i) => { 91 | const taskItemEl: HTMLElement = section.children[index] as HTMLElement; 92 | const lineStartInSection = lineStartEndNumbers[i].startLine; 93 | const lineEndsInSection = lineStartEndNumbers[i].endLine; 94 | const obsidianTask = this.plugin.taskParser.parseTaskEl(taskItemEl); 95 | return { 96 | obsidianTask: obsidianTask, 97 | taskCardStatus: { 98 | descriptionStatus: 'done', 99 | projectStatus: 'done', 100 | scheduleStatus: 'done', 101 | dueStatus: 'done', 102 | durationStatus: 'done' 103 | }, 104 | markdownTask: null, 105 | taskItemEl: taskItemEl, 106 | taskMetadata: { 107 | sectionEl: section, 108 | ctx: ctx, 109 | sourcePath: sourcePath, 110 | mdSectionInfo: mdSectionInfo, 111 | lineStartInSection: lineStartInSection, 112 | lineEndsInSection: lineEndsInSection 113 | } 114 | }; 115 | } 116 | ); 117 | return taskSyncs; 118 | } 119 | 120 | 121 | } 122 | 123 | export function getLineNumberOfListItem( 124 | ul: HTMLElement, 125 | index: number, 126 | content: string 127 | ): number { 128 | let lineNumber = 0; 129 | const originalLines = content.split('\n'); 130 | let originalLineIndex = 0; 131 | 132 | for (let i = 0; i < index; i++) { 133 | const markdown = htmlToMarkdown(ul.children[i].innerHTML); 134 | const lines = markdown.split('\n').filter((line) => line.trim() !== ''); 135 | 136 | lineNumber += lines.length; 137 | 138 | originalLineIndex += lines.length; 139 | // Count any empty lines that follow the current list item in the original content 140 | while ( 141 | originalLines.length > originalLineIndex && 142 | originalLines[originalLineIndex].trim() === '' 143 | ) { 144 | lineNumber++; 145 | originalLineIndex++; 146 | } 147 | } 148 | 149 | return lineNumber; 150 | } 151 | 152 | export function getLineNumbersOfListItem( 153 | ul: HTMLElement, 154 | index: number, 155 | content: string 156 | ): { startLine: number, endLine: number } { 157 | let startLine = 0; 158 | let endLine = 0; 159 | const originalLines = content.split('\n'); 160 | let originalLineIndex = 0; 161 | 162 | // Loop through each list item up to the specified index 163 | for (let i = 0; i <= index; i++) { 164 | const markdown = htmlToMarkdown(ul.children[i].innerHTML); 165 | const lines = markdown.split('\n').filter((line) => line.trim() !== ''); 166 | 167 | // If we're at the specified index, set the startLine 168 | if (i === index) { 169 | startLine = endLine; 170 | } 171 | 172 | // Update the end line number 173 | endLine += lines.length; 174 | 175 | originalLineIndex += lines.length; 176 | // Count any empty lines that follow the current list item in the original content 177 | while ( 178 | originalLines.length > originalLineIndex && 179 | originalLines[originalLineIndex].trim() === '' 180 | ) { 181 | endLine++; 182 | originalLineIndex++; 183 | } 184 | } 185 | 186 | return { startLine, endLine }; 187 | 188 | } -------------------------------------------------------------------------------- /src/renderer/fileOperator.ts: -------------------------------------------------------------------------------- 1 | import { App, TAbstractFile, TFile } from 'obsidian'; 2 | import TaskCardPlugin from '..'; 3 | import { logger } from '../utils/log'; 4 | 5 | export class FileOperator { 6 | app: App; 7 | plugin: TaskCardPlugin; 8 | 9 | constructor(plugin: TaskCardPlugin, app: App) { 10 | this.plugin = plugin; 11 | this.app = app; 12 | } 13 | 14 | async getFileContent(filePath: string): Promise { 15 | const file: TAbstractFile = this.app.vault.getAbstractFileByPath(filePath); 16 | if (!file) return null; 17 | const content = await this.app.vault.read(file as TFile); 18 | return content; 19 | } 20 | 21 | async getFileLines(filePath: string): Promise { 22 | const content = await this.getFileContent(filePath); 23 | if (!content) return null; 24 | return content.split('\n'); 25 | } 26 | 27 | async getLineFromFile(filePath: string, lineNumber: number): Promise { 28 | const fileLines = await this.getFileLines(filePath); 29 | if (!fileLines || fileLines.length < lineNumber) return null; 30 | return fileLines[lineNumber - 1]; 31 | } 32 | 33 | async getMarkdownBetweenLines( 34 | filePath: string, 35 | lineStart: number, 36 | lineEnd: number 37 | ): Promise { 38 | const fileLines = await this.getFileLines(filePath); 39 | if (!fileLines) return null; 40 | return fileLines.slice(lineStart, lineEnd).join('\n'); 41 | } 42 | 43 | async updateFile( 44 | filePath: string, 45 | newContent: string, 46 | lineStart: number, 47 | lineEnd: number 48 | ): Promise { 49 | const file = await this.app.vault.getAbstractFileByPath(filePath); 50 | if (!file) return; 51 | const fileLines = await this.getFileLines(filePath); 52 | const newFileLines: string[] = [...fileLines]; 53 | newFileLines.splice(lineStart, lineEnd - lineStart, newContent); 54 | await this.app.vault.modify(file as TFile, newFileLines.join('\n')); 55 | } 56 | 57 | async updateLineInFile(filePath: string, lineNumber: number, newContent: string): Promise { 58 | const file = await this.app.vault.getAbstractFileByPath(filePath); 59 | if (!file) return; 60 | const fileLines = await this.getFileLines(filePath); 61 | const newFileLines: string[] = [...fileLines]; 62 | newFileLines[lineNumber - 1] = newContent; 63 | await this.app.vault.modify(file as TFile, newFileLines.join('\n')); 64 | } 65 | 66 | getAllFilesAndFolders(): string[] { 67 | const mdFiles = this.app.vault.getMarkdownFiles(); 68 | const mdPaths = mdFiles.map((file) => file.path); 69 | 70 | // Initialize a Set to store the unique relative paths 71 | let relativePaths: Set = new Set(); 72 | // Loop through each Markdown file to get its path relative to the root 73 | mdPaths.forEach((relativePath) => { 74 | relativePaths.add(relativePath); 75 | 76 | // Add folders in between 77 | let folderPath = ''; 78 | const pathParts = relativePath.split('/'); 79 | for (let i = 0; i < pathParts.length - 1; i++) { 80 | folderPath += pathParts[i] + '/'; 81 | relativePaths.add(folderPath); 82 | } 83 | }); 84 | 85 | // Convert the Set back to an array of strings 86 | return Array.from(relativePaths); 87 | } 88 | 89 | getVaultRoot(): string { 90 | return this.app.vault.getRoot().path; 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /src/renderer/filters.ts: -------------------------------------------------------------------------------- 1 | import type { TaskValidator } from '../taskModule/taskValidator'; 2 | import { logger } from '../utils/log'; 3 | 4 | export function isTaskList(el: HTMLElement): boolean { 5 | // ul, class contains: contains-task-list and has-list-bullet 6 | if (!el) { 7 | return false; 8 | } 9 | // logger.debug(`isTaskList: el - ${el.innerHTML}, el.tagName - ${el.tagName}, el.classList - ${JSON.stringify(el.classList)}`) 10 | if (el.tagName !== 'UL') { 11 | return false; 12 | } 13 | return ( 14 | el.classList.contains('contains-task-list') && 15 | el.classList.contains('has-list-bullet') 16 | ); 17 | } 18 | 19 | export function isTaskItemEl( 20 | el: HTMLElement, 21 | taskValidator: TaskValidator 22 | ): boolean { 23 | if (!el) { 24 | return false; 25 | } 26 | if (el.tagName !== 'LI' || el.children.length === 0) { 27 | return false; 28 | } 29 | 30 | return taskValidator.isValidTaskElement(el as HTMLElement); 31 | } 32 | 33 | /** 34 | * Filters an array of HTMLElements to return only those that are task items. 35 | * A task item is defined as an 'LI' element that satisfies the taskValidator criteria. 36 | * 37 | * @param elems - An array of HTMLElements to be filtered. 38 | * @param taskValidator - An object with a method isValidTaskElement to validate task items. 39 | * @returns An array of HTMLElements that are valid task items. 40 | */ 41 | export function filterTaskItems( 42 | elems: HTMLElement[], 43 | taskValidator: TaskValidator 44 | ): HTMLElement[] { 45 | if ( 46 | !Array.isArray(elems) || 47 | typeof taskValidator.isValidTaskElement !== 'function' 48 | ) { 49 | throw new Error('Invalid input provided.'); 50 | } 51 | 52 | return elems.filter((el) => { 53 | if (!(el instanceof HTMLElement)) { 54 | return false; 55 | } 56 | return ( 57 | el.tagName === 'LI' && 58 | el.children.length > 0 && 59 | taskValidator.isValidTaskElement(el.children[0] as HTMLElement) 60 | ); 61 | }); 62 | } 63 | 64 | /** 65 | * Returns the indices of elements in an array that satisfy a given filter function. 66 | * 67 | * @param array - The array to be filtered. 68 | * @param filter - A filter function that returns a boolean for each element in the array. 69 | * @returns An array of indices of elements that satisfy the filter function. 70 | */ 71 | export function getIndicesOfFilter( 72 | array: any[], 73 | filter: (element: any) => boolean 74 | ): number[] { 75 | if (!Array.isArray(array) || typeof filter !== 'function') { 76 | throw new Error('Invalid input provided.'); 77 | } 78 | 79 | const indices: number[] = []; 80 | array.forEach((element, index) => { 81 | if (filter(element)) { 82 | indices.push(index); 83 | } 84 | }); 85 | return indices; 86 | } 87 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export { TaskCardRenderManager } from './TaskCardRenderer'; 4 | 5 | export { StaticTaskListRenderManager } from './StaticTaskListRenderer'; 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/renderer/postProcessor.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export { TaskItemSvelteAdapter } from './taskItemSvelteAdapter'; 4 | 5 | export type TaskDisplayMode = 'single-line' | 'multi-line'; 6 | export interface TaskDisplayParams { 7 | mode?: TaskDisplayMode | null; 8 | } 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/renderer/queryAndTaskListSvelteAdapter.ts: -------------------------------------------------------------------------------- 1 | import { SvelteComponent } from "svelte" 2 | import TaskCardPlugin from ".." 3 | import QueryEditor from "../ui/QueryEditor.svelte"; 4 | import StaticTaskList from '../ui/StaticTaskList.svelte'; 5 | import { QuerySyncManager } from "../query/querySyncManager" 6 | import { MarkdownPostProcessorContext, MarkdownSectionInformation } from "obsidian" 7 | import { logger } from "../utils/log"; 8 | import StaticTaskMatrix from "../ui/StaticTaskMatrix.svelte"; 9 | import QueryDisplay from "../ui/QueryDisplay.svelte"; 10 | 11 | 12 | export class QueryAndTaskListSvelteAdapter { 13 | plugin: TaskCardPlugin 14 | svelteComponent: SvelteComponent 15 | codeBlockEl: HTMLElement 16 | codeBlockMetadata: { 17 | sectionEl: HTMLElement 18 | ctx: MarkdownPostProcessorContext 19 | sourcePath: string 20 | lineStart: number 21 | lineEnd: number 22 | } 23 | querySyncManager: QuerySyncManager 24 | 25 | constructor( 26 | plugin: TaskCardPlugin, 27 | blockLanguage: string, 28 | source: string, 29 | el: HTMLElement, 30 | ctx: MarkdownPostProcessorContext, 31 | ) { 32 | this.plugin = plugin 33 | this.codeBlockEl = el 34 | const mdSectionInfo: MarkdownSectionInformation = ctx.getSectionInfo(el); 35 | this.codeBlockMetadata = { 36 | sectionEl: el, 37 | ctx: ctx, 38 | sourcePath: ctx.sourcePath, 39 | lineStart: mdSectionInfo.lineStart, 40 | lineEnd: mdSectionInfo.lineEnd 41 | } 42 | this.querySyncManager = new QuerySyncManager( 43 | this.plugin, 44 | blockLanguage, 45 | source, 46 | this.codeBlockMetadata 47 | ) 48 | } 49 | 50 | async onload() { 51 | if (this.querySyncManager.editMode) { 52 | this.svelteComponent = new QueryEditor({ 53 | target: this.codeBlockEl, 54 | props: { 55 | options: this.querySyncManager.getOptions(), 56 | query: this.querySyncManager.taskQuery, 57 | querySyncManager: this.querySyncManager, 58 | paths: this.plugin.fileOperator.getAllFilesAndFolders(), 59 | } 60 | }) 61 | } else { 62 | this.svelteComponent = new QueryDisplay({ 63 | target: this.codeBlockEl, 64 | props: { 65 | taskList: await this.querySyncManager.getFilteredTasks(), 66 | plugin: this.plugin, 67 | querySyncManager: this.querySyncManager, 68 | displayMode: this.plugin.settings.displaySettings.queryDisplayMode, 69 | } 70 | }) 71 | 72 | } 73 | 74 | } 75 | } -------------------------------------------------------------------------------- /src/renderer/store.ts: -------------------------------------------------------------------------------- 1 | import { Writable, writable } from 'svelte/store'; 2 | import { TaskDisplayMode } from './postProcessor'; 3 | import { SettingStore } from '../settings'; 4 | import { MarkdownView, Workspace, WorkspaceLeaf } from 'obsidian'; 5 | import { logger } from '../utils/log'; 6 | import { ObsidianTaskSyncProps } from '../taskModule/taskSyncManager'; 7 | 8 | export class TaskStore { 9 | private taskModes: Writable<{ [key: string]: TaskDisplayMode }>; 10 | public readonly subscribe: Function; 11 | private filePath: string = ''; 12 | private defaultMode: TaskDisplayMode = 'single-line'; // Default value 13 | 14 | constructor() { 15 | this.taskModes = writable({}); 16 | this.subscribe = this.taskModes.subscribe; 17 | 18 | SettingStore.subscribe((settings) => { 19 | this.defaultMode = settings.displaySettings.defaultMode as TaskDisplayMode; 20 | }); 21 | } 22 | 23 | // FilePath-related Methods 24 | activeLeafChangeHandler(leaf: WorkspaceLeaf): void { 25 | const view = leaf.view as MarkdownView; 26 | if (!view.file) { return; } 27 | const newFilePath = view.file.path; 28 | const mode = view.getMode(); 29 | if (mode !== 'preview') { this.clearTaskDisplayModes(); } 30 | this.setFilePath(newFilePath); 31 | } 32 | 33 | private clearTaskDisplayModes(): void { 34 | this.taskModes.set({}); 35 | } 36 | 37 | private setFilePath(newFilePath: string): void { 38 | if (newFilePath !== this.filePath) { 39 | this.filePath = newFilePath; 40 | this.clearTaskDisplayModes(); 41 | } 42 | } 43 | 44 | getDefaultMode(): TaskDisplayMode { 45 | return this.defaultMode; 46 | } 47 | 48 | // Mode-related CRUD Operations (By Line Numbers) 49 | setModeByLine(startLine: number, endLine: number, newMode: TaskDisplayMode = this.defaultMode): void { 50 | this.updateMode(this.generateKey(startLine, endLine), newMode); 51 | } 52 | 53 | getModeByLine(startLine: number, endLine: number): TaskDisplayMode | null { 54 | return this.getModeByKey(this.generateKey(startLine, endLine)); 55 | } 56 | 57 | updateModeByLine(startLine: number, endLine: number, newMode: TaskDisplayMode): void { 58 | this.ensureMode(this.generateKey(startLine, endLine), newMode); 59 | } 60 | 61 | // Mode-related CRUD Operations (By Key) 62 | setModeByKey(key: string, newMode: TaskDisplayMode = this.defaultMode): void { 63 | this.updateMode(key, newMode); 64 | } 65 | 66 | getModeByKey(key: string): TaskDisplayMode | null { 67 | let mode = null; 68 | this.taskModes.subscribe((modes) => { 69 | mode = modes[key] || null; 70 | })(); 71 | return mode; 72 | } 73 | 74 | updateModeByKey(key: string, newMode: TaskDisplayMode): void { 75 | this.ensureMode(key, newMode); 76 | } 77 | 78 | // Mode-related CRUD Operations (By Task Sync) 79 | setModeBySync(taskSync: ObsidianTaskSyncProps, newMode: TaskDisplayMode = this.defaultMode): void { 80 | this.updateMode(this.generateKeyFromSync(taskSync), newMode); 81 | } 82 | 83 | getModeBySync(taskSync: ObsidianTaskSyncProps): TaskDisplayMode | null { 84 | return this.getModeByKey(this.generateKeyFromSync(taskSync)); 85 | } 86 | 87 | updateModeBySync(taskSync: ObsidianTaskSyncProps, newMode: TaskDisplayMode): void { 88 | this.ensureMode(this.generateKeyFromSync(taskSync), newMode); 89 | } 90 | 91 | // Ensure Mode Exists 92 | private ensureMode(key: string, newMode: TaskDisplayMode): void { 93 | this.taskModes.update((modes) => { 94 | if (modes[key]) { 95 | modes[key] = newMode; 96 | } 97 | return modes; 98 | }); 99 | } 100 | 101 | // Get All Modes 102 | getAllModes(): { [key: string]: TaskDisplayMode } { 103 | let modes; 104 | this.taskModes.subscribe((currentModes) => { 105 | modes = { ...currentModes }; 106 | })(); 107 | return modes; 108 | } 109 | 110 | // Helper Methods 111 | private generateKey(startLine: number, endLine: number): string { 112 | return `${startLine}-${endLine}`; 113 | } 114 | 115 | private updateMode(key: string, newMode: TaskDisplayMode): void { 116 | this.taskModes.update((modes) => { 117 | modes[key] = newMode; 118 | return modes; 119 | }); 120 | } 121 | 122 | private generateKeyFromSync(taskSync: ObsidianTaskSyncProps): string { 123 | const docLineStart = taskSync.taskMetadata.lineStartInSection + taskSync.taskMetadata.mdSectionInfo.lineStart; 124 | const docLineEnd = taskSync.taskMetadata.lineEndsInSection + taskSync.taskMetadata.mdSectionInfo.lineStart; 125 | return this.generateKey(docLineStart, docLineEnd); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/renderer/taskItemSvelteAdapter.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | import { MarkdownRenderChild } from 'obsidian'; 4 | import { SvelteComponent } from 'svelte'; 5 | import TaskCardPlugin from '..'; 6 | import TaskItem from '../ui/TaskItem.svelte'; 7 | import { 8 | ObsidianTaskSyncManager, 9 | ObsidianTaskSyncProps 10 | } from '../taskModule/taskSyncManager'; 11 | 12 | 13 | export class TaskItemSvelteAdapter extends MarkdownRenderChild { 14 | taskSync: ObsidianTaskSyncProps; 15 | taskSyncManager: ObsidianTaskSyncManager; 16 | svelteComponent: SvelteComponent; 17 | plugin: TaskCardPlugin; 18 | 19 | constructor(taskSync: ObsidianTaskSyncProps, plugin: TaskCardPlugin) { 20 | super(taskSync.taskItemEl); 21 | this.taskSync = taskSync; 22 | this.taskSyncManager = new ObsidianTaskSyncManager(plugin, taskSync); 23 | this.plugin = plugin; 24 | } 25 | 26 | onload() { 27 | this.svelteComponent = new TaskItem({ 28 | target: this.taskSync.taskItemEl.parentElement, 29 | props: { 30 | taskSyncManager: this.taskSyncManager, 31 | plugin: this.plugin, 32 | // defaultParams: this.params 33 | }, 34 | anchor: this.taskSync.taskItemEl 35 | }); 36 | 37 | // New element has been created right before the target element, now hide the target element 38 | this.taskSync.taskItemEl.style.display = 'none'; 39 | } 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/settings/displaySettings.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Setting } from "obsidian"; 2 | import { TaskCardSettings } from "../settings"; 3 | import { logger } from "../utils/log"; 4 | 5 | 6 | 7 | 8 | 9 | export function cardDisplaySettings( 10 | containerEl: HTMLElement, 11 | pluginSettings: TaskCardSettings, 12 | writeSettings: Function, 13 | display: Function, 14 | ) { 15 | new Setting(containerEl) 16 | .setName('Default Display Mode') 17 | .setDesc('The default display mode when creating a new task card.') 18 | .addDropdown((dropdown) => { 19 | dropdown 20 | .addOptions({ 21 | 'single-line': 'Preview Mode', 22 | 'multi-line': 'Detailed Mode' 23 | }) 24 | .setValue(pluginSettings.displaySettings.defaultMode) 25 | .onChange(async (value: string) => { 26 | await writeSettings( 27 | (old) => (old.displaySettings.defaultMode = value) 28 | ); 29 | logger.info(`Default display mode updated: ${value}`); 30 | new Notice(`[TaskCard] Default display mode updated: ${value}.`); 31 | }); 32 | }); 33 | 34 | new Setting(containerEl) 35 | .setName('Upcoming Minutes') 36 | .setDesc('The number of minutes to display as upcoming task. Default is 15 minutes.') 37 | .addSlider((slider) => { 38 | let timeoutId: NodeJS.Timeout | null = null; 39 | slider 40 | .setValue(pluginSettings.displaySettings.upcomingMinutes) 41 | .setLimits(0, 60, 1) 42 | .setDynamicTooltip() 43 | .onChange(async (value: number) => { 44 | await writeSettings( 45 | (old) => (old.displaySettings.upcomingMinutes = value) 46 | ); 47 | logger.info(`Upcoming minutes updated: ${value}`); 48 | 49 | // Clear the existing timeout if there is one 50 | if (timeoutId !== null) { 51 | clearTimeout(timeoutId); 52 | } 53 | 54 | // Set a new timeout 55 | timeoutId = setTimeout(() => { 56 | new Notice(`[TaskCard] Upcoming minutes updated: ${value}.`); 57 | // Reset timeoutId to null when the notice is shown 58 | timeoutId = null; 59 | }, 2000); // 2000 milliseconds = 2 seconds delay 60 | }); 61 | }); 62 | 63 | new Setting(containerEl) 64 | .setName('Query Display Mode') 65 | .setDesc('The default display mode when displaying a task query.') 66 | .addDropdown((dropdown) => { 67 | dropdown 68 | .addOptions({ 69 | 'list': 'List Mode', 70 | 'matrix': 'Eisenhower Matrix Mode' 71 | }) 72 | .setValue(pluginSettings.displaySettings.queryDisplayMode) 73 | .onChange(async (value: string) => { 74 | await writeSettings( 75 | (old) => (old.displaySettings.queryDisplayMode = value) 76 | ); 77 | logger.info(`Query display mode updated: ${value}`); 78 | new Notice(`[TaskCard] Query display mode updated: ${value}.`); 79 | } 80 | ); 81 | } 82 | ); 83 | } -------------------------------------------------------------------------------- /src/taskModule/description/descriptionParser.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "../../utils/log"; 2 | 3 | 4 | // Fallback function to replace htmlToMarkdown from Obsidian 5 | const fallbackHtmlToMarkdown = (html: string): string => { 6 | // Your fallback implementation here 7 | return html; // For now, just returning the same HTML 8 | }; 9 | 10 | let htmlToMarkdown: (html: string) => string; 11 | 12 | try { 13 | // Try importing Obsidian's htmlToMarkdown 14 | const obsidian = require('obsidian'); 15 | htmlToMarkdown = obsidian.htmlToMarkdown; 16 | } catch (error) { 17 | // If Obsidian package is not found, use the fallback function 18 | console.warn("Obsidian package not found. Using fallback function for htmlToMarkdown."); 19 | htmlToMarkdown = fallbackHtmlToMarkdown; 20 | } 21 | 22 | export class DescriptionParser { 23 | constructor() { 24 | } 25 | 26 | // Extracts list elements (ul and ol) from taskElement 27 | static extractListEls(taskElement: HTMLElement): HTMLElement[] { 28 | if (!taskElement) { return []; } 29 | 30 | const listElements: HTMLElement[] = Array.from(taskElement.querySelectorAll('ul, ol')); 31 | return listElements; 32 | } 33 | 34 | // Parses the description from a given task element 35 | static parseDescriptionFromTaskEl(taskElement: HTMLElement): string { 36 | const listElements = DescriptionParser.extractListEls(taskElement); 37 | if (listElements.length === 0) { return ""; } 38 | let descriptionMarkdown = ""; 39 | 40 | for (const listEl of listElements) { 41 | try { 42 | descriptionMarkdown += htmlToMarkdown(listEl.outerHTML) + "\n"; 43 | } catch (error) { 44 | throw new Error(`Failed to convert HTML to Markdown: ${error.message}`); 45 | } 46 | } 47 | return descriptionMarkdown; 48 | } 49 | 50 | static progressOfDescription(description: string): [number, number] { 51 | if (!description || description.trim().length === 0) { return [0, 0]; } 52 | const taskRegex = /^(?:\s*)-\s\[(.)\]\s.+/gm; 53 | 54 | // Initialize counters for total tasks and finished tasks 55 | let totalTasks = 0; 56 | let finishedTasks = 0; 57 | 58 | // Find all matches 59 | let match; 60 | while ((match = taskRegex.exec(description)) !== null) { 61 | totalTasks++; 62 | if (match[1] && match[1].trim() !== '') { 63 | finishedTasks++; 64 | } 65 | } 66 | 67 | return [finishedTasks, totalTasks]; 68 | } 69 | 70 | 71 | 72 | } -------------------------------------------------------------------------------- /src/taskModule/description/index.ts: -------------------------------------------------------------------------------- 1 | export { DescriptionParser } from './descriptionParser'; 2 | -------------------------------------------------------------------------------- /src/taskModule/labels/index.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../../utils/log'; 2 | import { toCamelCase } from '../../utils/stringCaseConverter'; 3 | 4 | export class LabelModule { 5 | private labels: string[]; 6 | 7 | constructor() { 8 | this.labels = []; 9 | } 10 | 11 | // Check if a label is valid based on Obsidian's rules 12 | public isValidLabel(label: string): boolean { 13 | // The label should not be empty 14 | if (!label) return false; 15 | // The label should contain at least one non-numerical character 16 | if (!/[A-Za-z_\-\/]/.test(label)) return false; 17 | // The label should not contain spaces 18 | if (/\s/.test(label)) return false; 19 | return true; 20 | } 21 | 22 | // Validate and possibly format a label 23 | public validateLabel(label: string): string { 24 | // Remove symbols not accepted in a label, replace with space 25 | label = label.replace(/[^A-Za-z0-9_\-\/\s]/g, ' '); 26 | // Trim consecutive spaces to one, trim start and end spaces 27 | label = label.replace(/\s+/g, ' ').trim(); 28 | // Convert to camelCase if not strict 29 | return toCamelCase(label); 30 | } 31 | 32 | // Add a label to the labels list 33 | public addLabel(label: string, strict: boolean = true): void { 34 | label = label.replace(/^#+/, ''); // Remove leading "#" 35 | 36 | if (!this.isValidLabel(label)) { 37 | if (strict) { 38 | logger.error('Invalid label format'); 39 | } else { 40 | const validLabel = this.validateLabel(label); 41 | if (!this.isValidLabel(validLabel)) { 42 | logger.error('Invalid label format'); 43 | } else { 44 | this.pushLabel(validLabel); 45 | } 46 | } 47 | } else { 48 | this.pushLabel(label); 49 | } 50 | } 51 | 52 | private pushLabel(label: string): void { 53 | if (!this.labels.includes(label)) { 54 | this.labels.push(label); 55 | } 56 | } 57 | 58 | // Add multiple labels to the labels list 59 | public addLabels(labels: string[], strict: boolean = true): void { 60 | for (const label of labels) { 61 | this.addLabel(label, strict); 62 | } 63 | } 64 | 65 | // Get the list of labels 66 | public getLabels(): string[] { 67 | return this.labels.map((label) => '#' + label); // Add "#" at the start 68 | } 69 | 70 | // Set the list of labels 71 | public setLabels(labels: string[], strict: boolean = true): void { 72 | this.deleteAllLabels(); 73 | this.addLabels(labels, strict); 74 | } 75 | 76 | // Edit an existing label 77 | public editLabel( 78 | newLabel: string, 79 | oldLabel: string, 80 | strict: boolean = true 81 | ): void { 82 | oldLabel = oldLabel.replace(/^#+/, ''); // Remove leading "#" 83 | const index = this.labels.indexOf(oldLabel); 84 | if (index === -1) throw new Error('Label not found'); 85 | 86 | newLabel = newLabel.replace(/^#+/, ''); // Remove leading "#" 87 | 88 | if (!this.isValidLabel(newLabel)) { 89 | if (strict) { 90 | logger.error('Invalid new label format'); 91 | } else { 92 | const validLabel = this.validateLabel(newLabel); 93 | if (!this.isValidLabel(validLabel)) { 94 | logger.error('Invalid new label format'); 95 | } else { 96 | this.labels[index] = validLabel; 97 | } 98 | } 99 | } else { 100 | this.labels[index] = newLabel; 101 | } 102 | } 103 | 104 | // Delete a label from the labels list 105 | public deleteLabel(label: string): void { 106 | label = label.replace(/^#+/, ''); // Remove leading "#" 107 | const index = this.labels.indexOf(label); 108 | if (index === -1) throw new Error('Label not found'); 109 | this.labels.splice(index, 1); 110 | } 111 | 112 | private deleteAllLabels(): void { 113 | this.labels = []; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/taskModule/project/index.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { HSLToRGB, RGBToHEX, stringToHSL } from '../../utils/colorConverter'; 3 | import { logger } from '../../utils/log'; 4 | import { ColorPaletteManager } from '../../utils/colorPalette'; 5 | 6 | export type Project = { 7 | id: string; 8 | name: string; 9 | color?: string; 10 | }; 11 | 12 | export class ProjectModule { 13 | private projects: Map; 14 | private nameToIdMap: Map; 15 | private ColorPaletteManager: ColorPaletteManager; 16 | 17 | constructor() { 18 | 19 | this.projects = new Map(); 20 | this.nameToIdMap = new Map(); 21 | this.ColorPaletteManager = new ColorPaletteManager(); 22 | } 23 | 24 | // API to fetch the data as an array of Project objects 25 | getProjectsData(): Project[] { 26 | return [...this.projects.values()]; 27 | } 28 | 29 | private sortProjectsByName(): void { 30 | this.projects = new Map([...this.projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name))); 31 | // logger.debug(`projects are sorted: ${JSON.stringify([...this.projects.values()])}`); 32 | } 33 | 34 | 35 | // Update or create multiple projects at once 36 | updateProjects(projectData: Project[]): void { 37 | // if empty, do nothing 38 | if (!projectData || !projectData.length) { 39 | return; 40 | } 41 | for (const project of projectData) { 42 | this.updateProject(project, false); // Notice the new argument 43 | } 44 | this.sortProjectsByName(); // Sort once after updating all 45 | } 46 | 47 | 48 | // Accept update of the data using partial of the data structure 49 | // Added a new parameter to control sorting 50 | updateProject(data: Partial, sort: boolean = true): void { 51 | if (!data.name && !data.id) { 52 | return; // Empty info, don't create 53 | } 54 | 55 | let project: Project | undefined; 56 | if (data.id) { 57 | project = this.projects.get(data.id); 58 | } else if (data.name) { 59 | const id = this.nameToIdMap.get(data.name); 60 | if (id) { 61 | project = this.projects.get(id); 62 | } 63 | } 64 | 65 | if (project) { 66 | // Existing project, update 67 | const updatedProject = { ...project, ...data }; 68 | this.ensureProjectData(updatedProject); 69 | this.projects.set(updatedProject.id, updatedProject); 70 | } else { 71 | // New project, create 72 | const newProject: Project = { 73 | id: data.id || uuidv4(), 74 | name: data.name!, 75 | color: data.color || this.assignColor(data.name!) 76 | }; 77 | this.ensureProjectData(newProject); 78 | this.projects.set(newProject.id, newProject); 79 | this.nameToIdMap.set(newProject.name, newProject.id); 80 | } 81 | 82 | if (sort) { 83 | this.sortProjectsByName(); // Sort only if the sort flag is true 84 | } 85 | } 86 | 87 | 88 | // Ensure each project has all the necessary information 89 | private ensureProjectData(project: Project): void { 90 | if (!project.id) { 91 | project.id = uuidv4(); 92 | } 93 | if (!project.color) { 94 | project.color = this.assignColor(project.name); 95 | } 96 | } 97 | 98 | // Look up of the data by ID 99 | getProjectById(id: string): Project | undefined { 100 | return this.projects.get(id); 101 | } 102 | 103 | // Look up of the data by Name 104 | getProjectByName(name: string): Project | undefined { 105 | const id = this.nameToIdMap.get(name); 106 | if (id) { 107 | return this.projects.get(id); 108 | } 109 | return undefined; 110 | } 111 | 112 | // Add a new project by providing the name 113 | addProjectByName(name: string): Project | null { 114 | if (this.nameToIdMap.has(name)) { 115 | return null; // Project with the same name already exists 116 | } 117 | const id = uuidv4(); 118 | const color = this.assignColor(name); 119 | const newProject: Project = { id, name, color }; 120 | this.projects.set(id, newProject); 121 | this.nameToIdMap.set(name, id); 122 | this.sortProjectsByName(); 123 | return newProject; 124 | } 125 | 126 | addProject(project: Partial): boolean { 127 | if (!project.name && !project.id) { 128 | return false; 129 | } 130 | 131 | let existingProject: Project | undefined; 132 | if (project.id) { 133 | existingProject = this.projects.get(project.id); 134 | } else if (project.name) { 135 | const id = this.nameToIdMap.get(project.name); 136 | if (id) { 137 | existingProject = this.projects.get(id); 138 | } 139 | } 140 | 141 | if (existingProject) { 142 | return false; 143 | } else { 144 | // New project, create 145 | const newProject: Project = { 146 | id: project.id || uuidv4(), 147 | name: project.name!, 148 | color: project.color || this.assignColor(project.name) 149 | }; 150 | this.ensureProjectData(newProject); 151 | this.projects.set(newProject.id, newProject); 152 | this.nameToIdMap.set(newProject.name, newProject.id); 153 | this.sortProjectsByName(); 154 | } 155 | return true; 156 | } 157 | 158 | // Delete a project by its ID 159 | deleteProjectById(id: string): void { 160 | const project = this.projects.get(id); 161 | if (project) { 162 | this.nameToIdMap.delete(project.name); 163 | this.projects.delete(id); 164 | } 165 | } 166 | 167 | deleteProjectByName(name: string): void { 168 | const id = this.nameToIdMap.get(name); 169 | if (id) { 170 | this.deleteProjectById(id); 171 | } 172 | } 173 | 174 | assignColor(name: string): string { 175 | return this.ColorPaletteManager.assignColor(name); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/taskModule/task.ts: -------------------------------------------------------------------------------- 1 | import type { Static } from 'runtypes'; 2 | import { String } from 'runtypes'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import { Project } from './project'; 5 | import { TaskDisplayParams } from '../renderer/postProcessor'; 6 | import { SyncMappings } from '../api/externalAPIManager'; 7 | 8 | export const DateOnly = String.withConstraint((s) => 9 | /^\d{4}-\d{2}-\d{2}$/.test(s) 10 | ); 11 | export const TimeOnly = String.withConstraint((s) => /^\d{2}:\d{2}$/.test(s)); 12 | 13 | export type ScheduleDate = { 14 | isRecurring: boolean; 15 | date: Static; 16 | time?: Static | null; 17 | string?: string; 18 | timezone?: string | null; 19 | }; 20 | 21 | export type Duration = { 22 | hours: number; 23 | minutes: number; 24 | } 25 | 26 | export type SectionID = string; 27 | export type Priority = 1 | 2 | 3 | 4; 28 | export type Order = number; 29 | 30 | export interface TaskProperties { 31 | id: string; 32 | content: string; 33 | priority: Priority; 34 | description: string; 35 | order: Order | null; 36 | project: Project | null; 37 | sectionID: SectionID | null; 38 | labels: string[]; 39 | completed: boolean; 40 | 41 | parent?: TaskProperties | ObsidianTask | null; 42 | children: TaskProperties[] | ObsidianTask[]; 43 | 44 | schedule?: ScheduleDate | null; 45 | due?: ScheduleDate | null; 46 | duration?: Duration | null; 47 | metadata?: { 48 | taskDisplayParams?: TaskDisplayParams | null; 49 | syncMappings?: SyncMappings; 50 | [key: string]: any; 51 | }; 52 | } 53 | 54 | export class ObsidianTask implements TaskProperties { 55 | public id: string; 56 | public content: string; 57 | public priority: Priority | null; 58 | public description: string; 59 | public order: Order | null; 60 | public project: Project | null; 61 | public sectionID: SectionID | null; 62 | public labels: string[]; 63 | public completed: boolean; 64 | 65 | public parent?: TaskProperties | ObsidianTask | null; 66 | public children: TaskProperties[] | ObsidianTask[]; 67 | 68 | public schedule?: ScheduleDate | null; 69 | public due?: ScheduleDate | null; 70 | public duration?: Duration | null; 71 | 72 | public metadata?: { 73 | taskDisplayParams?: TaskDisplayParams | null; 74 | syncMappings?: SyncMappings | null; 75 | [key: string]: any; 76 | }; 77 | 78 | constructor(props?: Partial) { 79 | this.id = props?.id || uuidv4(); 80 | this.content = props?.content || ''; 81 | this.priority = props?.priority || 4; 82 | this.description = props?.description || ''; 83 | this.order = props?.order || 0; 84 | this.project = props?.project || { id: '', name: '' }; 85 | this.sectionID = props?.sectionID || ''; 86 | this.labels = props?.labels || []; 87 | this.completed = props?.completed || false; 88 | this.parent = props?.parent || null; 89 | this.children = props?.children || []; 90 | this.schedule = props?.schedule || null; 91 | this.due = props?.due || null; 92 | this.duration = props?.duration || null; 93 | this.metadata = props?.metadata || {}; 94 | } 95 | 96 | getCopy(): ObsidianTask { 97 | return new ObsidianTask({ 98 | id: this.id, 99 | content: this.content, 100 | priority: this.priority, 101 | description: this.description, 102 | order: this.order, 103 | project: this.project, 104 | sectionID: this.sectionID, 105 | labels: this.labels, 106 | completed: this.completed, 107 | parent: this.parent, 108 | children: this.children, 109 | schedule: this.schedule, 110 | due: this.due, 111 | duration: this.duration, 112 | metadata: this.metadata, 113 | }); 114 | } 115 | 116 | hasDescription() { 117 | return this.description.length > 0; 118 | } 119 | 120 | hasProject() { 121 | return this.project !== null && this.project.name.length > 0; 122 | } 123 | 124 | hasAnyLabels() { 125 | return this.labels.length > 0; 126 | } 127 | 128 | isCompleted() { 129 | return this.completed; 130 | } 131 | 132 | hasParent(): boolean { 133 | return this.parent !== null; 134 | } 135 | 136 | hasChildren(): boolean { 137 | return this.children.length > 0; 138 | } 139 | 140 | hasSchedule(): boolean { 141 | if (!this.schedule) return false; 142 | // return if the schedule string is not empty 143 | return !!this.schedule.string.trim(); 144 | } 145 | 146 | hasDue(): boolean { 147 | if (!this.due) return false; 148 | // return if the due string is not empty 149 | return !!this.due.string.trim(); 150 | } 151 | 152 | hasDuration(): boolean { 153 | if (!this.duration) return false; 154 | // return if the duration string is not empty = hours and minutes all zero 155 | return this.duration.hours > 0 || this.duration.minutes > 0; 156 | } 157 | 158 | setTaskDisplayParams(key: string, value: any): void { 159 | this.metadata.taskDisplayParams = { 160 | ...this.metadata.taskDisplayParams, 161 | [key]: value 162 | }; 163 | } 164 | 165 | clearTaskDisplayParams(): void { 166 | this.metadata.taskDisplayParams = null; 167 | } 168 | 169 | toTaskProps(): TaskProperties { 170 | return this; 171 | } 172 | 173 | } 174 | 175 | 176 | export interface TextPosition { 177 | line: number; 178 | col: number; 179 | } 180 | 181 | export interface DocPosition { 182 | filePath: string, 183 | start: TextPosition, 184 | end: TextPosition, 185 | } 186 | 187 | 188 | export interface PositionedTaskProperties extends TaskProperties { 189 | docPosition: DocPosition; 190 | } 191 | 192 | 193 | export class PositionedObsidianTask extends ObsidianTask implements PositionedTaskProperties { 194 | public docPosition: DocPosition; 195 | 196 | constructor(props?: Partial) { 197 | super(props); 198 | this.docPosition = props?.docPosition || { filePath: '', start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }; 199 | } 200 | 201 | toPositionedTaskProps(): PositionedTaskProperties { 202 | return { 203 | ...this.toTaskProps(), 204 | docPosition: this.docPosition 205 | }; 206 | } 207 | 208 | static fromObsidianTaskAndDocPosition(task: ObsidianTask, position: DocPosition): PositionedTaskProperties { 209 | return new PositionedObsidianTask({ 210 | ...(task.toTaskProps() as Partial), 211 | docPosition: position 212 | }); 213 | } 214 | 215 | toObsidianTask(): ObsidianTask { 216 | const { docPosition, ...taskProps } = this.toPositionedTaskProps(); 217 | return new ObsidianTask(taskProps as Partial); 218 | } 219 | 220 | toDocPosition(): DocPosition { 221 | return this.docPosition; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/taskModule/taskAPI.ts: -------------------------------------------------------------------------------- 1 | import { ObsidianTask } from "./task"; 2 | 3 | 4 | export enum TaskChangeType { 5 | ADD = 'add', 6 | UPDATE = 'update', 7 | REMOVE = 'remove', 8 | } 9 | 10 | 11 | export interface TaskChangeEvent { 12 | taskId: string; 13 | type: TaskChangeType; 14 | previousState?: ObsidianTask; 15 | currentState?: ObsidianTask; 16 | timestamp: Date; 17 | } 18 | 19 | 20 | export class TaskChangeAPI { 21 | private listeners: Array<(event: TaskChangeEvent) => void> = []; 22 | 23 | registerListener(listener: (event: TaskChangeEvent) => void): void { 24 | this.listeners.push(listener); 25 | } 26 | 27 | private notifyListeners(event: TaskChangeEvent): void { 28 | this.listeners.forEach(listener => listener(event)); 29 | } 30 | 31 | // Use this method to record and notify about changes 32 | recordChange(event: TaskChangeEvent): void { 33 | // Logic to push change to the listeners 34 | 35 | // Notify listeners 36 | this.notifyListeners(event); 37 | } 38 | } 39 | 40 | 41 | export function getUpdatedProperties(oldObj: T, newObj: T): Partial { 42 | const updatedProperties: Partial = {}; 43 | 44 | for (const key in oldObj) { 45 | if (oldObj[key] !== newObj[key]) { 46 | updatedProperties[key] = newObj[key]; 47 | } 48 | } 49 | 50 | return updatedProperties; 51 | } -------------------------------------------------------------------------------- /src/taskModule/taskFormatter.ts: -------------------------------------------------------------------------------- 1 | import { TaskDisplayMode } from '../renderer/postProcessor'; 2 | import { SettingStore } from '../settings'; 3 | import { logger } from '../utils/log'; 4 | import { camelToKebab } from '../utils/stringCaseConverter'; 5 | import { ObsidianTask } from './task'; 6 | 7 | export class TaskFormatter { 8 | indicatorTag: string; 9 | markdownSuffix: string; 10 | defaultMode: string; 11 | specialAttributes: string[] = ['completed', 'content', 'labels', 'description']; 12 | 13 | constructor(settingsStore: typeof SettingStore) { 14 | // Subscribe to the settings store 15 | settingsStore.subscribe((settings) => { 16 | this.indicatorTag = settings.parsingSettings.indicatorTag; 17 | this.markdownSuffix = settings.parsingSettings.markdownSuffix; 18 | this.defaultMode = settings.displaySettings.defaultMode; 19 | }); 20 | } 21 | 22 | taskToMarkdown(task: ObsidianTask): string { 23 | const taskPrefix = `- [${task.completed ? 'x' : ' '}]`; 24 | const labelMarkdown = task.labels.join(' '); 25 | let taskMarkdown = `${taskPrefix} ${task.content} ${labelMarkdown} #${this.indicatorTag}`; 26 | taskMarkdown = taskMarkdown.replace(/\s+/g, ' '); // remove multiple spaces 27 | 28 | // Initialize an empty object to hold all attributes 29 | const allAttributes: { [key: string]: any } = {}; 30 | 31 | // Iterate over keys in task, but exclude special attributes 32 | for (let key in task) { 33 | if (this.specialAttributes.includes(key)) continue; 34 | 35 | let value = task[key]; 36 | if (value === undefined) { 37 | value = null; 38 | } 39 | allAttributes[key] = value; 40 | } 41 | 42 | // Add the attributes object to the markdown line 43 | taskMarkdown += `${JSON.stringify(allAttributes)}`; 44 | 45 | // add suffix to task content 46 | taskMarkdown += this.markdownSuffix; 47 | 48 | // Add description 49 | if (task.description.length > 0) { 50 | // for each line, add 4 spaces 51 | taskMarkdown += `\n ${task.description.replace(/\n/g, '\n ')}`; 52 | } 53 | 54 | return taskMarkdown; 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/taskModule/taskSyncManager.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownPostProcessorContext, MarkdownSectionInformation } from 'obsidian'; 2 | import { ObsidianTask } from './task'; 3 | import TaskCardPlugin from '..'; 4 | import { logger } from '../utils/log'; 5 | 6 | 7 | type TaskCardStatus = { 8 | descriptionStatus: 'editing' | 'done'; 9 | projectStatus: 'selecting' | 'done'; 10 | scheduleStatus: 'editing' | 'done'; 11 | dueStatus: 'editing' | 'done'; 12 | durationStatus: 'editing' | 'done'; 13 | }; 14 | 15 | export interface ObsidianTaskSyncProps { 16 | obsidianTask: ObsidianTask; // typescript class for the ObsidianTask 17 | taskCardStatus: TaskCardStatus; 18 | taskItemEl: HTMLElement | null; // the HTML element to represent the task 19 | taskMetadata: { 20 | // metadata about the task, position in a file. 21 | sectionEl: HTMLElement; 22 | ctx: MarkdownPostProcessorContext; 23 | sourcePath: string; 24 | mdSectionInfo: MarkdownSectionInformation | null; 25 | lineStartInSection: number | null; 26 | lineEndsInSection: number | null; 27 | }; 28 | } 29 | 30 | export class ObsidianTaskSyncManager implements ObsidianTaskSyncProps { 31 | public obsidianTask: ObsidianTask; 32 | public taskCardStatus: TaskCardStatus; 33 | public taskItemEl: HTMLElement | null; 34 | public taskMetadata: { 35 | sectionEl: HTMLElement; 36 | ctx: MarkdownPostProcessorContext; 37 | sourcePath: string; 38 | mdSectionInfo: MarkdownSectionInformation | null; 39 | lineStartInSection: number | null; 40 | lineEndsInSection: number | null; 41 | }; 42 | public plugin: TaskCardPlugin; 43 | 44 | constructor(plugin: TaskCardPlugin, props?: Partial) { 45 | // this.markdownTask = props?.markdownTask || null; 46 | this.obsidianTask = props?.obsidianTask || new ObsidianTask(); 47 | this.taskCardStatus = props?.taskCardStatus || { 48 | descriptionStatus: 'done', 49 | projectStatus: 'done', 50 | scheduleStatus: 'done', 51 | dueStatus: 'done', 52 | durationStatus: 'done', 53 | }; 54 | this.taskItemEl = props?.taskItemEl || null; 55 | this.taskMetadata = props?.taskMetadata || { 56 | sectionEl: null, 57 | ctx: null, 58 | sourcePath: null, 59 | mdSectionInfo: null, 60 | lineStartInSection: null, 61 | lineEndsInSection: null 62 | }; 63 | this.plugin = plugin; 64 | } 65 | 66 | refreshMetadata(): void { 67 | this.taskMetadata.mdSectionInfo = this.taskMetadata.ctx.getSectionInfo(this.taskMetadata.sectionEl); 68 | } 69 | 70 | getDocLineStartEnd(): [number, number] { 71 | this.refreshMetadata(); 72 | return [ 73 | this.taskMetadata.lineStartInSection + this.taskMetadata.mdSectionInfo.lineStart, 74 | this.taskMetadata.lineEndsInSection + this.taskMetadata.mdSectionInfo.lineStart 75 | ]; 76 | } 77 | 78 | async getMarkdownTaskFromFile(): Promise { 79 | // use the taskMetadata to get the file content for the task 80 | const [docLineStart, docLineEnd] = this.getDocLineStartEnd(); 81 | const markdownTask = await this.plugin.fileOperator.getMarkdownBetweenLines( 82 | this.taskMetadata.sourcePath, 83 | docLineStart, 84 | docLineEnd 85 | ); 86 | return markdownTask; 87 | } 88 | 89 | async updateMarkdownTaskToFile(markdownTask: string): Promise { 90 | const [docLineStart, docLineEnd] = this.getDocLineStartEnd(); 91 | await this.plugin.fileOperator.updateFile( 92 | this.taskMetadata.sourcePath, 93 | markdownTask, 94 | docLineStart, 95 | docLineEnd 96 | ); 97 | } 98 | 99 | async updateObsidianTaskAttribute(key: string, value: any): Promise { 100 | const origTask = this.obsidianTask.getCopy(); 101 | this.obsidianTask[key] = value; 102 | const newTask = this.obsidianTask.getCopy(); 103 | logger.info(`successfully set ${key} to ${value}`); 104 | const syncMetadata = await this.plugin.externalAPIManager.updateTask(newTask, origTask); 105 | this.obsidianTask.metadata.syncMappings = syncMetadata; 106 | this.updateTaskToFile(); 107 | } 108 | 109 | updateObsidianTaskDisplayParams(key: string, value: any): void { 110 | this.obsidianTask.setTaskDisplayParams(key, value); 111 | this.updateTaskToFile(); 112 | } 113 | 114 | clearObsidianTaskDisplayParams(): void { 115 | this.obsidianTask.clearTaskDisplayParams(); 116 | this.updateTaskToFile(); 117 | } 118 | 119 | updateTaskToFile(): void { 120 | const markdownTask = this.plugin.taskFormatter.taskToMarkdown( 121 | this.obsidianTask 122 | ); 123 | this.updateMarkdownTaskToFile(markdownTask); 124 | } 125 | 126 | isValidStatus(key: keyof TaskCardStatus, status: string): boolean { 127 | const allowedStatuses = { 128 | descriptionStatus: ['editing', 'done'], 129 | projectStatus: ['selecting', 'done'], 130 | scheduleStatus: ['editing', 'done'], 131 | dueStatus: ['editing', 'done'], 132 | durationStatus: ['editing', 'done'] 133 | }; 134 | return allowedStatuses[key].includes(status); 135 | } 136 | 137 | setTaskCardStatus(key: keyof TaskCardStatus, status: string): void { 138 | // check if the status is valid 139 | if (!this.isValidStatus(key, status)) return; 140 | this.taskCardStatus[key] = status as any; 141 | } 142 | 143 | getTaskCardStatus(key: keyof TaskCardStatus): string { 144 | return this.taskCardStatus[key]; 145 | } 146 | 147 | async deleteTask(): Promise { 148 | const [docLineStart, docLineEnd] = this.getDocLineStartEnd(); 149 | await this.plugin.fileOperator.updateFile( 150 | this.taskMetadata.sourcePath, 151 | '', 152 | docLineStart, 153 | docLineEnd 154 | ); 155 | 156 | this.plugin.externalAPIManager.deleteTask(this.obsidianTask); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/ui/Content.svelte: -------------------------------------------------------------------------------- 1 | 60 | 61 | 62 | {#if isEditing} 63 | 68 | {:else} 69 | 77 | {content} 78 | 79 | {/if} 80 | 81 | -------------------------------------------------------------------------------- /src/ui/LabelInput.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | # 21 | 22 | finishLabelEditing(event, newLabel)} 28 | /> 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/ui/Labels.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 87 | 88 | 89 | 90 | {#each labelModule.getLabels() as label, index} 91 | {#if editingIndex === index} 92 | 97 | {:else} 98 | 99 | showPopupMenu(index, e)} 105 | 106 | > 107 | {label} 108 | 109 | 110 | {/if} 111 | {#if label !== labelModule.getLabels()[labelModule.getLabels().length - 1]}{" "}{/if} 112 | {/each} 113 | {#if isAddingLabel} 114 | 119 | {:else} 120 | 121 | 122 | 123 | {/if} 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /src/ui/QueryDisplay.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | {#if !cacheInitialized} 27 | 28 | Task Card Query Failed 29 | Tasks Not Fully Indexed. Please make sure that the dataview plugin is also enabled in Obsidian. This is necessary for this feature to work properly 30 | 31 | {:else if taskList.length > 0} 32 | {#if displayMode === "list"} 33 | 34 | {:else if displayMode === "matrix"} 35 | 36 | {/if} 37 | {:else} 38 | 39 | No Tasks Found 40 | It looks like there are no tasks that match your filter. 41 | 42 | {/if} 43 | 44 | 45 | TaskCard Query: {taskList.length} / {querySyncManager.plugin.cache.taskCache.getLength()} tasks. 46 | Edit 47 | 48 | 49 | 50 | 84 | -------------------------------------------------------------------------------- /src/ui/StaticTaskItem.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 26 | -------------------------------------------------------------------------------- /src/ui/StaticTaskList.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | {#each taskList as taskItem} 13 | 14 | {/each} 15 | 16 | 17 | 19 | -------------------------------------------------------------------------------- /src/ui/StaticTaskMatrix.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 | 51 | 52 | 53 | 54 | Do 55 | 56 | 57 | 58 | 59 | 60 | 61 | {#each taskList as taskItem} 62 | {#if categorizeTasks(taskItem) === "do"} 63 | 64 | {/if} 65 | {/each} 66 | 67 | 68 | 69 | 70 | 71 | Plan 72 | 73 | 74 | 75 | 76 | 77 | 78 | {#each taskList as taskItem} 79 | {#if categorizeTasks(taskItem) === "plan"} 80 | 81 | {/if} 82 | {/each} 83 | 84 | 85 | 86 | 87 | 88 | Delegate 89 | 90 | 91 | 92 | 93 | 94 | 95 | {#each taskList as taskItem} 96 | {#if categorizeTasks(taskItem) === "delegate"} 97 | 98 | {/if} 99 | {/each} 100 | 101 | 102 | 103 | 104 | 105 | Delete 106 | 107 | 108 | 109 | 110 | 111 | 112 | {#each taskList as taskItem} 113 | {#if categorizeTasks(taskItem) === "delete"} 114 | 115 | {/if} 116 | {/each} 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 210 | -------------------------------------------------------------------------------- /src/ui/SyncLogos.svelte: -------------------------------------------------------------------------------- 1 | 2 | 36 | 37 | 38 | {#if logoList.includes('google')} 39 | 40 | 41 | 46 | 47 | {/if} 48 | 49 | -------------------------------------------------------------------------------- /src/ui/TaskItem.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 47 | 48 | {#if params.mode === "single-line"} 49 | 50 | 56 | 61 | 62 | 63 | {:else} 64 | 68 | 73 | 74 | {/if} 75 | -------------------------------------------------------------------------------- /src/ui/calendar/Calendar.svelte: -------------------------------------------------------------------------------- 1 | 78 | -------------------------------------------------------------------------------- /src/ui/selections/FixedOptionsMultiSelect.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 44 | 45 | 46 | 47 | 48 | 49 | {title} 50 | {description} 51 | 52 | 53 | 54 | {#each choices as choice (choice.value)} 55 | toggleTag(choice.value, evt)} 58 | on:keydown={(evt) => toggleTag(choice.value, evt)}> 59 | {@html choice.displayText || choice.displayHTML} 60 | 61 | {/each} 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/ui/selections/FixedOptionsSelect.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | 38 | 39 | 40 | 41 | {title} 42 | {description} 43 | 44 | 45 | 46 | {#each choices as choice (choice.value)} 47 | selectTag(choice.value, evt)} 50 | on:keydown={(evt) => selectTag(choice.value, evt)}> 51 | {@html choice.displayText || choice.displayHTML} 52 | 53 | {/each} 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/ui/selections/ProjectSelection.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 39 | 40 | 41 | 42 | 43 | 44 | {title} 45 | {description} 46 | 47 | 48 | 49 | {#each choices as choice} 50 | toggleTag(choice.name, evt)} 53 | on:keydown={(evt) => toggleTag(choice.name, evt)}> 54 | 55 | 56 | {choice.name} 57 | 58 | 62 | 63 | 64 | {/each} 65 | 66 | 67 | 68 | 69 | 70 | 154 | -------------------------------------------------------------------------------- /src/ui/selections/TagSelect.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 39 | 40 | 41 | 42 | 43 | 44 | {title} 45 | {description} 46 | 47 | 48 | 49 | {#each choices as choice} 50 | toggleTag(choice, evt)} 53 | on:keydown={(evt) => toggleTag(choice, evt)} 54 | > 55 | {choice} 56 | 57 | {/each} 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/utils/colorConverter.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../utils/log'; 2 | 3 | type RGB = { r: number; g: number; b: number }; 4 | type RGBA = { r: number; g: number; b: number; a: number }; 5 | type HSL = { h: number; s: number; l: number }; 6 | type HEX = `#${string}`; 7 | 8 | type AvailableColor = RGB | RGBA | HEX; 9 | 10 | export function HEXToRGB(HEX: HEX): RGB { 11 | if (!validHEX(HEX)) return null; 12 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(HEX); 13 | return result 14 | ? { 15 | r: parseInt(result[1], 16), 16 | g: parseInt(result[2], 16), 17 | b: parseInt(result[3], 16) 18 | } 19 | : null; 20 | } 21 | 22 | export function RGBToHEX(RGB: RGB): HEX { 23 | if (!validRGB(RGB)) return null; 24 | const r = RGB.r.toString(16).padStart(2, '0'); 25 | const g = RGB.g.toString(16).padStart(2, '0'); 26 | const b = RGB.b.toString(16).padStart(2, '0'); 27 | return `#${r}${g}${b}`; 28 | } 29 | 30 | export function darkenRGBColor(RGB: RGB, darkenPercent: number): RGB { 31 | if (!validRGB(RGB)) return RGB; 32 | let factor = 1 - darkenPercent; 33 | if (factor < 0) factor = 0; 34 | return { 35 | r: Math.floor(RGB.r * factor), 36 | g: Math.floor(RGB.g * factor), 37 | b: Math.floor(RGB.b * factor) 38 | }; 39 | } 40 | 41 | export function darkenHEXColor(HEX: HEX, darkenPercent: number): HEX { 42 | if (!validHEX(HEX)) return HEX; 43 | const RGBColor = HEXToRGB(HEX); 44 | return RGBToHEX(darkenRGBColor(RGBColor, darkenPercent)); 45 | } 46 | 47 | function validRGB(RGB: RGB): boolean { 48 | try { 49 | return RGB.r >= 0 && RGB.g >= 0 && RGB.b >= 0; 50 | } catch (e) { 51 | return false; 52 | } 53 | } 54 | 55 | function validHEX(HEX: HEX): boolean { 56 | try { 57 | return /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(HEX); 58 | } catch (e) { 59 | return false; 60 | } 61 | } 62 | 63 | export function stringToColor(str) { 64 | var hash = 0; 65 | for (var i = 0; i < str.length; i++) { 66 | hash = str.charCodeAt(i) + ((hash << 5) - hash); 67 | } 68 | var color = '#'; 69 | for (var i = 0; i < 3; i++) { 70 | var value = (hash >> (i * 8)) & 0xff; 71 | color += ('00' + value.toString(16)).slice(-2); 72 | } 73 | return color; 74 | } 75 | 76 | export function stringToHSL(str: string, saturation = 50, lightness = 50): HSL { 77 | let hash = 0; 78 | for (let i = 0; i < str.length; i++) { 79 | hash = str.charCodeAt(i) + ((hash << 5) - hash); 80 | } 81 | 82 | const hue = ((hash % 360) + 360) % 360; // Force hue to be a non-negative integer between 0 and 359 83 | 84 | return { h: hue, s: saturation, l: lightness }; 85 | } 86 | 87 | export function HSLToRGB(HSL: HSL): RGB { 88 | let h = HSL.h / 360; 89 | let s = HSL.s / 100; 90 | let l = HSL.l / 100; 91 | let r, g, b; 92 | 93 | if (s === 0) { 94 | r = g = b = l; 95 | } else { 96 | const hue2RGB = (p, q, t) => { 97 | if (t < 0) t += 1; 98 | if (t > 1) t -= 1; 99 | if (t < 1 / 6) return p + (q - p) * 6 * t; 100 | if (t < 1 / 2) return q; 101 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 102 | return p; 103 | }; 104 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 105 | const p = 2 * l - q; 106 | r = hue2RGB(p, q, h + 1 / 3); 107 | g = hue2RGB(p, q, h); 108 | b = hue2RGB(p, q, h - 1 / 3); 109 | } 110 | 111 | return { 112 | r: Math.round(r * 255), 113 | g: Math.round(g * 255), 114 | b: Math.round(b * 255) 115 | }; 116 | } 117 | 118 | export function RGBToHSL(rgb: RGB): HSL { 119 | const r = rgb.r / 255; 120 | const g = rgb.g / 255; 121 | const b = rgb.b / 255; 122 | const max = Math.max(r, g, b); 123 | const min = Math.min(r, g, b); 124 | let h, 125 | s, 126 | l = (max + min) / 2; 127 | 128 | if (max === min) { 129 | h = s = 0; 130 | } else { 131 | const d = max - min; 132 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 133 | switch (max) { 134 | case r: 135 | h = (g - b) / d + (g < b ? 6 : 0); 136 | break; 137 | case g: 138 | h = (b - r) / d + 2; 139 | break; 140 | case b: 141 | h = (r - g) / d + 4; 142 | break; 143 | } 144 | h /= 6; 145 | } 146 | 147 | return { h: h * 360, s: s * 100, l: l * 100 }; 148 | } 149 | -------------------------------------------------------------------------------- /src/utils/colorPalette.ts: -------------------------------------------------------------------------------- 1 | import { HSLToRGB, RGBToHEX, stringToHSL } from "./colorConverter"; 2 | 3 | const HUSLColor15 = ['#f67088', 4 | '#f37932', 5 | '#ca9131', 6 | '#ad9c31', 7 | '#8ea531', 8 | '#4fb031', 9 | '#33b07a', 10 | '#34ad99', 11 | '#36abae', 12 | '#38a8c5', 13 | '#3ba3ec', 14 | '#9491f4', 15 | '#cc79f4', 16 | '#f45fe3', 17 | '#f569b7']; 18 | export class ColorPaletteManager { 19 | private colorPalette: string[]; 20 | private usedColors: Set; 21 | private lastAssignedIndex: number | null; 22 | 23 | constructor(initialColors: string[] = HUSLColor15) { 24 | this.colorPalette = initialColors; 25 | this.usedColors = new Set(); 26 | this.lastAssignedIndex = null; 27 | } 28 | 29 | assignColor(name: string): string { 30 | let startIndex = this.lastAssignedIndex !== null 31 | ? (this.lastAssignedIndex + Math.floor(this.colorPalette.length / 2)) % this.colorPalette.length 32 | : 0; 33 | 34 | for (let i = 0; i < this.colorPalette.length; i++) { 35 | const index = (startIndex + i) % this.colorPalette.length; 36 | const color = this.colorPalette[index]; 37 | 38 | if (!this.usedColors.has(color)) { 39 | this.usedColors.add(color); 40 | this.lastAssignedIndex = index; 41 | return color; 42 | } 43 | } 44 | 45 | // All colors are used, handle this situation 46 | return this.stringToColor(name); 47 | } 48 | 49 | releaseColor(color: string): void { 50 | this.usedColors.delete(color); 51 | } 52 | 53 | // Optional: For generating random colors when palette is exhausted 54 | generateRandomColor(): string { 55 | const letters = '0123456789ABCDEF'; 56 | let color = '#'; 57 | for (let i = 0; i < 6; i++) { 58 | color += letters[Math.floor(Math.random() * 16)]; 59 | } 60 | return color; 61 | } 62 | 63 | stringToColor(str: string): string { 64 | return RGBToHEX(HSLToRGB(stringToHSL(str))) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/dateTimeFormatter.ts: -------------------------------------------------------------------------------- 1 | import { DateOnly, TimeOnly } from '../taskModule/task'; 2 | import { logger } from '../utils/log'; 3 | 4 | export function displayDate(date: string): string { 5 | // the date string is local time. 6 | // if (!DateOnly.check(date)) return date; 7 | try { 8 | DateOnly.check(date); 9 | } catch (e) { 10 | return date; 11 | } 12 | 13 | // Convert UTC date to local time 14 | const inputDate = new Date(date); 15 | inputDate.setHours(0, 0, 0, 0); 16 | inputDate.setDate(inputDate.getDate() + 1); 17 | 18 | const currentDate = new Date(); 19 | 20 | // Format the date as "Mon date" or "Mon date, year" 21 | let month = inputDate.toLocaleString('default', { month: 'short' }); 22 | let datePart = inputDate.getDate(); 23 | if (inputDate.getFullYear() !== currentDate.getFullYear()) { 24 | let year = inputDate.getFullYear(); 25 | return `${month} ${datePart}, ${year}`; 26 | } 27 | 28 | // Check for yesterday, today, and tomorrow 29 | if (isYesterday(inputDate)) { 30 | return 'Yesterday'; 31 | } 32 | if (isToday(inputDate)) { 33 | return 'Today'; 34 | } 35 | if (isTomorrow(inputDate)) { 36 | return 'Tomorrow'; 37 | } 38 | 39 | // Adjust the logic to determine if the date is within the current week, starting from Sun to Sat 40 | if (isSameWeek(inputDate, 0) === true) { 41 | return inputDate.toLocaleString('default', { weekday: 'short' }); 42 | } 43 | 44 | return `${month} ${datePart}`; 45 | } 46 | 47 | export function displayTime(time: string): string { 48 | // the date string is local time. 49 | try { 50 | TimeOnly.check(time); 51 | } catch (e) { 52 | return time; 53 | } 54 | try { 55 | let [hours, minutes] = time.split(':'); 56 | return `${parseInt(hours) % 12 || 12}:${minutes} ${ 57 | parseInt(hours) >= 12 ? 'PM' : 'AM' 58 | }`; 59 | } catch (e) { 60 | return time; 61 | } 62 | } 63 | 64 | export function isYesterday(date: Date): boolean { 65 | const today = new Date(); 66 | const yesterday = new Date(today); 67 | yesterday.setDate(yesterday.getDate() - 1); 68 | return ( 69 | date.getFullYear() === yesterday.getFullYear() && 70 | date.getMonth() === yesterday.getMonth() && 71 | date.getDate() === yesterday.getDate() 72 | ); 73 | } 74 | 75 | export function isToday(date: Date): boolean { 76 | const today = new Date(); 77 | return ( 78 | date.getFullYear() === today.getFullYear() && 79 | date.getMonth() === today.getMonth() && 80 | date.getDate() === today.getDate() 81 | ); 82 | } 83 | 84 | export function isTomorrow(date: Date): boolean { 85 | const today = new Date(); 86 | const tomorrow = new Date(today); 87 | tomorrow.setDate(tomorrow.getDate() + 1); 88 | return ( 89 | date.getFullYear() === tomorrow.getFullYear() && 90 | date.getMonth() === tomorrow.getMonth() && 91 | date.getDate() === tomorrow.getDate() 92 | ); 93 | } 94 | 95 | export function isSameWeek(date: Date, weekStart: number = 0): boolean { 96 | const today = new Date(); 97 | const inputDate = new Date(date); // Copy the input date to avoid modifying the original 98 | 99 | // Normalize both dates to the start of the week, based on the weekStart value 100 | today.setDate(today.getDate() - ((today.getDay() - weekStart + 7) % 7)); 101 | inputDate.setDate( 102 | inputDate.getDate() - ((inputDate.getDay() - weekStart + 7) % 7) 103 | ); 104 | 105 | // Compare the normalized dates 106 | return ( 107 | today.getFullYear() === inputDate.getFullYear() && 108 | today.getMonth() === inputDate.getMonth() && 109 | today.getDate() === inputDate.getDate() 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/utils/filePathSuggester.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | export function filePathSuggest(userInput: string, paths: string[], caseSensitive: boolean = false): string[] { 5 | if (userInput.length === 0) return []; 6 | 7 | // Step 1: Calculate relevance and specificity for each path 8 | const scoredPaths = paths.map((path) => { 9 | let maxOverlap = 0; 10 | let specificity = 0; 11 | 12 | const comparePath = caseSensitive ? path : path.toLowerCase(); // Use case-sensitive or insensitive path 13 | const compareInput = caseSensitive ? userInput : userInput.toLowerCase(); // Use case-sensitive or insensitive user input 14 | 15 | for (let i = 0; i <= comparePath.length - compareInput.length; i++) { 16 | const substring = comparePath.substring(i, i + compareInput.length); 17 | 18 | if (substring === compareInput) { 19 | maxOverlap = compareInput.length; 20 | specificity = comparePath.length - (i + compareInput.length); 21 | break; 22 | } 23 | } 24 | 25 | return { path, maxOverlap, specificity }; 26 | }); 27 | 28 | // Step 1.5: Filter out paths that don't have a full match 29 | const filteredPaths = scoredPaths.filter((scoredPath) => scoredPath.maxOverlap === (caseSensitive ? userInput : userInput.toLowerCase()).length); 30 | 31 | // Step 2: Sort by relevance and then by specificity 32 | filteredPaths.sort((a, b) => { 33 | if (b.maxOverlap !== a.maxOverlap) { 34 | return b.maxOverlap - a.maxOverlap; 35 | } 36 | return a.specificity - b.specificity; 37 | }); 38 | 39 | // Step 3: Extract the sorted paths with their original case 40 | const sortedPaths = filteredPaths.map((scoredPath) => scoredPath.path); 41 | 42 | return sortedPaths; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/log.ts: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const { format, transports } = winston; 3 | const path = require('path'); 4 | 5 | const loggerConfiguration = { 6 | transports: [new winston.transports.Console()], 7 | level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', 8 | format: format.combine( 9 | format.label({ label: 'Obsidian Task Card' }), 10 | format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 11 | format.splat(), 12 | format.simple(), 13 | format.printf((info) => { 14 | const { timestamp, label, level, message, ...metadata } = info; 15 | let msg = `${timestamp} [${label}] ${level}: ${message} `; 16 | if (Object.keys(metadata).length > 0) { 17 | msg += JSON.stringify(metadata); 18 | } 19 | return msg; 20 | }) 21 | ) 22 | }; 23 | 24 | export const logger = winston.createLogger(loggerConfiguration); 25 | -------------------------------------------------------------------------------- /src/utils/markdownToHTML.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./log"; 2 | 3 | 4 | var showdown = require('showdown'); 5 | 6 | export function markdownToHTML(markdown: string) { 7 | const converter = new showdown.Converter(); 8 | const html = converter.makeHtml(markdown); 9 | logger.debug(`markdown: ${markdown}`); 10 | logger.debug(`html: ${html}`); 11 | return html; 12 | 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/utils/regexUtils.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../utils/log'; 2 | 3 | export function escapeRegExp(string: string) { 4 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 5 | } 6 | 7 | export function extractTags(text: string): [string[], string] { 8 | // Regular expression to detect valid tags based on the provided rules 9 | const tagRegex = /#([a-zA-Z_/-]+[a-zA-Z0-9_/-]*|[a-zA-Z_/-][a-zA-Z0-9_/-]+)/g; 10 | let matches = text.match(tagRegex) || []; 11 | if (matches.length === 0) { return [[], text]; } 12 | // for the matches, get the label part (remove the #) 13 | let labels = matches.map((match) => match.substring(1)); 14 | 15 | // Remove the tags from the content and then trim any consecutive spaces greater than 2 16 | const remainingText = text 17 | .replace( 18 | /(\s?)#([a-zA-Z_/-]+[a-zA-Z0-9_/-]*|[a-zA-Z_/-][a-zA-Z0-9_/-]+)(\s?)/g, 19 | ' ' 20 | ) 21 | .trim(); 22 | 23 | labels = labels.map(label => { 24 | // Remove all leading '#' characters 25 | const cleanedLabel = label.replace(/^#+/, ''); 26 | // Add a single '#' at the beginning 27 | return '#' + cleanedLabel; 28 | }); 29 | 30 | return [labels, remainingText]; 31 | } 32 | 33 | /** 34 | * Get the starting index of a matching group within a given string. 35 | * 36 | * @param {string} text - The string to match against. 37 | * @param {RegExp} regex - The regular expression containing the match groups. 38 | * @param {number} groupIndex - The index of the desired match group. 39 | * @returns {number|null} The index in the string where the desired match group starts, or null if not found. 40 | */ 41 | export function getGroupStartIndex( 42 | text: string, 43 | regex: RegExp, 44 | groupIndex: number 45 | ): number | null { 46 | const match = regex.exec(text); 47 | if (match && match[groupIndex]) { 48 | const fullMatchIndex = match.index; 49 | return fullMatchIndex + match[0].indexOf(match[groupIndex]); 50 | } 51 | return null; 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/stringCaseConverter.ts: -------------------------------------------------------------------------------- 1 | // List of special words 2 | const specialWords = ['ID', 'API']; 3 | 4 | // Helper function to convert camelCase to kebab-case 5 | export function camelToKebab(camelCase: string) { 6 | let result = camelCase; 7 | 8 | // Replace special words first 9 | for (let specialWord of specialWords) { 10 | const regex = new RegExp(specialWord, 'g'); 11 | result = result.replace(regex, `-${specialWord.toLowerCase()}`); 12 | } 13 | 14 | // Replace rest of the uppercase letters 15 | result = result.replace(/([A-Z])/g, (letter) => `-${letter.toLowerCase()}`); 16 | 17 | // If the string starts with a hyphen, remove it 18 | result = result.replace(/^-/, ''); 19 | 20 | return result; 21 | } 22 | 23 | // Helper function to convert kebab-case to camelCase 24 | export function kebabToCamel(kebabCase: string) { 25 | let result = kebabCase; 26 | 27 | // Replace special words first 28 | for (let specialWord of specialWords) { 29 | const regex = new RegExp(`-${specialWord.toLowerCase()}`, 'g'); 30 | result = result.replace(regex, specialWord); 31 | } 32 | 33 | // Replace rest of the hyphenated letters 34 | result = result.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); 35 | 36 | return result; 37 | } 38 | 39 | // Convert a word + space structure to camelCase 40 | export function toCamelCase(str: string): string { 41 | return str 42 | .trim() 43 | .split(/\s+/) 44 | .map((word, index) => { 45 | return index === 0 46 | ? word.toLowerCase() 47 | : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); 48 | }) 49 | .join(''); 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/typeConversion.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../utils/log'; 2 | 3 | export function toArray(value: string): string[] { 4 | if (!value) return []; 5 | try { 6 | // If the value is single-quoted, replace with double quotes 7 | const formattedValue = value.replace(/'/g, '"'); 8 | return JSON.parse(formattedValue); 9 | } catch (e) { 10 | throw new Error(`Failed to convert string to array: ${value}`); 11 | } 12 | } 13 | 14 | export function toBoolean(value: string): boolean { 15 | return value.toLowerCase() === 'true'; 16 | } 17 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --project-popup-padding: 4px; 3 | } 4 | 5 | .obsidian-taskcard { 6 | background-color: var(--background-primary); 7 | color: var(--text-normal); 8 | font-size: var(--font-text-size); 9 | font-family: var(--font-text); 10 | line-height: var(--line-height-normal); 11 | border-radius: var(--radius-m); 12 | border: var(--border-width) solid var(--background-modifier-border); 13 | height: var(--input-height); 14 | margin: 1px 0; 15 | display: inline-flex; 16 | flex-direction: column; 17 | gap: 3px; 18 | white-space: normal; 19 | outline: none; 20 | align-items: flex-start; 21 | text-align: inherit; /* Aligns text as in a regular div */ 22 | width: 100%; 23 | height: 100%; 24 | margin-inline-start: calc(-1 * var(--list-indent)); 25 | } 26 | 27 | .obsidian-taskcard.mode-single-line { 28 | cursor: var(--cursor-link); 29 | padding: 0.25em 0.5em 0.25em 0.48em; 30 | } 31 | 32 | .obsidian-taskcard.mode-single-line:hover { 33 | background-color: var(--background-primary-alt); 34 | } 35 | 36 | .obsidian-taskcard.mode-single-line:active { 37 | background-color: var(--background-modifier-active-hover); 38 | } 39 | 40 | .obsidian-taskcard.mode-multi-line { 41 | padding: 0.25em 0.5em 0.25em 0.48em; 42 | } 43 | 44 | .obsidian-taskcard.mode-multi-line.is-flashing { 45 | box-shadow: 0px 0px 10px var(--text-highlight-bg); 46 | background-color: var(--background-primary) !important; 47 | } 48 | 49 | .obsidian-taskcard-list-item.is-flashing .obsidian-taskcard.mode-single-line { 50 | box-shadow: 0px 0px 10px var(--text-highlight-bg); 51 | } 52 | 53 | .obsidian-taskcard-list-item { 54 | position: relative; 55 | padding: 0; 56 | margin: 0; 57 | font: inherit; 58 | cursor: pointer; 59 | outline: inherit; 60 | background-color: inherit !important; 61 | } 62 | 63 | 64 | .task-card-single-line { 65 | display: flex; 66 | flex-direction: row; 67 | align-items: center; 68 | justify-content: space-between; /* Align the two main containers */ 69 | width: 100%; 70 | box-shadow: none; 71 | } 72 | 73 | 74 | .task-card-single-line-left-container { 75 | display: flex; 76 | flex-direction: row; 77 | align-items: center; /* Vertically center the checkbox and content */ 78 | flex-grow: 1; /* Take up all available space */ 79 | } 80 | 81 | .task-card-single-line-right-container { 82 | display: flex; 83 | flex-direction: row; 84 | align-items: center; /* Vertically center the children */ 85 | align-self: center; 86 | } 87 | 88 | .task-card-checkbox-wrapper { 89 | grid-column: 1; 90 | grid-row: 1; 91 | display: flex; 92 | align-items: center; 93 | /* margin-right: 4px; */ 94 | } 95 | 96 | .task-card-content-project-line { 97 | position: relative; 98 | grid-column: 2; 99 | grid-row: 1; 100 | display: flex; 101 | flex-wrap: wrap; /* Allows content to wrap to the next line */ 102 | justify-content: space-between; /* Aligns content left, project right */ 103 | align-items: center; /* Centers items vertically */ 104 | } 105 | 106 | .task-card-content { 107 | border-radius: var(--radius-s); /* Rounded square */ 108 | cursor: pointer; /* Pointer cursor on hover */ 109 | flex-grow: 0.98; 110 | padding-left: 0.25em; 111 | padding-right: 0.25em; 112 | font-size: var(--font-text-size); 113 | } 114 | 115 | .task-card-menu-button.mode-multi-line { 116 | grid-column: 3; 117 | grid-row: 1; 118 | } 119 | 120 | .task-card-attribute-bottom-bar { 121 | display: flex; /* Use Flexbox layout */ 122 | align-items: center; /* Center children vertically */ 123 | justify-content: space-between; /* Align the first 3 elements to the left and the button to the right */ 124 | width: 100%; 125 | flex-direction: row; /* Not necessary, as 'row' is the default value, but can be included for clarity */ 126 | } 127 | 128 | .task-card-attribute-bottom-bar-left { 129 | display: flex; /* Enables Flexbox for the left-aligned elements */ 130 | align-items: center; /* Center children vertically */ 131 | overflow: hidden; /* Hide the overflow */ 132 | flex-grow: 1; /* Takes up remaining left space */ 133 | } 134 | 135 | .task-card-attribute-bottom-bar-right { 136 | display: flex; 137 | align-items: center; 138 | margin-left: calc(0.1 * var(--list-indent)); 139 | } 140 | 141 | .task-card-attribute-separator { 142 | flex-shrink: 0; /* Prevents shrinking */ 143 | margin: 0 3px 0 2px; 144 | margin-top: 3px; 145 | border-left: 1px solid var(--interactive-hover); 146 | height: 13px; 147 | align-items: center; 148 | align-self: center; 149 | } 150 | 151 | .task-card-icon { 152 | align-self: center; 153 | width: var(--icon-xs); 154 | height: var(--icon-xs); 155 | } 156 | 157 | .task-card-button { 158 | display: flex; 159 | align-items: center; 160 | justify-content: center; 161 | box-shadow: none !important; 162 | background: none; 163 | padding: 0; 164 | cursor: pointer; 165 | width: calc(var(--icon-xs) + 10px); 166 | height: calc(var(--icon-xs) + 10px); 167 | overflow: hidden; /* Hide overflow */ 168 | min-width: calc(var(--icon-xs) + 10px); /* Set minimum width */ 169 | min-height: calc(var(--icon-xs) + 10px); /* Set minimum height */ 170 | flex-shrink: 0; /* Prevent shrinking */ 171 | } 172 | 173 | .task-card-button:hover { 174 | background: var(--background-modifier-hover); 175 | } 176 | 177 | .task-card-menu-button:hover svg { 178 | color: var(--icon-color-hover); 179 | } 180 | 181 | .task-card-menu-button:active { 182 | background: none !important; 183 | box-shadow: none !important; 184 | } 185 | 186 | .task-card-menu-button:active svg { 187 | color: var(--icon-color-active); 188 | } 189 | -------------------------------------------------------------------------------- /tests/colorConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | darkenHEXColor, 3 | darkenRGBColor, 4 | HEXToRGB, 5 | RGBToHEX 6 | } from '../src/utils/colorConverter'; 7 | 8 | describe('Color Conversion Utilities', () => { 9 | describe('HEXToRGB', () => { 10 | it('should correctly convert HEX to RGB', () => { 11 | const RGB = HEXToRGB('#FFFFFF'); 12 | expect(RGB).toEqual({ r: 255, g: 255, b: 255 }); 13 | }); 14 | 15 | it('should return null for invalid HEX code', () => { 16 | const RGB = HEXToRGB('#GGGGGG'); 17 | expect(RGB).toBeNull(); 18 | }); 19 | }); 20 | 21 | describe('RGBToHEX', () => { 22 | it('should correctly convert RGB to HEX', () => { 23 | const HEX = RGBToHEX({ r: 255, g: 255, b: 255 }); 24 | expect(HEX).toEqual('#ffffff'); 25 | }); 26 | }); 27 | 28 | describe('darkenRGBColor', () => { 29 | it('should darken the color correctly', () => { 30 | const RGB = darkenRGBColor({ r: 255, g: 255, b: 255 }, 0.5); 31 | expect(RGB).toEqual({ r: 127, g: 127, b: 127 }); 32 | }); 33 | 34 | it('should not darken beyond 0', () => { 35 | const RGB = darkenRGBColor({ r: 255, g: 255, b: 255 }, 1.5); 36 | expect(RGB).toEqual({ r: 0, g: 0, b: 0 }); 37 | }); 38 | }); 39 | 40 | describe('darkenHEXColor', () => { 41 | it('should darken the color correctly', () => { 42 | const HEX = darkenHEXColor('#FFFFFF', 0.5); 43 | expect(HEX).toEqual('#7f7f7f'); 44 | }); 45 | 46 | it('should not darken beyond 0', () => { 47 | const HEX = darkenHEXColor('#FFFFFF', 1.5); 48 | expect(HEX).toEqual('#000000'); 49 | }); 50 | 51 | it('should return original color for invalid HEX code', () => { 52 | const HEX = darkenHEXColor('#GGGGGG', 0.5); 53 | expect(HEX).toEqual('#GGGGGG'); 54 | }); 55 | 56 | it('another test case for darken color', () => { 57 | const HEX = darkenHEXColor('#F6F6F6', 0.2); 58 | expect(HEX).toEqual('#c4c4c4'); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/dateTimeFormatter.test.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../src/utils/log'; 2 | import { 3 | displayDate, 4 | displayTime, 5 | isSameWeek, 6 | isToday, 7 | isTomorrow, 8 | isYesterday 9 | } from '../src/utils/dateTimeFormatter'; 10 | 11 | describe('displayDate', () => { 12 | it('returns "Today" for the current date', () => { 13 | const date = new Date(); 14 | const dateString = getLocalISODate(date); 15 | expect(displayDate(dateString)).toBe('Today'); 16 | }); 17 | 18 | it('returns "Tomorrow" for the next day', () => { 19 | const date = new Date(); 20 | date.setDate(date.getDate() + 1); 21 | const dateString = getLocalISODate(date); 22 | expect(displayDate(dateString)).toBe('Tomorrow'); 23 | }); 24 | 25 | it('returns "Yesterday" for the previous day', () => { 26 | const date = new Date(); 27 | date.setDate(date.getDate() - 1); 28 | const dateString = getLocalISODate(date); 29 | expect(displayDate(dateString)).toBe('Yesterday'); 30 | }); 31 | 32 | it('returns the correct month and date for the same year', () => { 33 | const date = new Date(fixedDate()); 34 | date.setMonth(date.getMonth() + 2); // Move to a different month 35 | const expectedFormat = `${date.toLocaleString('default', { 36 | month: 'short' 37 | })} ${date.getDate()}`; 38 | const dateString = getLocalISODate(date); 39 | expect(displayDate(dateString)).toBe(expectedFormat); 40 | }); 41 | 42 | it('returns the correct month, date, and year for a different year', () => { 43 | const date = new Date(fixedDate()); 44 | date.setFullYear(date.getFullYear() + 1); // Move to next year 45 | const expectedFormat = `${date.toLocaleString('default', { 46 | month: 'short' 47 | })} ${date.getDate()}, ${date.getFullYear()}`; 48 | const dateString = getLocalISODate(date); 49 | expect(displayDate(dateString)).toBe(expectedFormat); 50 | }); 51 | 52 | it('returns the original string if it does not pass DateOnly.check', () => { 53 | const date = 'Not a date'; 54 | expect(displayDate(date)).toBe(date); 55 | }); 56 | }); 57 | 58 | describe('isYesterday', () => { 59 | it('returns true if the date is yesterday', () => { 60 | const date = new Date(); 61 | date.setDate(date.getDate() - 1); 62 | expect(isYesterday(date)).toBe(true); 63 | }); 64 | 65 | it('returns false if the date is not yesterday', () => { 66 | const date = new Date(); 67 | expect(isYesterday(date)).toBe(false); 68 | }); 69 | }); 70 | 71 | describe('isToday', () => { 72 | it('returns true if the date is today', () => { 73 | const date = new Date(); 74 | expect(isToday(date)).toBe(true); 75 | }); 76 | 77 | it('returns false if the date is not today', () => { 78 | const date = new Date(); 79 | date.setDate(date.getDate() + 1); 80 | expect(isToday(date)).toBe(false); 81 | }); 82 | }); 83 | 84 | describe('isTomorrow', () => { 85 | it('returns true if the date is tomorrow', () => { 86 | const date = new Date(); 87 | date.setDate(date.getDate() + 1); 88 | expect(isTomorrow(date)).toBe(true); 89 | }); 90 | 91 | it('returns false if the date is not tomorrow', () => { 92 | const date = new Date(); 93 | expect(isTomorrow(date)).toBe(false); 94 | }); 95 | }); 96 | 97 | describe('isSameWeek', () => { 98 | it('returns true if the date is within the same week', () => { 99 | const today = new Date(); 100 | const date = new Date(today); 101 | date.setDate(today.getDate() - today.getDay()); // Set to the start of the week 102 | expect(isSameWeek(date)).toBe(true); 103 | }); 104 | 105 | it('returns false if the date is not within the same week', () => { 106 | const today = new Date(); 107 | const date = new Date(today); 108 | date.setDate(today.getDate() - today.getDay() - 1); // Set to the day before the start of the week 109 | expect(isSameWeek(date)).toBe(false); 110 | }); 111 | 112 | it('considers the week start day if provided', () => { 113 | const today = new Date(); 114 | const date = new Date(today); 115 | date.setDate(today.getDate() - today.getDay() + 1); // Set to Monday 116 | expect(isSameWeek(date, 1)).toBe(true); // Should be true if the week starts on Monday 117 | }); 118 | }); 119 | 120 | describe('displayTime', () => { 121 | test('should return undefined when time is undefined', () => { 122 | expect(displayTime(undefined)).toBe(undefined); 123 | }); 124 | 125 | test('should convert midnight to 12:00 AM', () => { 126 | expect(displayTime('00:00')).toBe('12:00 AM'); 127 | }); 128 | 129 | test('should convert noon to 12:00 PM', () => { 130 | expect(displayTime('12:00')).toBe('12:00 PM'); 131 | }); 132 | 133 | test('should correctly format morning time', () => { 134 | expect(displayTime('09:30')).toBe('9:30 AM'); 135 | }); 136 | 137 | test('should correctly format afternoon time', () => { 138 | expect(displayTime('15:45')).toBe('3:45 PM'); 139 | }); 140 | 141 | // You might also want to test for invalid input 142 | test('should handle invalid time', () => { 143 | // Behavior here is not defined by the given function, so you'll need to decide what is expected. 144 | // It could be to return null, or the original string, or something else. 145 | // Here's an example expecting the original string: 146 | expect(displayTime('invalid-input')).toBe('invalid-input'); 147 | }); 148 | }); 149 | 150 | function fixedDate(): string | number | Date { 151 | // return the Mar 3 of this year 152 | const currentYear = new Date().getFullYear(); 153 | return new Date(currentYear, 3, 3); 154 | } 155 | 156 | function getLocalISODate(date: Date) { 157 | const year = date.getFullYear(); 158 | const month = String(date.getMonth() + 1).padStart(2, '0'); 159 | const day = String(date.getDate()).padStart(2, '0'); 160 | const dateString = `${year}-${month}-${day}`; 161 | return dateString; 162 | } 163 | -------------------------------------------------------------------------------- /tests/filter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isTaskList, 3 | isTaskItemEl, 4 | filterTaskItems, 5 | getIndicesOfFilter 6 | } from '../src/renderer/filters'; 7 | 8 | import { JSDOM } from 'jsdom'; 9 | 10 | const { window } = new JSDOM(''); 11 | const { document } = window; 12 | global.HTMLElement = window.HTMLElement; 13 | 14 | // Mocking TaskValidator's isValidTaskElement function 15 | jest.mock('../src/taskModule/taskValidator', () => { 16 | return { 17 | isValidTaskElement: jest.fn() 18 | }; 19 | }); 20 | 21 | const mockValidator = { 22 | isValidTaskElement: jest.fn() 23 | }; 24 | 25 | // Test for isTaskList 26 | 27 | describe('isTaskList', () => { 28 | it('should return false for null or undefined elements', () => { 29 | expect(isTaskList(null)).toBe(false); 30 | expect(isTaskList(undefined)).toBe(false); 31 | }); 32 | 33 | it('should return false for non-UL elements', () => { 34 | const div = document.createElement('div'); 35 | expect(isTaskList(div)).toBe(false); 36 | }); 37 | 38 | it('should return true for UL elements with correct classes', () => { 39 | const ul = document.createElement('ul'); 40 | ul.classList.add('contains-task-list', 'has-list-bullet'); 41 | expect(isTaskList(ul)).toBe(true); 42 | }); 43 | 44 | it('should return false for UL elements without correct classes', () => { 45 | const ul = document.createElement('ul'); 46 | ul.classList.add('contains-task-list'); 47 | expect(isTaskList(ul)).toBe(false); 48 | }); 49 | }); 50 | 51 | // Test for isTaskItemEl 52 | 53 | describe('isTaskItemEl', () => { 54 | beforeEach(() => { 55 | mockValidator.isValidTaskElement.mockReturnValue(true); 56 | }); 57 | 58 | it('should return false for null or undefined elements', () => { 59 | expect(isTaskItemEl(null, mockValidator as any)).toBe(false); 60 | expect(isTaskItemEl(undefined, mockValidator as any)).toBe(false); 61 | }); 62 | 63 | it('should return false for non-LI elements', () => { 64 | const div = document.createElement('div'); 65 | expect(isTaskItemEl(div, mockValidator as any)).toBe(false); 66 | }); 67 | 68 | it('should return true for LI elements with valid task', () => { 69 | const li = document.createElement('li'); 70 | const task = document.createElement('div'); 71 | li.appendChild(task); 72 | expect(isTaskItemEl(li, mockValidator as any)).toBe(true); 73 | }); 74 | 75 | it('should return false for LI elements without valid task', () => { 76 | mockValidator.isValidTaskElement.mockReturnValue(false); 77 | const li = document.createElement('li'); 78 | const task = document.createElement('div'); 79 | li.appendChild(task); 80 | expect(isTaskItemEl(li, mockValidator as any)).toBe(false); 81 | }); 82 | }); 83 | 84 | // Test for filterTaskItems 85 | 86 | describe('filterTaskItems', () => { 87 | beforeEach(() => { 88 | mockValidator.isValidTaskElement.mockReturnValue(true); 89 | }); 90 | 91 | // it('should throw error for invalid inputs', () => { 92 | // expect(() => filterTaskItems('string', mockValidator)).toThrow( 93 | // 'Invalid input provided.' 94 | // ); 95 | // expect(() => filterTaskItems([], 'string')).toThrow( 96 | // 'Invalid input provided.' 97 | // ); 98 | // }); 99 | 100 | it('should return valid task items', () => { 101 | const li1 = document.createElement('li'); 102 | const task1 = document.createElement('div'); 103 | li1.appendChild(task1); 104 | 105 | const li2 = document.createElement('li'); 106 | 107 | const elems = [li1, li2]; 108 | const result = filterTaskItems(elems, mockValidator as any); 109 | expect(result.length).toBe(1); 110 | expect(result[0]).toBe(li1); 111 | }); 112 | }); 113 | 114 | // Test for getIndicesOfFilter 115 | 116 | describe('getIndicesOfFilter', () => { 117 | // it('should throw error for invalid inputs', () => { 118 | // expect(() => getIndicesOfFilter('string', () => true)).toThrow( 119 | // 'Invalid input provided.' 120 | // ); 121 | // expect(() => getIndicesOfFilter([], 'string')).toThrow( 122 | // 'Invalid input provided.' 123 | // ); 124 | // }); 125 | 126 | it('should return indices of elements satisfying the filter', () => { 127 | const array = [1, 2, 3, 4, 5]; 128 | const filter = (num) => num % 2 === 0; 129 | const result = getIndicesOfFilter(array, filter); 130 | expect(result).toEqual([1, 3]); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /tests/indexedMapDatabase.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { IndexedMapDatabase, LogicalExpression, QueryFunction } from "../src/query/indexMapDatabase"; 3 | 4 | 5 | describe('IndexedMapDatabase', () => { 6 | let db: IndexedMapDatabase<{ age: number; name: string }>; 7 | 8 | beforeEach(() => { 9 | db = new IndexedMapDatabase(); 10 | }); 11 | 12 | it('should store and query items', () => { 13 | db.store('1', { age: 25, name: 'Alice' }); 14 | db.store('2', { age: 30, name: 'Bob' }); 15 | 16 | const query: QueryFunction<{ age: number; name: string }> = item => item.age > 26; 17 | const expression: LogicalExpression<{ age: number; name: string }> = { 18 | operator: 'AND', 19 | operands: [query], 20 | }; 21 | 22 | const result = db.queryByComplexLogic(expression); 23 | expect(result).toEqual([{ age: 30, name: 'Bob' }]); 24 | }); 25 | 26 | it('should bulk store items', () => { 27 | db.bulkStore([ 28 | { id: '1', item: { age: 25, name: 'Alice' } }, 29 | { id: '2', item: { age: 30, name: 'Bob' } }, 30 | ]); 31 | 32 | const query: QueryFunction<{ age: number; name: string }> = item => item.age > 26; 33 | const expression: LogicalExpression<{ age: number; name: string }> = { 34 | operator: 'AND', 35 | operands: [query], 36 | }; 37 | 38 | const result = db.queryByComplexLogic(expression); 39 | expect(result).toEqual([{ age: 30, name: 'Bob' }]); 40 | }); 41 | 42 | it('should create and query by index', () => { 43 | db.store('1', { age: 25, name: 'Alice' }); 44 | db.store('2', { age: 30, name: 'Bob' }); 45 | db.createIndex('age', item => item.age); 46 | 47 | const query: QueryFunction<{ age: number; name: string }> = item => item.age > 26; 48 | const expression: LogicalExpression<{ age: number; name: string }> = { 49 | operator: 'AND', 50 | operands: [query], 51 | }; 52 | 53 | const result = db.queryByComplexLogic(expression); 54 | expect(result).toEqual([{ age: 30, name: 'Bob' }]); 55 | }); 56 | 57 | it('should handle complex logical queries', () => { 58 | db.bulkStore([ 59 | { id: '1', item: { age: 25, name: 'Alice' } }, 60 | { id: '2', item: { age: 30, name: 'Bob' } }, 61 | { id: '3', item: { age: 35, name: 'Charlie' } }, 62 | ]); 63 | 64 | const query1: QueryFunction<{ age: number; name: string }> = item => item.age > 26; 65 | const query2: QueryFunction<{ age: number; name: string }> = item => item.age < 34; 66 | 67 | const expression: LogicalExpression<{ age: number; name: string }> = { 68 | operator: 'AND', 69 | operands: [ 70 | query1, 71 | { 72 | operator: 'NOT', 73 | operands: [query2], 74 | }, 75 | ], 76 | }; 77 | 78 | const result = db.queryByComplexLogic(expression); 79 | expect(result).toEqual([{ age: 35, name: 'Charlie' }]); 80 | }); 81 | 82 | it('should return empty array for no matches', () => { 83 | db.store('1', { age: 25, name: 'Alice' }); 84 | 85 | const query: QueryFunction<{ age: number; name: string }> = item => item.age > 26; 86 | const expression: LogicalExpression<{ age: number; name: string }> = { 87 | operator: 'AND', 88 | operands: [query], 89 | }; 90 | 91 | const result = db.queryByComplexLogic(expression); 92 | expect(result).toEqual([]); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/labelModule.test.ts: -------------------------------------------------------------------------------- 1 | import { LabelModule } from '../src/taskModule/labels'; 2 | 3 | describe('LabelModule', () => { 4 | let labelModule: LabelModule; 5 | 6 | beforeEach(() => { 7 | labelModule = new LabelModule(); 8 | }); 9 | 10 | test('addLabel should add a valid label to the list', () => { 11 | labelModule.addLabel('validLabel'); 12 | expect(labelModule.getLabels()).toEqual(['#validLabel']); 13 | }); 14 | 15 | test('addLabel should add another valid label with #', () => { 16 | labelModule.addLabel('#validLabel'); 17 | expect(labelModule.getLabels()).toEqual(['#validLabel']); 18 | }); 19 | 20 | test('addLabel should not add an invalid label in strict mode', () => { 21 | labelModule.addLabel('invalid Label', true); 22 | expect(labelModule.getLabels()).toEqual([]); 23 | }); 24 | 25 | test('addLabel should format and add an invalid label in non-strict mode', () => { 26 | labelModule.addLabel('#invalidLabel', false); 27 | expect(labelModule.getLabels()).toEqual(['#invalidLabel']); 28 | }); 29 | 30 | test('addLabels should add multiple labels', () => { 31 | labelModule.addLabels(['labelOne', 'labelTwo']); 32 | expect(labelModule.getLabels()).toEqual(['#labelOne', '#labelTwo']); 33 | }); 34 | 35 | test('setLabels should set the list of labels', () => { 36 | labelModule.setLabels(['newLabelOne', 'newLabelTwo']); 37 | expect(labelModule.getLabels()).toEqual(['#newLabelOne', '#newLabelTwo']); 38 | }); 39 | 40 | test('editLabel should update an existing label', () => { 41 | labelModule.addLabel('oldLabel'); 42 | labelModule.editLabel('newLabel', 'oldLabel'); 43 | expect(labelModule.getLabels()).toEqual(['#newLabel']); 44 | }); 45 | 46 | test('editLabel should throw an error if the old label is not found', () => { 47 | expect(() => 48 | labelModule.editLabel('newLabel', 'nonexistentLabel') 49 | ).toThrowError('Label not found'); 50 | }); 51 | 52 | test('editLabel should log an error if the new label is invalid in strict mode', () => { 53 | labelModule.addLabel('oldLabel'); 54 | labelModule.editLabel('#invalid Label', 'oldLabel', true); 55 | // We can't test logger.error output in Jest, so we just check that the label wasn't changed 56 | expect(labelModule.getLabels()).toEqual(['#oldLabel']); 57 | }); 58 | 59 | test('deleteLabel should remove a label from the list', () => { 60 | labelModule.addLabels(['labelOne', 'labelTwo']); 61 | labelModule.deleteLabel('labelOne'); 62 | expect(labelModule.getLabels()).toEqual(['#labelTwo']); 63 | }); 64 | 65 | test('deleteLabel should throw an error if the label is not found', () => { 66 | expect(() => labelModule.deleteLabel('nonexistentLabel')).toThrowError( 67 | 'Label not found' 68 | ); 69 | }); 70 | 71 | test('validateLabel should format an invalid label', () => { 72 | const formattedLabel = labelModule.validateLabel('# invalid Label'); 73 | expect(formattedLabel).toEqual('invalidLabel'); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /tests/regexUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | escapeRegExp, 3 | extractTags, 4 | getGroupStartIndex 5 | } from '../src/utils/regexUtils'; 6 | 7 | describe('textUtils', () => { 8 | describe('escapeRegExp', () => { 9 | it('should escape special characters in a string', () => { 10 | const input = '.*+?^${}()|[\\]'; 11 | const expected = '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\\\\\]'; 12 | expect(escapeRegExp(input)).toEqual(expected); 13 | }); 14 | 15 | it('should return the same string if no special characters are present', () => { 16 | const input = 'normalString'; 17 | expect(escapeRegExp(input)).toEqual(input); 18 | }); 19 | }); 20 | 21 | describe('extractTags', () => { 22 | it('should extract valid tags from a text and return remaining content without tags', () => { 23 | const text = 'some content with tags #tag1 #tag2 #tag3'; 24 | const expectedTags = ['#tag1', '#tag2', '#tag3']; 25 | const expectedContent = 'some content with tags'; 26 | const [tags, content] = extractTags(text); 27 | expect(tags).toEqual(expectedTags); 28 | expect(content).toEqual(expectedContent); 29 | }); 30 | 31 | it('should return an empty array if no valid tags are present and the original text', () => { 32 | const text = 'This is a sample text with no valid tags.'; 33 | const expectedTags = []; 34 | const expectedContent = 'This is a sample text with no valid tags.'; 35 | const [tags, content] = extractTags(text); 36 | expect(tags).toEqual(expectedTags); 37 | expect(content).toEqual(expectedContent); 38 | }); 39 | 40 | it('should handle multiple consecutive spaces created by tags', () => { 41 | const text = 'This is a sample #tag1 text #tag2 with spaces.'; 42 | const expectedTags = ['#tag1', '#tag2']; 43 | const expectedContent = 'This is a sample text with spaces.'; 44 | const [tags, content] = extractTags(text); 45 | expect(tags).toEqual(expectedTags); 46 | expect(content).toEqual(expectedContent); 47 | }); 48 | 49 | it('should handle texts that have only tags', () => { 50 | const text = '#only #tags #here'; 51 | const expectedTags = ['#only', '#tags', '#here']; 52 | const expectedContent = ''; 53 | const [tags, content] = extractTags(text); 54 | expect(tags).toEqual(expectedTags); 55 | expect(content).toEqual(expectedContent); 56 | }); 57 | 58 | it('should handle empty texts', () => { 59 | const text = ''; 60 | const expectedTags = []; 61 | const expectedContent = ''; 62 | const [tags, content] = extractTags(text); 63 | expect(tags).toEqual(expectedTags); 64 | expect(content).toEqual(expectedContent); 65 | }); 66 | }); 67 | 68 | describe('getGroupStartIndex', () => { 69 | it('should return the correct index of the matching group', () => { 70 | const text = 'green red apple red'; 71 | const regex = /apple (red|green)/; 72 | expect(getGroupStartIndex(text, regex, 1)).toBe(16); 73 | }); 74 | 75 | it('should handle multiple groups', () => { 76 | const text = 'apple green red'; 77 | const regex = /apple (green) (red)/; 78 | expect(getGroupStartIndex(text, regex, 1)).toBe(6); 79 | expect(getGroupStartIndex(text, regex, 2)).toBe(12); 80 | }); 81 | 82 | it('should return null when the group does not exist', () => { 83 | const text = 'apple green'; 84 | const regex = /apple (red|green)/; 85 | expect(getGroupStartIndex(text, regex, 2)).toBeNull(); 86 | }); 87 | 88 | it('should return null when there is no match', () => { 89 | const text = 'apple blue'; 90 | const regex = /apple (red|green)/; 91 | expect(getGroupStartIndex(text, regex, 1)).toBeNull(); 92 | }); 93 | 94 | it('should handle special characters in the group', () => { 95 | const text = 'apple $green$'; 96 | const regex = /apple (\$green\$)/; 97 | expect(getGroupStartIndex(text, regex, 1)).toBe(6); 98 | }); 99 | 100 | it('should handle the start and end of the string', () => { 101 | const text = 'red apple green'; 102 | const regex = /^(red) apple (green)$/; 103 | expect(getGroupStartIndex(text, regex, 1)).toBe(0); 104 | expect(getGroupStartIndex(text, regex, 2)).toBe(10); 105 | }); 106 | 107 | it('hard case with spaces', () => { 108 | const text = 'red apple green'; 109 | const regex = /apple(\s?)green/; 110 | expect(getGroupStartIndex(text, regex, 1)).toBe(9); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /tests/stringCaseConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | camelToKebab, 3 | kebabToCamel, 4 | toCamelCase 5 | } from '../src/utils/stringCaseConverter'; 6 | 7 | describe('string case conversion', () => { 8 | test('camelToKebab should convert camelCase to kebab-case', () => { 9 | expect(camelToKebab('camelCaseString')).toBe('camel-case-string'); 10 | expect(camelToKebab('specialCaseID')).toBe('special-case-id'); 11 | expect(camelToKebab('anotherCamelCaseString')).toBe( 12 | 'another-camel-case-string' 13 | ); 14 | expect(camelToKebab('someIDInString')).toBe('some-id-in-string'); 15 | }); 16 | 17 | test('kebabToCamel should convert kebab-case to camelCase', () => { 18 | expect(kebabToCamel('kebab-case-string')).toBe('kebabCaseString'); 19 | expect(kebabToCamel('special-case-id')).toBe('specialCaseID'); 20 | expect(kebabToCamel('another-kebab-case-string')).toBe( 21 | 'anotherKebabCaseString' 22 | ); 23 | expect(kebabToCamel('some-id-in-string')).toBe('someIDInString'); 24 | }); 25 | 26 | test('toCamelCase should convert a word + space structure to camelCase', () => { 27 | expect(toCamelCase('hello')).toBe('hello'); 28 | expect(toCamelCase('Hello World')).toBe('helloWorld'); 29 | expect(toCamelCase('The Quick Brown Fox')).toBe('theQuickBrownFox'); 30 | expect(toCamelCase(' The pretty brown fox ')).toBe('thePrettyBrownFox'); 31 | expect(toCamelCase(' The Slow Brown Fox ')).toBe( 32 | 'theSlowBrownFox' 33 | ); 34 | expect(toCamelCase('')).toBe(''); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/taskFormatter.test.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import { ObsidianTask } from '../src/taskModule/task'; 3 | import { TaskFormatter } from '../src/taskModule/taskFormatter'; 4 | 5 | describe('taskToMarkdown', () => { 6 | let mockSettingStore; 7 | let taskFormatter: TaskFormatter; 8 | 9 | beforeEach(() => { 10 | // Mock the SettingStore with controlled settings 11 | mockSettingStore = writable({ 12 | parsingSettings: { 13 | indicatorTag: 'TaskCard', 14 | markdownSuffix: ' .', 15 | }, 16 | displaySettings: { 17 | defaultMode: 'single-line', 18 | } 19 | }); 20 | taskFormatter = new TaskFormatter(mockSettingStore); 21 | }); 22 | 23 | it('should format a basic task with content and completion status', () => { 24 | const task = new ObsidianTask({ 25 | content: 'An example task', 26 | completed: false 27 | }); 28 | const result = taskFormatter.taskToMarkdown(task); 29 | expect(result).toContain('- [ ] An example task'); 30 | }); 31 | 32 | it('should format a task with priority', () => { 33 | const task = new ObsidianTask({ 34 | content: 'An example task', 35 | completed: false, 36 | priority: 4 37 | }); 38 | const result = taskFormatter.taskToMarkdown(task); 39 | expect(result).toContain('"priority":4'); 40 | }); 41 | 42 | it('should format a task with description', () => { 43 | const task = new ObsidianTask({ 44 | content: 'An example task', 45 | completed: false, 46 | description: '- A multi line description.\n- the second line.' 47 | }); 48 | const result = taskFormatter.taskToMarkdown(task); 49 | expect(result).toContain('- A multi line description.\n- the second line.'); 50 | }); 51 | 52 | it('should format a task with order', () => { 53 | const task = new ObsidianTask({ 54 | content: 'An example task', 55 | completed: false, 56 | order: 1 57 | }); 58 | const result = taskFormatter.taskToMarkdown(task); 59 | expect(result).toContain('"order":1'); 60 | }); 61 | 62 | it('should format a task with project', () => { 63 | const task = new ObsidianTask({ 64 | content: 'An example task', 65 | completed: false, 66 | project: { id: 'project-123', name: 'Project Name' } 67 | }); 68 | const result = taskFormatter.taskToMarkdown(task); 69 | expect(result).toContain('"project":{"id":"project-123","name":"Project Name"}'); 70 | }); 71 | 72 | it('should format a task with sectionID', () => { 73 | const task = new ObsidianTask({ 74 | content: 'An example task', 75 | completed: false, 76 | sectionID: 'section-456' 77 | }); 78 | const result = taskFormatter.taskToMarkdown(task); 79 | expect(result).toContain('"sectionID":"section-456"'); 80 | }); 81 | 82 | it('should format a task with labels', () => { 83 | const task = new ObsidianTask({ 84 | content: 'An example task', 85 | completed: false, 86 | labels: ['#label1', '#label2'] 87 | }); 88 | const result = taskFormatter.taskToMarkdown(task); 89 | expect(result).toContain('#label1 #label2'); 90 | }); 91 | 92 | it('should format a task with schedule date', () => { 93 | const task = new ObsidianTask({ 94 | content: 'An example task', 95 | completed: false, 96 | schedule: { 97 | isRecurring: false, 98 | date: '2024-08-15', 99 | string: '2023-08-15', 100 | timezone: null 101 | } 102 | }); 103 | const result = taskFormatter.taskToMarkdown(task); 104 | expect(result).toContain('"schedule":{"isRecurring":false,"date":"2024-08-15","string":"2023-08-15","timezone":null}'); 105 | }); 106 | 107 | it('should format a task with metadata', () => { 108 | const task = new ObsidianTask({ 109 | content: 'An example task', 110 | completed: false, 111 | metadata: { filePath: '/path/to/file' } 112 | }); 113 | const result = taskFormatter.taskToMarkdown(task); 114 | expect(result).toContain('"metadata":{"filePath":"/path/to/file"}'); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /tests/typeConversion.test.ts: -------------------------------------------------------------------------------- 1 | import { toArray, toBoolean } from '../src/utils/typeConversion'; 2 | 3 | describe('toArray function', () => { 4 | test('should convert JSON string to array', () => { 5 | const value = '["item1", "item2", "item3"]'; 6 | const result = toArray(value); 7 | expect(result).toEqual(['item1', 'item2', 'item3']); 8 | }); 9 | 10 | test('should cover single quote array', () => { 11 | const value = "['item1', 'item2', 'item3']"; 12 | const result = toArray(value); 13 | expect(result).toEqual(['item1', 'item2', 'item3']); 14 | }); 15 | 16 | test('should throw an error for invalid JSON', () => { 17 | const value = 'not a valid JSON string'; 18 | expect(() => toArray(value)).toThrowError( 19 | 'Failed to convert string to array: not a valid JSON string' 20 | ); 21 | }); 22 | }); 23 | 24 | describe('toBoolean function', () => { 25 | test('should convert "true" string to boolean', () => { 26 | const value = 'true'; 27 | const result = toBoolean(value); 28 | expect(result).toBe(true); 29 | }); 30 | 31 | test('should convert "false" string to boolean', () => { 32 | const value = 'false'; 33 | const result = toBoolean(value); 34 | expect(result).toBe(false); 35 | }); 36 | 37 | test('should convert non-boolean string to false', () => { 38 | const value = 'not a boolean'; 39 | const result = toBoolean(value); 40 | expect(result).toBe(false); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["svelte", "node", "jest"], 5 | "esModuleInterop": true, 6 | "verbatimModuleSyntax": false 7 | }, 8 | "module": "CommonJS" 9 | } 10 | --------------------------------------------------------------------------------
Tasks Not Fully Indexed. Please make sure that the dataview plugin is also enabled in Obsidian. This is necessary for this feature to work properly
It looks like there are no tasks that match your filter.