feat: new command parser

It's not fully hooked up to the react app, and it's not quite complete
either, but it's a start.
Currently, it will parse filter implicitly, and there's also the done
and add command. For now, all commands must place the command name in
the right location.

Additionally, I've hooked up the react table's selection state to the
command parser. Filter based on _ids_ explicitly does work. Manually
selecting an ID will also place it in the input field for
discoverability. It does not remove ids that were manually deselected
yet, nor does filter based on any other attribute work.

The command parser also parses times, but it does so very poorly
currently. More work will need to go into that, for now it's basically a
stub. I'll also need to convert the parser to Go.

The react table conviently handles virtual tables based on it's internal
`row.id`. I'm still wondering how to implement virtual ids, but at least
on the web app, they are constantly updated since the view is always
visible, which is different to the CLI (though perhaps the same as a
                                               TUI?)

Notably, there's no semantics for the difference between selection and
filtering. I think perhaps the implicit filter command should default to
selection, but the explicit filter command should be handled
differently, and actually execute filtering. I haven't yet looked into
how the TanStack Table handles filtering, but I am aware that it does.

I think for an MVP, once done is implemented, I can deploy this, the web
socket server, and try and start using it.
This commit is contained in:
Anish Lakhwara 2024-11-10 02:15:10 -08:00
parent 73e41285a2
commit 791cd547ae
4 changed files with 1508 additions and 9 deletions

View file

