Merge pull request #121 from h-sphere/feat/interactive-task-checkboxes

Feat/interactive task checkboxes
This commit is contained in:
Kacper Kula 2025-03-21 17:37:43 +00:00 committed by GitHub
commit aa3dc772b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 448 additions and 102 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,4 +1,4 @@
import { renderLink } from "./ui"
import { renderLink } from "./ui/link"
const makeApp = () => ({ metadataCache: { getFirstLinkpathDest: jest.fn(p => ({ path: p })) } })

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,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');
});
});
});

View file

@ -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
}

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']

159
src/view/SidebarView.ts Normal file
View file

@ -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<void> {
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<void> {
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'
});
});
});
}
}