diff --git a/.changeset/orange-showers-argue.md b/.changeset/orange-showers-argue.md new file mode 100644 index 0000000..8161497 --- /dev/null +++ b/.changeset/orange-showers-argue.md @@ -0,0 +1,5 @@ +--- +"sqlseal": minor +--- + +Adding tasks statuses support diff --git a/docs/data-sources/tasks-statuses.png b/docs/data-sources/tasks-statuses.png new file mode 100644 index 0000000..7b8b444 Binary files /dev/null and b/docs/data-sources/tasks-statuses.png differ diff --git a/docs/data-sources/vault-data.md b/docs/data-sources/vault-data.md index 011fd42..bc7d0a6 100644 --- a/docs/data-sources/vault-data.md +++ b/docs/data-sources/vault-data.md @@ -44,6 +44,7 @@ Tasks table consists of the following columns: | --------------- |--------------------------------------------------------------------------------------------------------------------------| ------------- | | `task` | Content of the task (text) | | | `completed` | 0 if not completed, 1 if completed | | +| `status` | The raw checkbox character (` ` for unchecked, `x` for completed, `/` for in-progress, `-` for cancelled, etc.) | 0.40.0 | | `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 | @@ -88,3 +89,9 @@ WHERE target = @path AND json_extract(position, '$.frontmatterKey') = 'type' ``` +## Task Status Field + +The `status` column in the `tasks` table contains the raw checkbox character from your markdown tasks. This enables querying tasks by their specific states beyond just completed/uncompleted. This is functionality used commonly in plugins like Tasks and Obsidian natively supports them too. Many themes also render these fields correctly by default. You can read more about it in [the Tasks documentation](https://publish.obsidian.md/tasks/Getting+Started/Statuses#Credit%20Sytone%20and%20the%20'Tasks%20SQL%20Powered'%20plugin). + +### Example +![Tasks Statuses](tasks-statuses.png) diff --git a/src/modules/sync/sync/tables/tasksTable.test.ts b/src/modules/sync/sync/tables/tasksTable.test.ts new file mode 100644 index 0000000..6fad7d6 --- /dev/null +++ b/src/modules/sync/sync/tables/tasksTable.test.ts @@ -0,0 +1,96 @@ +/** + * Tests for the `status` column added to the tasks table (main-2xf). + * + * `getFileTasks()` is an instance method that calls `this.app` and `this.db`, + * so we test the pure data-shaping logic by replicating it here rather than + * mocking the full Obsidian API surface. + */ + +interface ListItemMock { + task: string // raw checkbox char: ' ', 'x', '/', '-', '<', '>', etc. + position: { start: { line: number } } +} + +/** Mirrors the core logic of getFileTasks() in tasksTable.ts */ +function shapeTask(listItem: ListItemMock, lineContent: string, filePath: string) { + if (!listItem.task) return undefined + + const status = listItem.task !== ' ' + const taskContent = lineContent.substring(lineContent.indexOf(']') + 1).trim() + + return { + filePath, + path: filePath, + task: taskContent, + completed: status ? 1 : 0, + status: listItem.task, + position: listItem.position.start.line, + } +} + +describe('tasks table — status column (main-2xf)', () => { + describe('status value reflects the raw checkbox character', () => { + it('is " " (space) for an unchecked task', () => { + const item: ListItemMock = { task: ' ', position: { start: { line: 0 } } } + const row = shapeTask(item, '- [ ] Buy groceries', 'note.md') + expect(row?.status).toBe(' ') + }) + + it('is "x" for a standard completed task', () => { + const item: ListItemMock = { task: 'x', position: { start: { line: 1 } } } + const row = shapeTask(item, '- [x] Done', 'note.md') + expect(row?.status).toBe('x') + }) + + it('is "/" for an in-progress task (Tasks plugin custom status)', () => { + const item: ListItemMock = { task: '/', position: { start: { line: 2 } } } + const row = shapeTask(item, '- [/] In progress', 'note.md') + expect(row?.status).toBe('/') + }) + + it('is "-" for a cancelled task', () => { + const item: ListItemMock = { task: '-', position: { start: { line: 3 } } } + const row = shapeTask(item, '- [-] Cancelled', 'note.md') + expect(row?.status).toBe('-') + }) + + it('is ">" for a forwarded/rescheduled task', () => { + const item: ListItemMock = { task: '>', position: { start: { line: 4 } } } + const row = shapeTask(item, '- [>] Rescheduled', 'note.md') + expect(row?.status).toBe('>') + }) + }) + + describe('completed column is unaffected', () => { + it('completed=0 when status is space', () => { + const item: ListItemMock = { task: ' ', position: { start: { line: 0 } } } + expect(shapeTask(item, '- [ ] Pending', 'note.md')?.completed).toBe(0) + }) + + it('completed=1 when status is "x"', () => { + const item: ListItemMock = { task: 'x', position: { start: { line: 0 } } } + expect(shapeTask(item, '- [x] Done', 'note.md')?.completed).toBe(1) + }) + + it('completed=1 for custom non-space chars (any char != space counts as complete)', () => { + // This matches the existing semantics: status !== ' ' → completed + const item: ListItemMock = { task: '/', position: { start: { line: 0 } } } + expect(shapeTask(item, '- [/] In progress', 'note.md')?.completed).toBe(1) + }) + }) + + describe('non-task list items are excluded', () => { + it('returns undefined when listItem.task is empty string (plain list item)', () => { + const item = { task: '', position: { start: { line: 0 } } } + expect(shapeTask(item, '- plain list item', 'note.md')).toBeUndefined() + }) + }) + + describe('task content extraction is unaffected', () => { + it('strips the checkbox syntax and returns just the content', () => { + const item: ListItemMock = { task: 'x', position: { start: { line: 0 } } } + const row = shapeTask(item, '- [x] Buy milk', 'note.md') + expect(row?.task).toBe('Buy milk') + }) + }) +}) diff --git a/src/modules/sync/sync/tables/tasksTable.ts b/src/modules/sync/sync/tables/tasksTable.ts index 96b8d12..1431ca5 100644 --- a/src/modules/sync/sync/tables/tasksTable.ts +++ b/src/modules/sync/sync/tables/tasksTable.ts @@ -47,6 +47,7 @@ export class TasksFileSyncTable extends AFileSyncTable { checked: status, path: file.path, task: taskContent, + status: listItem.task, position: { line: listItem.position.start.line, lineContent: lineContent @@ -63,6 +64,7 @@ export class TasksFileSyncTable extends AFileSyncTable { path: file.path, task: taskContent, completed: status ? 1 : 0, + status: listItem.task, position: listItem.position.start.line, checkbox: `SQLSEALCUSTOM(${JSON.stringify(checkboxData)})`, heading: heading ? heading.heading : undefined, @@ -72,7 +74,7 @@ export class TasksFileSyncTable extends AFileSyncTable { } async onInit(): Promise { - await this.db.createTableNoTypes('tasks', ['checkbox', 'task', 'completed', 'filePath', 'path', 'position', 'heading', 'heading_level']) + await this.db.createTableNoTypes('tasks', ['checkbox', 'task', 'completed', 'status', 'filePath', 'path', 'position', 'heading', 'heading_level']) // Indexes const toIndex = ['filePath'] diff --git a/src/modules/syntaxHighlight/cellParser/parser/checkbox.ts b/src/modules/syntaxHighlight/cellParser/parser/checkbox.ts index b46f438..dfa8657 100644 --- a/src/modules/syntaxHighlight/cellParser/parser/checkbox.ts +++ b/src/modules/syntaxHighlight/cellParser/parser/checkbox.ts @@ -11,7 +11,8 @@ interface CheckboxProps { line: number, lineContent: string }, - task: string + task: string, + status?: string } const isCheckboxProp = (arg: any): arg is CheckboxProps => { @@ -45,7 +46,8 @@ export class CheckboxParser implements CellFunction { const el = createEl('input', { type: 'checkbox', attr: { - checked: !!(values && values.checked) ? true : null + checked: !!(values && values.checked) ? true : null, + 'data-task': values.status || ' ' } }) @@ -73,11 +75,26 @@ export class CheckboxParser implements CellFunction { // Get the line containing the task const lineIndex = values.position?.line if (lineIndex >= 0 && lineIndex < lines.length) { + // Get current status + const currentStatus = values.status || (values.checked ? 'x' : ' ') + + // Escape special regex characters + const escapedStatus = currentStatus.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Update the task status if (el.checked) { - lines[lineIndex] = lines[lineIndex].replace('- [ ]', '- [x]') + // Mark as completed + lines[lineIndex] = lines[lineIndex].replace( + new RegExp(`- \\[${escapedStatus}\\]`), + '- [x]' + ) } else { - lines[lineIndex] = lines[lineIndex].replace('- [x]', '- [ ]') + // Restore original status + const originalStatus = currentStatus === 'x' ? ' ' : currentStatus + lines[lineIndex] = lines[lineIndex].replace( + '- [x]', + `- [${originalStatus}]` + ) } // Write back to the file @@ -98,10 +115,15 @@ export class CheckboxParser implements CellFunction { } return '[ ]' } else { + // Use status if available + if (values.status) { + return `[${values.status}]` + } + // Fall back to checked boolean if (values.checked) { return '[x]' } else { - return '[ ] ' + return '[ ]' } } }