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.test.ts b/src/utils/ui.test.ts index 1b6b8bc..d5b4078 100644 --- a/src/utils/ui.test.ts +++ b/src/utils/ui.test.ts @@ -1,4 +1,4 @@ -import { renderLink } from "./ui" +import { renderLink } from "./ui/link" const makeApp = () => ({ metadataCache: { getFirstLinkpathDest: jest.fn(p => ({ path: p })) } }) 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.test.ts b/src/utils/ui/helperFunctions.test.ts new file mode 100644 index 0000000..461d677 --- /dev/null +++ b/src/utils/ui/helperFunctions.test.ts @@ -0,0 +1,67 @@ +import { isLinkLocal, isLinktext, removeExtension } from './helperFunctions'; + +describe('helperFunctions', () => { + describe('isLinkLocal', () => { + it('should return true for local links', () => { + expect(isLinkLocal('local/file.md')).toBe(true); + expect(isLinkLocal('folder/file')).toBe(true); + expect(isLinkLocal('file')).toBe(true); + }); + + it('should return false for http links', () => { + expect(isLinkLocal('http://example.com')).toBe(false); + expect(isLinkLocal('https://example.com')).toBe(false); + }); + + it('should handle empty or undefined input', () => { + expect(isLinkLocal('')).toBe(true); + expect(isLinkLocal(undefined as any)).toBe(true); + }); + }); + + describe('isLinktext', () => { + it('should return true for valid linktext format', () => { + expect(isLinktext('[[file]]')).toBe(true); + expect(isLinktext('[[folder/file]]')).toBe(true); + expect(isLinktext('[[file with spaces]]')).toBe(true); + }); + + it('should return false for invalid linktext format', () => { + expect(isLinktext('file')).toBe(false); + expect(isLinktext('[file]')).toBe(false); + expect(isLinktext('[[file')).toBe(false); + expect(isLinktext('file]]')).toBe(false); + }); + }); + + describe('removeExtension', () => { + it('should remove recognized file extensions', () => { + expect(removeExtension('file.md')).toBe('file'); + expect(removeExtension('document.csv')).toBe('document'); + expect(removeExtension('data.json')).toBe('data'); + expect(removeExtension('config.json5')).toBe('config'); + }); + + it('should handle files with multiple dots', () => { + expect(removeExtension('my.file.md')).toBe('my.file'); + expect(removeExtension('config.min.js')).toBe('config.min.js'); // .js is not recognized + }); + + it('should return original filename if no extension', () => { + expect(removeExtension('file')).toBe('file'); + expect(removeExtension('document')).toBe('document'); + }); + + it('should handle filenames starting with dot', () => { + expect(removeExtension('.gitignore')).toBe('.gitignore'); + }); + + it('should not remove unrecognized extensions', () => { + expect(removeExtension('this.com')).toBe('this.com'); + expect(removeExtension('this.com.md')).toBe('this.com'); + expect(removeExtension('file.txt')).toBe('file.txt'); + expect(removeExtension('image.jpg')).toBe('image.jpg'); + expect(removeExtension('script.js')).toBe('script.js'); + }); + }); +}); \ 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..9015e40 --- /dev/null +++ b/src/utils/ui/helperFunctions.ts @@ -0,0 +1,16 @@ +export const isLinkLocal = (link: string) => !link?.trim().startsWith('http') + +export const isLinktext = (link: string) => link.startsWith('[[') && link.endsWith(']]') + +const RECOGNIZED_EXTENSIONS = ['md', 'csv', 'json', 'json5'] + +export const removeExtension = (filename: string) => { + const parts = filename.split('.') + if (parts.length > 1) { + const lastPart = parts[parts.length - 1].toLowerCase() + if (RECOGNIZED_EXTENSIONS.includes(lastPart)) { + 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'] diff --git a/src/view/SidebarView.ts b/src/view/SidebarView.ts new file mode 100644 index 0000000..e77f999 --- /dev/null +++ b/src/view/SidebarView.ts @@ -0,0 +1,159 @@ +import { ItemView, TFile, WorkspaceLeaf } from 'obsidian'; +import SqlSealPlugin from '../main'; + +export const SIDEBAR_VIEW_TYPE = 'sql-seal-sidebar-view'; + +export class SidebarView extends ItemView { + private currentFile: TFile | null = null; + public contentEl: HTMLElement; + private plugin: SqlSealPlugin; + + constructor(leaf: WorkspaceLeaf, plugin: SqlSealPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return SIDEBAR_VIEW_TYPE; + } + + getDisplayText(): string { + return 'SQL Seal Sidebar'; + } + + async onload(): Promise { + super.onload(); + + // Create container for the sidebar content + this.contentEl = this.containerEl.createDiv('sql-seal-sidebar-container'); + + // Register event handler for file changes + this.registerEvent( + this.app.workspace.on('file-open', (file: TFile | null) => { + this.currentFile = file; + this.updateView(); + }) + ); + } + + async updateView(): Promise { + this.contentEl.empty(); + + if (!this.currentFile) { + this.contentEl.createEl('div', { + text: 'No file is currently open', + cls: 'sql-seal-sidebar-empty' + }); + return; + } + + try { + // Get file metadata or frontmatter + const fileCache = this.app.metadataCache.getFileCache(this.currentFile); + const frontmatter = fileCache?.frontmatter || {}; + + // Add file-specific properties to frontmatter + const enhancedFrontmatter = { + ...frontmatter, + '@file': { + name: this.currentFile.name, + path: this.currentFile.path, + basename: this.currentFile.basename, + extension: this.currentFile.extension, + stat: { + ctime: this.currentFile.stat.ctime, + mtime: this.currentFile.stat.mtime, + size: this.currentFile.stat.size + } + }, + '@tags': fileCache?.tags?.map(tag => tag.tag) || [], + '@links': fileCache?.links?.map(link => link.link) || [], + '@headings': fileCache?.headings?.map(h => ({ + text: h.heading, + level: h.level + })) || [] + }; + + // Check if there's a specific SQL query defined in frontmatter + const query = frontmatter?.['sql-seal-query']; + const view = frontmatter?.['sql-seal-view'] || 'table'; // Default to table view + + if (query) { + const result = await this.plugin.sqlSeal.db.select(query, enhancedFrontmatter); + this.renderQueryResults(result.data, view); + } else { + this.contentEl.createEl('div', { + text: 'No SQL query defined for this file', + cls: 'sql-seal-sidebar-empty' + }); + } + } catch (error) { + this.contentEl.createEl('div', { + text: `Error: ${error.message}`, + cls: 'sql-seal-sidebar-error' + }); + } + } + + private renderQueryResults(results: any[], view: string): void { + if (!results || results.length === 0) { + this.contentEl.createEl('div', { + text: 'No results found', + cls: 'sql-seal-sidebar-empty' + }); + return; + } + + if (view === 'list') { + this.renderListView(results); + } else { + this.renderTableView(results); + } + } + + private renderTableView(results: any[]): void { + const table = this.contentEl.createEl('table', { + cls: 'sql-seal-sidebar-table' + }); + + // Create header row + const headerRow = table.createEl('tr'); + Object.keys(results[0]).forEach(key => { + headerRow.createEl('th', { text: key }); + }); + + // Create data rows + results.forEach(row => { + const tr = table.createEl('tr'); + Object.values(row).forEach(value => { + tr.createEl('td', { text: String(value) }); + }); + }); + } + + private renderListView(results: any[]): void { + const list = this.contentEl.createEl('ul', { + cls: 'sql-seal-sidebar-list' + }); + + results.forEach(row => { + const li = list.createEl('li'); + Object.entries(row).forEach(([key, value]) => { + const span = li.createEl('span', { + cls: 'sql-seal-sidebar-list-item' + }); + span.createEl('span', { + text: key, + cls: 'sql-seal-sidebar-list-key' + }); + span.createEl('span', { + text: ': ' + }); + span.createEl('span', { + text: String(value), + cls: 'sql-seal-sidebar-list-value' + }); + }); + }); + } +} \ No newline at end of file