@ -8,6 +8,7 @@ import {
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { DataTable } from "@/components/ui/data-table" import { DataTable } from "@/components/ui/data-table"
import * as addParser from '../../parser/add_parser.js'; import * as addParser from '../../parser/add_parser.js';
import * as commandParser from '../../parser/command_parser.js';
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -49,13 +50,31 @@ export const columns: ColumnDef<Todo>[] = [
className="translate-y-[2px]" className="translate-y-[2px]"
/> />
), ),
cell: ({ row }) => ( cell: ({ row, table }) => (
<div className="flex items-center gap-2">
<p>{row.index+1}</p>
<Checkbox <Checkbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => {
row.toggleSelected(!!value);
// Get setNewText from table meta
const setNewText = table.options.meta?.setNewText;
const NewText = table.options.meta?.newText;
if (setNewText && value === true) {
const calculatedRowId = row.index+1
setNewText(NewText+calculatedRowId+",");
} else {
// TODO:
// We need to _remove_ the value here
// That probably means taking the NewText
// Removing the first instance of "row.index,"
// And then setting that with setNewText
}
}}
aria-label="Select row" aria-label="Select row"
className="translate-y-[2px]" className="translate-y-[2px]"
/> />
</div>
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
@ -82,6 +101,7 @@ export const columns: ColumnDef<Todo>[] = [
function App({ ctx }) { function App({ ctx }) {
const todos = useQuery(ctx, "SELECT * FROM todos").data; const todos = useQuery(ctx, "SELECT * FROM todos").data;
const [newText, setNewText] = useState(""); const [newText, setNewText] = useState("");
const [rowSelection, setRowSelection] = useState({});
const addTodo = (e) => { const addTodo = (e) => {
if (e.key === "Enter" && e.target.value.trim() !== "") { if (e.key === "Enter" && e.target.value.trim() !== "") {
@ -97,16 +117,54 @@ function App({ ctx }) {
} }
} }
const parseTodos = (e) => {
if (e.target.value.trim() !== "") {
try {
const parsed = commandParser.parse(e.target.value);
console.log(parsed)
if (parsed.filters && parsed.filters.length > 0) {
const idFilters = parsed.filters.filter(f => f.type === "id");
console.log(idFilters)
// Create new selection state
const newSelection = {};
idFilters.forEach(filter => {
filter.ids.forEach(id => {
newSelection[id-1] = true;
})
});
setRowSelection(newSelection);
}
} catch (error) {
console.log("Unable to parse field")
return;
}
} else {
setRowSelection({});
}
}
return ( return (
<> <>
<section className="pt-8 container"> <section className="pt-8 container">
<Input type="text" <Input type="text"
className="bg-background p-4" className="bg-background p-4"
onKeyUp={addTodo} placeholder="..."
value={newText}
onKeyUp={parseTodos}
onChange={(e) => setNewText(e.target.value)} onChange={(e) => setNewText(e.target.value)}
/> />
<div className="p-2"/> <div className="p-2"/>
<DataTable columns={columns} data={todos} /> <DataTable
columns={columns}
data={todos}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
setNewText={setNewText}
newText={newText}
/>
</section> </section>
</> </>
) )

View file

@ -5,6 +5,7 @@ import { useState } from "react"
import { import {
ColumnDef, ColumnDef,
ColumnSizingState, ColumnSizingState,
RowSelectionState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
@ -22,11 +23,17 @@ import {
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[] columns: ColumnDef<TData, TValue>[]
data: TData[] data: TData[]
rowSelection: {}
setRowSelection: (value: {}) => void
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
columns, columns,
data, data,
rowSelection,
setNewText,
newText,
setRowSelection,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [colSizing, setColSizing] = useState<ColumnSizingState>({}); const [colSizing, setColSizing] = useState<ColumnSizingState>({});
const table = useReactTable({ const table = useReactTable({
@ -36,9 +43,15 @@ export function DataTable<TData, TValue>({
columnResizeMode: "onChange", columnResizeMode: "onChange",
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
onColumnSizingChange: setColSizing, onColumnSizingChange: setColSizing,
onRowSelectionChange: setRowSelection,
state: { state: {
columnSizing: colSizing, columnSizing: colSizing,
rowSelection: rowSelection,
}, },
meta: {
setNewText,
newText,
}
}) })
return ( return (

143
parser/command_js.peg Normal file
View file

@ -0,0 +1,143 @@
{
function makeCommand(type, filters, parts) {
const validParts = parts || [];
// Extract pure description (without attributes)
const description = validParts
.filter(part => typeof part === 'string')
.join(' ')
.trim();
// Collect all attributes
const attributes = validParts
.filter(part => typeof part === 'object')
.filter(part => part !== null);
// Store original parts for reconstruction
const originalParts = validParts.filter(part => part !== null);
return {
type: type,
filters: filters.flat() || [],
description: description || [],
attributes: attributes || [],
parts: parts || [],
reconstruct: function() {
const filterStr = this.filters.map(f => f.reconstruct()).join(',');
const partsStr = this.parts.map(p => p.reconstruct()).join(' ');
return [filterStr, this.type, partsStr].filter(Boolean).join(' ');
}
};
}
}
Start = AddCommand / DoneCommand / ExplicitFilterCommand / ImplicitFilterCommand
// ADD COMMAND
AddCommand = "add" _ parts:(Part / _)+ EOF {
return makeCommand('add', null, parts.filter(p => p !== null));
}
// DONE COMMAND
DoneCommand = filters:Filters _ "done" EOF {
return makeCommand('done', filters, null);
}
ExplicitFilterCommand = filters:Filters _ "filter" _* moreFilters:Filters* EOF {
return makeCommand('filter', [...filters, ...moreFilters], null);
}
ImplicitFilterCommand = filters:Filters+ EOF {
return makeCommand('filter', filters, null);
}
Filters = first:Filter rest:(_ Filter)* {
return [first, ...rest.map(r => r[1])];
}
IdRange = start:Integer "-" end:Integer {
const ids = [];
for (let i = start; i <= end; i++) {
ids.push(i);
}
return ids;
}
SingleId = id:Integer {
return [id];
}
Filter = IdFilter / Tag / Project / Priority / Due
IdFilter = first:(IdRange / SingleId) rest:("," (IdRange / SingleId))* trailing:"," ? {
const ids = [first, ...rest.map(r => r[1])].flat();
return {
type: 'id',
ids: ids,
reconstruct: function() { return this.ids.join(','); }
};
}
Part = Attribute / TextPart
TextPart = chars:Word {
return {
type: "text",
value: chars,
reconstruct: function() { return this.value; }
};
}
Attribute = Due / Tag / Project / Priority
Due = "@" value:TimeValue {
return {
type: "due",
value: value,
reconstruct: function() { return `@${this.value}`; }
};
}
Project = ("pro:" / "project:" / "+") value:Word {
return {
type: "project",
value: value,
reconstruct: function() { return `+${this.value}`; }
};
}
Priority = "priority:" value:[HML] {
return {
type: "priority",
value: value,
reconstruct: function() { return `priority:${this.value}`; }
};
}
Tag = "#" value:Word {
return {
type: "tag",
value: value,
reconstruct: function() { return `#${this.value}`; }
};
}
Integer = digits:[0-9]+ {
return parseInt(digits.join(''), 10);
}
TimeValue = chars:[0-9:]+ ("am" / "pm")? {
return chars.join('') + (text() || '');
}
Word = chars:[a-zA-Z0-9_-]+ {
return chars.join('');
}
_ = [ \t]+ {
return null;
}
EOF = !.

1285
parser/command_parser.js Normal file

File diff suppressed because it is too large Load diff