feat: added option to display interactive checkboxes in tasks table

This commit is contained in:
Kacper Kula 2025-03-21 16:50:03 +00:00
parent fba988511c
commit 66b1172342
8 changed files with 217 additions and 101 deletions

View file

@ -43,6 +43,8 @@ Tasks table consists of the following columns:
| `completed` | 0 if not completed, 1 if completed | |
| `path` | Full path of the file the tag belongs to | 0.24.1 |
| `filePath` | (deprecated) same like `path`. Name changed for compatibility with other tables. Will get removed in the future versions | |
| `checkbox` | Interactive checkbox for the task that can be clicked to toggle completion state | 0.29.0 |
| `position` | Line number where the task appears in the original file | 0.29.0 |
### `links` table
Table containing all the links between files.

View file

@ -58,4 +58,32 @@ LIMIT 10
You can display boolean data as checkbox in the interface by calling `checkbox` function:
```sqlseal
SELECT date, checkbox(excercised) FROM files WHERE date is not null
```
```
### Interactive Task Checkboxes
SQLSeal provides two ways to create interactive checkboxes for tasks:
#### Method 1: Direct checkbox column (Recommended)
The simplest and most reliable approach is to directly use the `checkbox` column:
```sqlseal
SELECT checkbox, task FROM tasks
```
This approach automatically renders interactive checkboxes without requiring any function wrappers, making your queries cleaner and more maintainable.
#### Method 2: Using the `checkbox` function
For backwards compatibility, you can also use the checkbox function with a single parameter:
```sqlseal
-- Using the checkbox column
SELECT task, checkbox(completed) FROM tasks
The checkbox function accepts a single parameter which can be either:
- A numeric value (0 = unchecked, 1 = checked) - renders as a disabled checkbox
- Any boolean/truthy value - renders as a disabled checkbox
> [!NOTE] Interactive vs Disabled Checkboxes
> Only checkboxes created using the `checkbox` column are interactive. When using the `completed` column or other values, the checkbox will be rendered in a disabled state to indicate it cannot be modified.

View file

