diff --git a/docs/data-sources/vault-data.md b/docs/data-sources/vault-data.md index 58212e0..fe3f862 100644 --- a/docs/data-sources/vault-data.md +++ b/docs/data-sources/vault-data.md @@ -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. diff --git a/docs/links-and-images.md b/docs/links-and-images.md index 149f2d0..92ac3f3 100644 --- a/docs/links-and-images.md +++ b/docs/links-and-images.md @@ -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 -``` \ No newline at end of file +``` + +### 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. + diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 0e17060..e2a7cb2 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -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) diff --git a/src/utils/ui/checkbox.ts b/src/utils/ui/checkbox.ts new file mode 100644 index 0000000..b084209 --- /dev/null +++ b/src/utils/ui/checkbox.ts @@ -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 +} \ No newline at end of file diff --git a/src/utils/ui/helperFunctions.ts b/src/utils/ui/helperFunctions.ts new file mode 100644 index 0000000..25b7e34 --- /dev/null +++ b/src/utils/ui/helperFunctions.ts @@ -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 +} \ No newline at end of file diff --git a/src/utils/ui/image.ts b/src/utils/ui/image.ts new file mode 100644 index 0000000..632d068 --- /dev/null +++ b/src/utils/ui/image.ts @@ -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) } }); +} \ No newline at end of file diff --git a/src/utils/ui/link.ts b/src/utils/ui/link.ts new file mode 100644 index 0000000..40aa8b9 --- /dev/null +++ b/src/utils/ui/link.ts @@ -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 +} \ No newline at end of file diff --git a/src/vaultSync/tables/tasksTable.ts b/src/vaultSync/tables/tasksTable.ts index 3dd4280..f7a3188 100644 --- a/src/vaultSync/tables/tasksTable.ts +++ b/src/vaultSync/tables/tasksTable.ts @@ -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 { - await this.db.createTableNoTypes('tasks', ['task', 'completed', 'filePath', 'path']) + await this.db.createTableNoTypes('tasks', ['checkbox', 'task', 'completed', 'filePath', 'path', 'position']) // Indexes const toIndex = ['filePath']