@ -1,6 +1,9 @@
import { identity } from "lodash"
import { App } from "obsidian"
import { CellParserRegistar } from "../cellParser"
import { renderCheckbox } from "./ui/checkbox"
import { renderLink } from "./ui/link"
import { renderImage } from "./ui/image"
export const displayNotice = (el: HTMLElement, text: string) => {
el.empty()
@ -12,34 +15,7 @@ export const displayError = (el: HTMLElement, text: string) => {
el.createDiv({ text: text, cls: 'sqlseal-error' })
}
const isLinkLocal = (link: string) => !link?.trim().startsWith('http')
const isLinktext = (link: string) => link.startsWith('[[') && link.endsWith(']]')
const renderCheckbox = (app: App) => ([value]: [string]) => {
const el = createEl('input', {
type: 'checkbox',
attr: {
disabled: true
},
})
if (!!value) {
el.checked = true
}
return el
}
const removeExtension = (filename: string) => {
const parts = filename.split('.')
if (parts.length > 1) {
return parts.slice(0, -1).join('.')
}
return filename
}
type LinkType = [string] | [string, string]
export const isStringifiedArray = (str: string) => {
return str.length > 1 && str.startsWith('[') && str.endsWith(']') && str[1] !== '[' && str[str.length - 2] !== ']'
@ -56,77 +32,6 @@ export const renderStringifiedArray = (input: string, transformer: (f: string) =
return el
}
export const renderLink = (app: App, create = createEl) => ([href, name]: LinkType): string | Node => {
if (!href) {
return ''
}
if (isStringifiedArray(href)) {
try {
return renderStringifiedArray(href, href => renderLink(app, create)([href]))
} catch (e) {
return href
}
}
if (!name) {
name = href
}
href = href.trim()
let cls = ''
if (isLinktext(href)) {
const [path, linkName] = href.slice(2, -2).split('|')
const link = app.metadataCache.getFirstLinkpathDest(path, '')
if (name.trim() === href) {
name = linkName ?? path
}
if (!link) {
href = path
} else {
href = link.path
}
cls = 'internal-link'
} else if (isLinkLocal(href)) {
let fileHref = href
if (fileHref.endsWith('.md')) {
fileHref = fileHref.slice(0, -3)
}
if (name === href) {
const components = href.split('/')
name = removeExtension(components[components.length - 1])
}
cls = 'internal-link'
}
const el = create('a', { text: name, href, cls })
return el
}
const renderImage = (app: App) => ([href, path]: [string, string]) => {
if (!isLinkLocal(href)) {
return createEl('img', { attr: { src: href } });
}
href = (href ?? '').trim()
if (href.startsWith('![[')) {
href = href.slice(3, -2)
}
let parentPath = ''
if (path) {
const parent = app.vault.getFileByPath(path)
if (parent) {
parentPath = parent.parent?.path ?? ''
}
}
path = (path ? parentPath + '/' : '') + href
const file = app.vault.getFileByPath(path);
if (!file) {
return 'File does not exist'
}
return createEl('img', { attr: { src: app.vault.getResourcePath(file) } });
}
export const registerDefaultFunctions = (registar: CellParserRegistar, app: App) => {
registar.register('checkbox', renderCheckbox(app), 1)
registar.register('a', renderLink(app), 2)

75
src/utils/ui/checkbox.ts Normal file
View file

@ -0,0 +1,75 @@
import { App } from "obsidian";
interface CheckboxProps {
checked: boolean,
path: string,
position: {
line: number,
lineContent: string
},
task: string
}
const isCheckboxProp = (arg: any): arg is CheckboxProps => {
return arg && arg['position'] && arg['path']
}
export const renderCheckbox = (app: App) => (values: number[] | CheckboxProps) => {
if (!isCheckboxProp(values)) {
const el = createEl('input', {
type: 'checkbox',
attr: {
disabled: true
}
});
el.checked = !!values[0]
return el
}
const el = createEl('input', {
type: 'checkbox',
})
if (values && values.checked) {
el.checked = true
}
el.addEventListener('change', async () => {
try {
// Values should contain the data we need
if (!values || !values.path) {
console.error('Missing required checkbox data')
return
}
const file = app.vault.getFileByPath(values.path)
if (!file) {
console.error('File not found:', values.path)
return
}
// Read file content
const content = await app.vault.read(file)
const lines = content.split('\n')
// Get the line containing the task
const lineIndex = values.position?.line
if (lineIndex >= 0 && lineIndex < lines.length) {
// Update the task status
if (el.checked) {
lines[lineIndex] = lines[lineIndex].replace('- [ ]', '- [x]')
} else {
lines[lineIndex] = lines[lineIndex].replace('- [x]', '- [ ]')
}
// Write back to the file
await app.vault.modify(file, lines.join('\n'))
}
} catch (error) {
console.error('Error updating task:', error)
}
})
return el
}

View file

@ -0,0 +1,12 @@
export const isLinkLocal = (link: string) => !link?.trim().startsWith('http')
export const isLinktext = (link: string) => link.startsWith('[[') && link.endsWith(']]')
export const removeExtension = (filename: string) => {
const parts = filename.split('.')
if (parts.length > 1) {
return parts.slice(0, -1).join('.')
}
return filename
}

25
src/utils/ui/image.ts Normal file
View file

@ -0,0 +1,25 @@
import { App } from "obsidian";
import { isLinkLocal } from "./helperFunctions";
export const renderImage = (app: App) => ([href, path]: [string, string]) => {
if (!isLinkLocal(href)) {
return createEl('img', { attr: { src: href } });
}
href = (href ?? '').trim()
if (href.startsWith('![[')) {
href = href.slice(3, -2)
}
let parentPath = ''
if (path) {
const parent = app.vault.getFileByPath(path)
if (parent) {
parentPath = parent.parent?.path ?? ''
}
}
path = (path ? parentPath + '/' : '') + href
const file = app.vault.getFileByPath(path);
if (!file) {
return 'File does not exist'
}
return createEl('img', { attr: { src: app.vault.getResourcePath(file) } });
}

53
src/utils/ui/link.ts Normal file
View file

@ -0,0 +1,53 @@
import { App } from "obsidian"
import { isLinkLocal, isLinktext, removeExtension } from "./helperFunctions"
import { isStringifiedArray, renderStringifiedArray } from "../ui"
type LinkType = [string] | [string, string]
export const renderLink = (app: App, create = createEl) => ([href, name]: LinkType): string | Node => {
if (!href) {
return ''
}
if (isStringifiedArray(href)) {
try {
return renderStringifiedArray(href, href => renderLink(app, create)([href]))
} catch (e) {
return href
}
}
if (!name) {
name = href
}
href = href.trim()
let cls = ''
if (isLinktext(href)) {
const [path, linkName] = href.slice(2, -2).split('|')
const link = app.metadataCache.getFirstLinkpathDest(path, '')
if (name.trim() === href) {
name = linkName ?? path
}
if (!link) {
href = path
} else {
href = link.path
}
cls = 'internal-link'
} else if (isLinkLocal(href)) {
let fileHref = href
if (fileHref.endsWith('.md')) {
fileHref = fileHref.slice(0, -3)
}
if (name === href) {
const components = href.split('/')
name = removeExtension(components[components.length - 1])
}
cls = 'internal-link'
}
const el = create('a', { text: name, href, cls })
return el
}

View file

@ -39,17 +39,33 @@ export class TasksFileSyncTable extends AFileSyncTable {
const taskContent = lineContent.substring(
lineContent.indexOf(']') + 1
).trim();
const checkboxData = {
type: "checkbox",
values: {
checked: status,
path: file.path,
task: taskContent,
position: {
line: listItem.position.start.line,
lineContent: lineContent
}
}
};
return {
filePath: file.path,
path: file.path,
task: taskContent,
completed: status ? 1 : 0
completed: status ? 1 : 0,
position: listItem.position.start.line,
checkbox: `SQLSEALCUSTOM(${JSON.stringify(checkboxData)})`
}
}).filter(t => !!t)
}
async onInit(): Promise<void> {
await this.db.createTableNoTypes('tasks', ['task', 'completed', 'filePath', 'path'])
await this.db.createTableNoTypes('tasks', ['checkbox', 'task', 'completed', 'filePath', 'path', 'position'])
// Indexes
const toIndex = ['filePath']