├── .gitignore ├── go.mod ├── examples ├── todos │ ├── todo-basic.md │ ├── todo-states.md │ ├── todo-nested.md │ ├── todo-project.md │ └── todo-long.md └── misc │ ├── test.md │ ├── tasks.md │ └── movie_reviews.md ├── flake.lock ├── Makefile ├── sorting.go ├── LICENSE ├── flake.nix ├── main_test.go ├── README.md └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | dynomark 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/k-lar/dynomark 2 | 3 | go 1.22.5 4 | -------------------------------------------------------------------------------- /examples/todos/todo-basic.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: My basic TODOs 3 | --- 4 | 5 | # My TODOs 6 | 7 | - [ ] Write a blog post about my latest project 8 | - [X] Read a book on compiler design 9 | - [X] Practice coding algorithms 10 | - [ ] Attend a local tech meetup 11 | - [X] Organize my workspace 12 | - [X] Learn a new programming language 13 | -------------------------------------------------------------------------------- /examples/todos/todo-states.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: A file for my TODOs with different states 3 | --- 4 | 5 | # Test file for my TODOs with different states of completion 6 | 7 | - [ ] This one isn't done yet 8 | - [x] This one is done 9 | - [X] This one is done too 10 | - [.] This one has been started 11 | - [o] This one has some progress 12 | - [0] This one has some more progress (makes sense if your font has a dot in the middle of the zero) 13 | 14 | -------------------------------------------------------------------------------- /examples/todos/todo-nested.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Weirdly nested TODOs 3 | --- 4 | 5 | # My weird nested TODOs that should still work 6 | 7 | - [X] This one is done 8 | - [x] Yeah, this one is done too 9 | - [ ] This one is not done 10 | - [ ] Wow, a space in front of the dash 11 | - [ ] Another one 12 | - [ ] Three spaces in front of the dash 13 | - [ ] Four spaces in front of the dash 14 | 15 | - [ ] This one is indented with a tab 16 | - [ ] Another one indented with a tab 17 | - [ ] A tab and 4 spaces 18 | - [ ] A tab and 9 spaces! 19 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1745930157, 6 | "narHash": "sha256-y3h3NLnzRSiUkYpnfvnS669zWZLoqqI6NprtLQ+5dck=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "46e634be05ce9dc6d4db8e664515ba10b78151ae", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /examples/misc/test.md: -------------------------------------------------------------------------------- 1 | # Test Markdown File 2 | 3 | ## Tasks 4 | 5 | - [ ] Implement DynoMark parser 6 | - [ ] Implement DynoMark parser but better 7 | - [x] Create test markdown file 8 | - [ ] Write unit tests 9 | - [x] Design CLI interface 10 | 11 | ## Lists 12 | 13 | ### Unordered List 14 | 15 | - Item 1 16 | - Item 2 17 | - Item 3 that's 18 | like really 19 | really 20 | really 21 | long 22 | - Item 4 23 | 24 | ### Ordered List 25 | 26 | 1. First item 27 | 2. Second item 28 | 3. Third item that's 29 | kinda 30 | sorta 31 | long-ish 32 | 4. Fourth item 33 | 34 | ## Code 35 | 36 | Here's a sample code block: 37 | 38 | ```go 39 | func main() { 40 | fmt.Println("Hello, DynoMark!") 41 | } 42 | ``` 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX ?= /usr 2 | BINARY_NAME=dynomark 3 | 4 | all: build 5 | 6 | build: 7 | go build -o ${BINARY_NAME} . 8 | 9 | build-macos: 10 | GOOS=darwin GOARCH=amd64 go build -o ${BINARY_NAME} . 11 | 12 | build-windows: 13 | GOOS=windows GOARCH=amd64 go build -o ${BINARY_NAME}.exe . 14 | 15 | test: 16 | go test -v ./... 17 | 18 | run: 19 | go build -o ${BINARY_NAME} . 20 | ./${BINARY_NAME} 21 | 22 | install: 23 | @# Create the bin directory if it doesn't exist (Mac doesn't support install -D) 24 | @if [ ! -d $(DESTDIR)$(PREFIX)/bin ]; then \ 25 | mkdir -p $(DESTDIR)$(PREFIX)/bin; \ 26 | fi 27 | 28 | @install -m755 ${BINARY_NAME} $(DESTDIR)$(PREFIX)/bin/${BINARY_NAME} 29 | 30 | uninstall: 31 | @rm -f $(DESTDIR)$(PREFIX)/bin/${BINARY_NAME} 32 | 33 | clean: 34 | go clean 35 | rm ${BINARY_NAME} 36 | 37 | -------------------------------------------------------------------------------- /sorting.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "unicode" 6 | ) 7 | 8 | func NaturalSort(s1, s2 string) bool { 9 | i, j := 0, 0 10 | for i < len(s1) && j < len(s2) { 11 | c1, c2 := s1[i], s2[j] 12 | 13 | if unicode.IsDigit(rune(c1)) && unicode.IsDigit(rune(c2)) { 14 | num1, nextI := extractNumber(s1, i) 15 | num2, nextJ := extractNumber(s2, j) 16 | if num1 != num2 { 17 | return num1 < num2 18 | } 19 | i, j = nextI, nextJ 20 | } else { 21 | // Compare as strings instead 22 | if c1 != c2 { 23 | return c1 < c2 24 | } 25 | i++ 26 | j++ 27 | } 28 | } 29 | 30 | // If reached the end of one of the strings, the shorter string is less 31 | return len(s1) < len(s2) 32 | } 33 | 34 | func extractNumber(s string, i int) (int, int) { 35 | start := i 36 | for i < len(s) && unicode.IsDigit(rune(s[i])) { 37 | i++ 38 | } 39 | num, _ := strconv.Atoi(s[start:i]) 40 | return num, i 41 | } 42 | -------------------------------------------------------------------------------- /examples/misc/tasks.md: -------------------------------------------------------------------------------- 1 | # Some todos 2 | 3 | Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim labore 4 | culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet. Nisi anim 5 | cupidatat excepteur officia. Reprehenderit nostrud nostrud ipsum Lorem est aliquip 6 | amet voluptate voluptate dolor minim nulla est proident. Nostrud officia pariatur 7 | ut officia. Sit irure elit esse ea nulla sunt ex occaecat reprehenderit commodo 8 | officia dolor Lorem duis laboris cupidatat officia voluptate. Culpa proident 9 | adipisicing id nulla nisi laboris ex in Lorem sunt duis officia eiusmod. Aliqua 10 | reprehenderit commodo ex non excepteur duis sunt velit enim. Voluptate laboris 11 | sint cupidatat ullamco ut ea consectetur et est culpa et culpa duis. 12 | 13 | - [ ] Task 1 14 | - [X] Task 2 15 | - [ ] Task 3 16 | - [X] Task 4 17 | 18 | ## More todos 19 | 20 | Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint 21 | consectetur cupidatat. 22 | 23 | - [X] Task 5 24 | - [ ] Task 6 25 | - [ ] Task 7 26 | - [X] Task 8 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 K_Lar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A Nix-flake-based Go 1.22 development environment"; 3 | 4 | inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 5 | 6 | outputs = { 7 | self, 8 | nixpkgs, 9 | }: let 10 | goVersion = 24; # Change this to update the whole stack 11 | 12 | supportedSystems = ["x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"]; 13 | forEachSupportedSystem = f: 14 | nixpkgs.lib.genAttrs supportedSystems (system: 15 | f { 16 | pkgs = import nixpkgs { 17 | inherit system; 18 | overlays = [self.overlays.default]; 19 | }; 20 | }); 21 | in { 22 | overlays.default = final: prev: { 23 | go = final."go_1_${toString goVersion}"; 24 | }; 25 | 26 | devShells = forEachSupportedSystem ({pkgs}: { 27 | default = pkgs.mkShell { 28 | packages = with pkgs; [ 29 | # go (version is specified by overlay) 30 | go 31 | 32 | # goimports, godoc, etc. 33 | gotools 34 | 35 | # https://github.com/golangci/golangci-lint 36 | golangci-lint 37 | ]; 38 | }; 39 | }); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /examples/misc/movie_reviews.md: -------------------------------------------------------------------------------- 1 | --- 2 | author: John Doe 3 | tags: tests movie reviews 4 | --- 5 | 6 | # Movie reviews 7 | 8 | **Generic race movie** 9 | 10 | Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim labore culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet. Nisi anim cupidatat excepteur officia. Reprehenderit nostrud nostrud ipsum Lorem est aliquip amet voluptate voluptate dolor minim nulla est proident. Nostrud officia pariatur ut officia. Sit irure elit esse ea nulla sunt ex occaecat reprehenderit commodo officia dolor Lorem duis laboris cupidatat officia voluptate. Culpa proident adipisicing id nulla nisi laboris ex in Lorem sunt duis officia eiusmod. Aliqua reprehenderit commodo ex non excepteur duis sunt velit enim. Voluptate laboris sint cupidatat ullamco ut ea consectetur et est culpa et culpa duis. 11 | 12 | **Generic Action movie** 13 | 14 | Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim labore culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet. Nisi anim cupidatat excepteur officia. Reprehenderit nostrud nostrud ipsum Lorem est aliquip amet voluptate voluptate dolor minim nulla est proident. Nostrud officia pariatur ut officia. Sit irure elit esse ea nulla sunt ex occaecat reprehenderit commodo officia dolor Lorem duis laboris cupidatat officia voluptate. Culpa proident adipisicing id nulla nisi laboris ex in Lorem sunt duis officia eiusmod. Aliqua reprehenderit commodo ex non excepteur duis sunt velit enim. Voluptate laboris sint cupidatat ullamco ut ea consectetur et est culpa et culpa duis. 15 | 16 | What's cool: 17 | 18 | - Stuff 19 | - Cool action 20 | - Cars 21 | - Lighting 22 | -------------------------------------------------------------------------------- /examples/todos/todo-project.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Project TODO" 3 | project: "Acme Client Dashboard Revamp" 4 | owner: "Jane Doe" 5 | team: ["Alice", "Bob", "Carlos", "Jane"] 6 | status: "In Progress" 7 | updated: 2025-05-26 8 | tags: [dashboard, frontend, backend, QA, client-project] 9 | --- 10 | 11 | # TODO List: Acme Client Dashboard Revamp 12 | 13 | ## ☑️ Project Overview 14 | - [X] Kickoff meeting with stakeholders 15 | - [X] Define scope and deliverables 16 | - [X] Setup project repository 17 | - [x] Assign roles and responsibilities 18 | - [.] Initial planning docs created 19 | 20 | ## 🎨 UI/UX Design 21 | - [X] Review current client dashboard 22 | - [O] Wireframes for new layout 23 | - [x] Dashboard overview 24 | - [X] User management section 25 | - [o] Notification center 26 | - [.] Settings page 27 | - [.] Finalize branding guide 28 | - [ ] UX review meeting with client 29 | 30 | ## ⚙️ Frontend Development 31 | - [.] Setup project scaffold (Vite + React + Tailwind) 32 | - [O] Component Library 33 | - [X] Buttons 34 | - [X] Modals 35 | - [O] Cards 36 | - [X] Base Card 37 | - [o] Analytics Card 38 | - [ ] User Profile Card 39 | - [.] Alerts 40 | - [0] Dashboard Page 41 | - [X] Layout and routing 42 | - [O] Data widgets 43 | - [o] Revenue chart 44 | - [O] Recent activity log 45 | - [ ] KPIs panel 46 | - [.] Filter bar and export options 47 | 48 | ## 🛠️ Backend Development 49 | - [O] API Design 50 | - [X] Auth endpoints 51 | - [X] Users CRUD 52 | - [O] Analytics endpoints 53 | - [o] Daily stats 54 | - [ ] Export logs 55 | - [o] Integration with frontend 56 | - [ ] Rate limiting and error handling 57 | - [ ] Logging and monitoring setup 58 | 59 | ## 🔒 Authentication & Security 60 | - [X] Implement OAuth2 with Google 61 | - [.] Role-based access control (RBAC) 62 | - [ ] Audit logging for admin actions 63 | - [ ] Penetration testing (external vendor) 64 | 65 | ## 🧪 QA & Testing 66 | - [.] Unit test coverage (target: 90%) 67 | - [ ] End-to-end tests (Playwright) 68 | - [o] QA checklist document 69 | - [ ] Accessibility audit 70 | 71 | ## 🚀 Deployment 72 | - [.] Setup CI/CD pipeline (GitHub Actions) 73 | - [ ] Dev/Staging/Production environments 74 | - [ ] Final smoke test 75 | - [ ] Launch! 76 | 77 | ## 🗓️ Post-launch 78 | - [ ] Client training session 79 | - [ ] Collect user feedback 80 | - [ ] Prepare v1.1 roadmap 81 | 82 | **Legend:** 83 | 84 | - `[X]` / `[x]` = Done 85 | - `[.]` = Started 86 | - `[o]` = In progress 87 | - `[O]` = More progress 88 | - `[0]` = Almost done 89 | - `[ ]` = Not started 90 | -------------------------------------------------------------------------------- /examples/todos/todo-long.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: My long TODOs file 3 | --- 4 | 5 | # A long test file for TODOs 6 | 7 | - [ ] This is a test TODO 8 | - [ ] This is another test TODO 9 | - [ ] This is a third test TODO 10 | - [ ] This is a fourth test TODO 11 | - [ ] This is a fifth test TODO 12 | - [ ] This is a sixth test TODO 13 | - [ ] This is a seventh test TODO 14 | - [ ] This is an eighth test TODO 15 | - [ ] This is a ninth test TODO 16 | - [ ] This is a tenth test TODO 17 | - [ ] This is an eleventh test TODO 18 | - [ ] This is a twelfth test TODO 19 | - [ ] This is a thirteenth test TODO 20 | - [ ] This is a fourteenth test TODO 21 | - [ ] This is a fifteenth test TODO 22 | - [ ] This is a sixteenth test TODO 23 | - [ ] This is a seventeenth test TODO 24 | - [ ] This is an eighteenth test TODO 25 | - [ ] This is a nineteenth test TODO 26 | - [ ] This is a twentieth test TODO 27 | - [ ] This is a twenty-first test TODO 28 | - [ ] This is a twenty-second test TODO 29 | - [ ] This is a twenty-third test TODO 30 | - [ ] This is a twenty-fourth test TODO 31 | - [ ] This is a twenty-fifth test TODO 32 | - [ ] This is a twenty-sixth test TODO 33 | - [ ] This is a twenty-seventh test TODO 34 | - [ ] This is a twenty-eighth test TODO 35 | - [ ] This is a twenty-ninth test TODO 36 | - [ ] This is a thirtieth test TODO 37 | - [ ] This is a thirty-first test TODO 38 | - [ ] This is a thirty-second test TODO 39 | - [ ] This is a thirty-third test TODO 40 | - [ ] This is a thirty-fourth test TODO 41 | - [ ] This is a thirty-fifth test TODO 42 | - [ ] This is a thirty-sixth test TODO 43 | - [ ] This is a thirty-seventh test TODO 44 | - [ ] This is a thirty-eighth test TODO 45 | - [ ] This is a thirty-ninth test TODO 46 | - [ ] This is a fortieth test TODO 47 | - [ ] This is a forty-first test TODO 48 | - [ ] This is a forty-second test TODO 49 | - [ ] This is a forty-third test TODO 50 | - [ ] This is a forty-fourth test TODO 51 | - [ ] This is a forty-fifth test TODO 52 | - [ ] This is a forty-sixth test TODO 53 | - [ ] This is a forty-seventh test TODO 54 | - [ ] This is a forty-eighth test TODO 55 | - [ ] This is a forty-ninth test TODO 56 | - [ ] This is a fiftieth test TODO 57 | - [ ] This is a fifty-first test TODO 58 | - [ ] This is a fifty-second test TODO 59 | - [ ] This is a fifty-third test TODO 60 | - [ ] This is a fifty-fourth test TODO 61 | - [ ] This is a fifty-fifth test TODO 62 | - [ ] This is a fifty-sixth test TODO 63 | - [ ] This is a fifty-seventh test TODO 64 | - [ ] This is a fifty-eighth test TODO 65 | - [ ] This is a fifty-ninth test TODO 66 | - [ ] This is a sixtieth test TODO 67 | - [ ] This is a sixty-first test TODO 68 | - [ ] This is a sixty-second test TODO 69 | - [ ] This is a sixty-third test TODO 70 | - [ ] This is a sixty-fourth test TODO 71 | - [ ] This is a sixty-fifth test TODO 72 | - [ ] This is a sixty-sixth test TODO 73 | - [ ] This is a sixty-seventh test TODO 74 | - [ ] This is a sixty-eighth test TODO 75 | - [ ] This is a sixty-ninth test TODO 76 | - [ ] This is a seventieth test TODO 77 | - [ ] This is a seventy-first test TODO 78 | - [ ] This is a seventy-second test TODO 79 | - [ ] This is a seventy-third test TODO 80 | - [ ] This is a seventy-fourth test TODO 81 | - [ ] This is a seventy-fifth test TODO 82 | - [ ] This is a seventy-sixth test TODO 83 | - [ ] This is a seventy-seventh test TODO 84 | - [ ] This is a seventy-eighth test TODO 85 | - [ ] This is a seventy-ninth test TODO 86 | - [ ] This is an eightieth test TODO 87 | - [ ] This is an eighty-first test TODO 88 | - [ ] This is an eighty-second test TODO 89 | - [ ] This is an eighty-third test TODO 90 | - [ ] This is an eighty-fourth test TODO 91 | - [ ] This is an eighty-fifth test TODO 92 | - [ ] This is an eighty-sixth test TODO 93 | - [ ] This is an eighty-seventh test TODO 94 | - [ ] This is an eighty-eighth test TODO 95 | - [ ] This is an eighty-ninth test TODO 96 | - [ ] This is a ninetieth test TODO 97 | - [ ] This is a ninety-first test TODO 98 | - [ ] This is a ninety-second test TODO 99 | - [ ] This is a ninety-third test TODO 100 | - [ ] This is a ninety-fourth test TODO 101 | - [ ] This is a ninety-fifth test TODO 102 | - [ ] This is a ninety-sixth test TODO 103 | - [ ] This is a ninety-seventh test TODO 104 | - [ ] This is a ninety-eighth test TODO 105 | - [ ] This is a ninety-ninth test TODO 106 | - [X] This is the hundredth test TODO 107 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type TestQuery struct { 8 | name string 9 | query string 10 | expected string 11 | } 12 | 13 | func runTestQueries(t *testing.T, queries []TestQuery) { 14 | for _, test := range queries { 15 | msg, err := executeQuery(test.query, false) 16 | if err != nil { 17 | t.Errorf("Error executing query: %v", err) 18 | continue 19 | } 20 | if msg != test.expected { 21 | t.Errorf("\nQuery: %s\nExpected output:\n%s\nGot:\n%s", test.query, test.expected, msg) 22 | } 23 | } 24 | } 25 | 26 | func TestListQueries(t *testing.T) { 27 | queries := []TestQuery{ 28 | { 29 | name: "LIST query on a directory", 30 | query: "LIST FROM \"examples/misc/\"", 31 | expected: `- movie_reviews.md 32 | - tasks.md 33 | - test.md`, 34 | }, 35 | { 36 | name: "LIST query on a directory with WHERE clause on file metadata", 37 | query: "LIST FROM \"examples/misc/\" WHERE [file.shortname] IS \"test\"", 38 | expected: `- test.md`, 39 | }, 40 | { 41 | name: "LIST query on a directory with WHERE clause on user defined metadata", 42 | query: "LIST FROM \"examples/misc/\" WHERE [author] IS \"John Doe\"", 43 | expected: `- movie_reviews.md`, 44 | }, 45 | } 46 | 47 | runTestQueries(t, queries) 48 | } 49 | 50 | func TestParagraphQueries(t *testing.T) { 51 | queries := []TestQuery{ 52 | { 53 | name: "PARAGRAPH query with a single file", 54 | query: "PARAGRAPH FROM \"examples/misc/tasks.md\"", 55 | expected: `Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim labore 56 | culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet. Nisi anim 57 | cupidatat excepteur officia. Reprehenderit nostrud nostrud ipsum Lorem est aliquip 58 | amet voluptate voluptate dolor minim nulla est proident. Nostrud officia pariatur 59 | ut officia. Sit irure elit esse ea nulla sunt ex occaecat reprehenderit commodo 60 | officia dolor Lorem duis laboris cupidatat officia voluptate. Culpa proident 61 | adipisicing id nulla nisi laboris ex in Lorem sunt duis officia eiusmod. Aliqua 62 | reprehenderit commodo ex non excepteur duis sunt velit enim. Voluptate laboris 63 | sint cupidatat ullamco ut ea consectetur et est culpa et culpa duis. 64 | 65 | Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint 66 | consectetur cupidatat.`, 67 | }, 68 | { 69 | name: "PARAGRAPH query with a single file and a condition", 70 | query: "PARAGRAPH FROM \"examples/misc/movie_reviews.md\" WHERE CONTAINS \"Generic\"", 71 | expected: `**Generic race movie** 72 | **Generic Action movie**`, 73 | }, 74 | { 75 | name: "PARAGRAPH query with a single file and a negative condition", 76 | query: "PARAGRAPH FROM \"examples/misc/movie_reviews.md\" WHERE NOT CONTAINS \"Lorem\"", 77 | expected: `**Generic race movie** 78 | 79 | 80 | **Generic Action movie** 81 | 82 | 83 | What's cool:`, 84 | }, 85 | } 86 | 87 | runTestQueries(t, queries) 88 | } 89 | 90 | func TestTaskQueries(t *testing.T) { 91 | queries := []TestQuery{ 92 | { 93 | name: "TASK query with a single file", 94 | query: "TASK FROM \"examples/misc/tasks.md\"", 95 | expected: `- [ ] Task 1 96 | - [X] Task 2 97 | - [ ] Task 3 98 | - [X] Task 4 99 | - [X] Task 5 100 | - [ ] Task 6 101 | - [ ] Task 7 102 | - [X] Task 8`, 103 | }, 104 | { 105 | name: "TASK query with a single file and a condition", 106 | query: "TASK FROM \"examples/misc/tasks.md\" WHERE CHECKED", 107 | expected: `- [X] Task 2 108 | - [X] Task 4 109 | - [X] Task 5 110 | - [X] Task 8`, 111 | }, 112 | { 113 | name: "TASK query with a single file and a negative condition", 114 | query: "TASK FROM \"examples/misc/tasks.md\" WHERE NOT CHECKED", 115 | expected: `- [ ] Task 1 116 | - [ ] Task 3 117 | - [ ] Task 6 118 | - [ ] Task 7`, 119 | }, 120 | { 121 | name: "TASK query with a single file and a limit of 3", 122 | query: "TASK FROM \"examples/misc/test.md\" LIMIT 3", 123 | expected: `- [ ] Implement DynoMark parser 124 | - [ ] Implement DynoMark parser but better 125 | - [x] Create test markdown file`, 126 | }, 127 | { 128 | name: "TASK query with a single file with two conditions with OR", 129 | query: "TASK FROM \"examples/misc/test.md\" WHERE CONTAINS \"CLI\" OR CONTAINS \"unit\"", 130 | expected: `- [ ] Write unit tests 131 | - [x] Design CLI interface`, 132 | }, 133 | { 134 | name: "TASK query with a single file and a condition where the task is checked", 135 | query: "TASK FROM \"examples/misc/test.md\" WHERE CHECKED", 136 | expected: `- [x] Create test markdown file 137 | - [x] Design CLI interface`, 138 | }, 139 | { 140 | name: "TASK query with a single file and a condition where the task is not checked", 141 | query: "TASK FROM \"examples/misc/test.md\" WHERE NOT CHECKED", 142 | expected: `- [ ] Implement DynoMark parser 143 | - [ ] Implement DynoMark parser but better 144 | - [ ] Write unit tests`, 145 | }, 146 | { 147 | name: "TASK query with a single file and 3 conditions with OR and AND", 148 | query: "TASK FROM \"examples/misc/test.md\" WHERE CONTAINS \"CLI\" OR CONTAINS \"unit\" AND NOT CHECKED", 149 | expected: `- [ ] Write unit tests`, 150 | }, 151 | } 152 | 153 | runTestQueries(t, queries) 154 | } 155 | 156 | func TestUnorderedListQueries(t *testing.T) { 157 | queries := []TestQuery{ 158 | { 159 | name: "UNORDEREDLIST query with a single file", 160 | query: "UNORDEREDLIST FROM \"examples/misc/test.md\"", 161 | expected: `- Item 1 162 | - Item 2 163 | - Item 3 that's 164 | like really 165 | really 166 | really 167 | long 168 | - Item 4`, 169 | }, 170 | { 171 | name: "UNORDEREDLIST query with a single file and a condition", 172 | query: "UNORDEREDLIST FROM \"examples/misc/test.md\" WHERE CONTAINS \"Item 2\"", 173 | expected: `- Item 2`, 174 | }, 175 | { 176 | name: "UNORDEREDLIST query with a single file and a negative condition", 177 | query: "UNORDEREDLIST FROM \"examples/misc/test.md\" WHERE NOT CONTAINS \"Item 2\"", 178 | expected: `- Item 1 179 | - Item 3 that's 180 | like really 181 | really 182 | really 183 | long 184 | - Item 4`, 185 | }, 186 | { 187 | name: "UNORDEREDLIST query with a single file and a condition for a long item", 188 | query: "UNORDEREDLIST FROM \"examples/misc/test.md\" WHERE CONTAINS \"really\"", 189 | expected: `- Item 3 that's 190 | like really 191 | really 192 | really 193 | long`, 194 | }, 195 | { 196 | name: "UNORDEREDLIST query with a single file and a condition for excluding a long item", 197 | query: "UNORDEREDLIST FROM \"examples/misc/test.md\" WHERE NOT CONTAINS \"really\"", 198 | expected: `- Item 1 199 | - Item 2 200 | - Item 4`, 201 | }, 202 | } 203 | 204 | runTestQueries(t, queries) 205 | } 206 | 207 | func TestOrderedListQueries(t *testing.T) { 208 | queries := []TestQuery{ 209 | { 210 | name: "ORDEREDLIST query with a single file", 211 | query: "ORDEREDLIST FROM \"examples/misc/test.md\"", 212 | expected: `1. First item 213 | 2. Second item 214 | 3. Third item that's 215 | kinda 216 | sorta 217 | long-ish 218 | 4. Fourth item`, 219 | }, 220 | { 221 | name: "ORDEREDLIST query with a single file and a condition", 222 | query: "ORDEREDLIST FROM \"examples/misc/test.md\" WHERE CONTAINS \"kinda\"", 223 | expected: `3. Third item that's 224 | kinda 225 | sorta 226 | long-ish`, 227 | }, 228 | } 229 | 230 | runTestQueries(t, queries) 231 | } 232 | 233 | func TestFencedCodeQueries(t *testing.T) { 234 | queries := []TestQuery{ 235 | { 236 | name: "FENCEDCODE query with a single file", 237 | query: "FENCEDCODE FROM \"examples/misc/test.md\"", 238 | expected: `func main() { 239 | fmt.Println("Hello, DynoMark!") 240 | }`, 241 | }, 242 | } 243 | 244 | runTestQueries(t, queries) 245 | } 246 | 247 | func TestTableQueries(t *testing.T) { 248 | queries := []TestQuery{ 249 | { 250 | name: "TABLE query with 5 files with title metadata (frontmatter)", 251 | query: "TABLE file.path AS \"Relative path\", title AS \"Title\" FROM \"examples/todos/\"", 252 | expected: `| File | Relative path | Title | 253 | |-----------------|--------------------------------|-------------------------------------------| 254 | | todo-basic.md | examples/todos/todo-basic.md | My basic TODOs | 255 | | todo-long.md | examples/todos/todo-long.md | My long TODOs file | 256 | | todo-nested.md | examples/todos/todo-nested.md | Weirdly nested TODOs | 257 | | todo-project.md | examples/todos/todo-project.md | Project TODO | 258 | | todo-states.md | examples/todos/todo-states.md | A file for my TODOs with different states | 259 | `, 260 | }, 261 | } 262 | 263 | runTestQueries(t, queries) 264 | } 265 | 266 | func TestTableNoIdQueries(t *testing.T) { 267 | queries := []TestQuery{ 268 | { 269 | name: "TABLE NO ID query with 5 files with title metadata (frontmatter)", 270 | query: "TABLE NO ID file.path AS \"Relative path\", title AS \"Title\" FROM \"examples/todos/\"", 271 | expected: `| Relative path | Title | 272 | |--------------------------------|-------------------------------------------| 273 | | examples/todos/todo-basic.md | My basic TODOs | 274 | | examples/todos/todo-long.md | My long TODOs file | 275 | | examples/todos/todo-nested.md | Weirdly nested TODOs | 276 | | examples/todos/todo-project.md | Project TODO | 277 | | examples/todos/todo-states.md | A file for my TODOs with different states | 278 | `, 279 | }, 280 | } 281 | 282 | runTestQueries(t, queries) 283 | } 284 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DynoMark 2 | 3 | Dynomark strives to be a markdown query language engine, similar to obsidian's 4 | [Dataview plugin](https://github.com/blacksmithgu/obsidian-dataview). 5 | 6 | This program can be used with editors like neovim, vscode and emacs to provide 7 | a similar experience to Dataview (but very barebones for now). 8 | 9 | ## Installation 10 | 11 | **You can download the executables here:** 12 | - [Download for Windows](https://github.com/k-lar/dynomark/releases/latest/downloads/dynomark.exe) 13 | - [Download for MacOS](https://github.com/k-lar/dynomark/releases/latest/download/dynomark-macos) 14 | - [Download for Linux](https://github.com/k-lar/dynomark/releases/latest/download/dynomark-linux) 15 | 16 | **Or if you want to build it yourself:** 17 | 18 | Requirements: 19 | - Go (1.22.5) 20 | 21 | ```bash 22 | # Clone the repository 23 | git clone https://github.com/k-lar/dynomark 24 | cd dynomark/ 25 | 26 | # Compile the program 27 | make 28 | 29 | # Install the program 30 | sudo make install 31 | 32 | # If you want to uninstall the program 33 | sudo make uninstall 34 | ``` 35 | 36 | > [!NOTE] 37 | > For MacOS users: 38 | > If you want to install the program to `/usr/local/bin/` like `brew` does, you 39 | > have to set the `PREFIX` variable to `/usr/local` like so: 40 | > ```bash 41 | > sudo make PREFIX=/usr/local install 42 | > ``` 43 | > And to uninstall: 44 | > ```bash 45 | > sudo make PREFIX=/usr/local uninstall 46 | > ``` 47 | 48 | > [!NOTE] 49 | > For Windows users: 50 | > The simplest way for you to compile the program is to use the `go build` command: 51 | > ```bash 52 | > go build -o dynomark.exe 53 | > ``` 54 | > And then you can run the program with `.\dynomark.exe`. 55 | > If you want dynomark to be available as a command in your terminal, you have to add 56 | > dynomark.exe to your PATH environment variable. 57 | 58 | ## Roadmap 59 | 60 | - [X] Completed engine 61 | - [X] LIST support 62 | - [X] TASKS support 63 | - [X] PARAGRAPH support 64 | - [X] ORDEREDLIST support 65 | - [X] UNORDEREDLIST support 66 | - [X] FENCEDCODE support 67 | - [X] Limits 68 | - [X] Conditional statements 69 | - [X] AND 70 | - [X] OR 71 | - [X] IS statement (equals / ==) 72 | - [X] SORT (Order by) 73 | - [X] ASCENDING 74 | - [X] DESCENDING 75 | - [X] GROUP BY (metadata) 76 | - [X] Limit max number of groups 77 | - [X] Limit the results under each group 78 | - [X] Metadata parsing 79 | - [X] Query multiple files/directories at once 80 | - [X] Support metadata/tag based conditionals (e.g. TABLE author, published FROM example.md WHERE [author] IS "Shakespeare") 81 | - [X] TABLE support 82 | - [X] TABLE NO ID support (A TABLE query without ID/File column) 83 | - [X] Support AS statements (e.g. TABLE author AS "Author", published AS "Date published" FROM ...) 84 | - [X] [🎉 Neovim plugin 🎉](https://github.com/k-lar/dynomark.nvim) 85 | - [X] [🎉 Visual Studio Code extension 🎉](https://marketplace.visualstudio.com/items?itemName=k-lar.vscode-dynomark) - [Github repo](https://github.com/k-lar/vscode-dynomark) 86 | - [X] [🎉 Emacs package 🎉](https://github.com/k-lar/dynomark.el) 87 | - [ ] Query syntax doc 88 | 89 | ## Examples 90 | 91 | Here's an example markdown document: 92 | 93 | ````md 94 | # Test Markdown File 95 | 96 | This is a test markdown file to test the Dynomark parser. 97 | 98 | ## Tasks 99 | 100 | - [ ] Implement DynoMark parser 101 | - [x] Create test markdown file 102 | - [ ] Write unit tests 103 | - [x] Design CLI interface 104 | 105 | ## Lists 106 | 107 | ### Unordered List 108 | 109 | - Item 1 110 | - Item 2 111 | - Item 3 that's 112 | like really 113 | really 114 | really 115 | long 116 | - Item 4 117 | 118 | ### Ordered List 119 | 120 | 1. First item 121 | 2. Second item 122 | 3. Third item that's 123 | kinda 124 | sorta 125 | long-ish 126 | 4. Fourth item 127 | 128 | ## Code 129 | 130 | Here's a sample code block: 131 | 132 | ```go 133 | func main() { 134 | fmt.Println("Hello, DynoMark!") 135 | } 136 | ``` 137 | ```` 138 | 139 | Here are some queries and their results: 140 | 141 | List of files in the `examples/` directory: 142 | Query: `LIST FROM "examples/"` 143 | 144 | Result: 145 | 146 | ``` 147 | - movie_reviews.md 148 | - tasks.md 149 | - test.md 150 | ``` 151 | 152 | Paragraphs from the `examples/movie_reviews.md` and `examples/tasks.md` files: 153 | Query: `PARAGRAPH FROM examples/movie_reviews.md, examples/tasks.md` 154 | 155 | Result: 156 | 157 | ``` 158 | Some movie review stuff here. 159 | 160 | This is a test markdown file to test the Dynomark parser. 161 | ``` 162 | 163 | List of tasks in the `examples/test.md` file: 164 | Query: `TASK FROM "examples/test.md" WHERE NOT CHECKED` 165 | 166 | Result: 167 | 168 | ``` 169 | - [ ] Implement DynoMark parser 170 | - [ ] Write unit tests 171 | ``` 172 | 173 | List of unchecked tasks in all .md files inside `todos/` directory, grouped by file path: 174 | Query: `TASK FROM todos/ WHERE NOT CHECKED GROUP BY [file.path]` 175 | 176 | Result: 177 | 178 | ``` 179 | - todos/todo-1.md 180 | - [ ] Task 1 181 | - [ ] Task 3 182 | 183 | - todos/todo-2.md 184 | - [ ] Item 1 185 | 186 | - todos/todo-3.md 187 | - [ ] Other task 1 188 | - [ ] Other task 2 189 | ``` 190 | 191 | List of tasks in all .md files inside `todos/` directory, grouped by file name (max 2 groups with 192 | max 3 results under each group): 193 | Query: `TASK FROM todos/ GROUP BY 2 [file.path] LIMIT 3` 194 | 195 | Result: 196 | 197 | ``` 198 | - todo-1.md 199 | - [ ] Task 1 200 | - [X] Task 2 201 | - [ ] Task 3 202 | 203 | - todo-2.md 204 | - [ ] Item 1 205 | - [X] Item 2 206 | - [X] Item 3 207 | ``` 208 | 209 | All unordered lists in `examples/test.md`: 210 | Query: `UNORDEREDLIST FROM "examples/test.md"` 211 | 212 | Result: 213 | 214 | ``` 215 | - Item 1 216 | - Item 2 217 | - Item 3 that's 218 | like really 219 | really 220 | really 221 | long 222 | - Item 4 223 | ``` 224 | 225 | All unordered list items in `examples/test.md` where the list contains the word "really": 226 | Query: `UNORDEREDLIST FROM "examples/test.md" WHERE CONTAINS "really"` 227 | 228 | Result: 229 | 230 | ``` 231 | - Item 3 that's 232 | like really 233 | really 234 | really 235 | long 236 | ``` 237 | 238 | All ordered list items in `examples/test.md` where the list contains the word "kinda": 239 | Query: `ORDEREDLIST FROM "examples/test.md" WHERE CONTAINS "kinda"` 240 | 241 | Result: 242 | 243 | ``` 244 | 3. Third item that's 245 | kinda 246 | sorta 247 | long-ish 248 | ``` 249 | 250 | All tasks in `examples/test.md` but limit the results to the first 2: 251 | Query: `TASK FROM "examples/test.md" LIMIT 2` 252 | 253 | Result: 254 | 255 | ``` 256 | - [ ] Implement DynoMark parser 257 | - [x] Create test markdown file 258 | ``` 259 | 260 | All tasks in `examples/test.md` where the tasks contains either the word "unit" or "CLI": 261 | Query: `TASK FROM "examples/test.md" WHERE CONTAINS "CLI" OR CONTAINS "unit"` 262 | 263 | Result: 264 | 265 | ``` 266 | - [ ] Write unit tests 267 | - [x] Design CLI interface 268 | ``` 269 | 270 | All tasks in `examples/test.md` where the tasks contains either the word "unit" 271 | or "CLI" and the task is not checked: 272 | Query: `TASK FROM "examples/test.md" WHERE CONTAINS "CLI" OR CONTAINS "unit" AND NOT CHECKED` 273 | 274 | Result: 275 | 276 | ``` 277 | - [ ] Write unit tests 278 | ``` 279 | 280 | All fenced code blocks in `examples/test.md`: 281 | Query: `FENCEDCODE FROM "examples/test.md"` 282 | 283 | Result: 284 | 285 | ``` 286 | func main() { 287 | fmt.Println("Hello, DynoMark!") 288 | } 289 | ``` 290 | 291 | ### Sorting 292 | 293 | As of version `0.2.0` dynomark supports sorting table results by metadata fields 294 | and sorting regular queries alphabetically (ascending and descending). 295 | 296 | All tasks in `examples/test.md` sorted by their checked status in ascending order: 297 | Query: `TASK FROM "examples/test.md" WHERE NOT CHECKED SORT ASC` 298 | 299 | Result: 300 | 301 | ``` 302 | - [ ] Implement DynoMark parser 303 | - [ ] Implement DynoMark parser but better 304 | - [ ] Task 1 305 | - [ ] Task 3 306 | - [ ] Task 6 307 | - [ ] Task 7 308 | - [ ] Test 1 309 | - [ ] Write unit tests 310 | ``` 311 | 312 | ## Metadata support 313 | 314 | Dynomark supports metadata in the form of key-value pairs. For now, you can use the 315 | [dataview syntax](https://blacksmithgu.github.io/obsidian-dataview/annotation/add-metadata/) 316 | to add metadata to your markdown files. Currently only the standard metadata 317 | syntax is supported and not the alternative "hidden" syntax (maybe in the future). 318 | To reference metadata in your queries, you have to use the following syntax: 319 | `[metadata_key]` 320 | 321 | The only place where that syntax is not required is in the `TABLE` query, 322 | where you can use the metadata key directly as shown in the examples below. 323 | 324 | There are 10 metadata fields that are defined by default for every file it processes: 325 | - `file.path`: The relative path to the file 326 | - `file.name`: The name of the file, including the file extension 327 | - `file.shortname`: The name of the file without the file extension 328 | - `file.folder`: The folder of the file where it's located 329 | - `file.link`: The markdown link to the file (relative to your current working directory) 330 | - `file.size`: The size of the file in bytes 331 | - `file.cday`: The creation day of the file in ISO8601 format 332 | - `file.mday`: The modification day of the file in ISO8601 format 333 | - `file.ctime`: The creation time of the file in ISO8601 format 334 | - `file.mtime`: The modification time of the file in ISO8601 format 335 | 336 | NOTE: 337 | IS is a strict version of the CONTAINS statement, it will only match if 338 | the metadata value is exactly the same as the argument after IS. It can 339 | also be used with normal queries where CONTAINS doesn't cut it, 340 | but that's rare because you would have to know the exact value of the 341 | result you're looking for. 342 | 343 | You can use metadata in your queries like so: 344 | ``` 345 | PARAGRAPH FROM "examples/" WHERE [author] IS "Shakespeare" 346 | ``` 347 | 348 | This will return all paragraphs from all .md files from `examples/` 349 | where the metadata key `author` is `Shakespeare`. 350 | 351 | ## Tables 352 | 353 | Dynomark supports querying metadata from files in a table format. 354 | 355 | Here's an example query that queries all files in the `todos/` 356 | directory by their creation date and their title: 357 | `TABLE file.cday AS "Date created", title AS "Title" FROM todos/` 358 | 359 | That would return a table like this: 360 | ``` 361 | | File | Date created | Title | 362 | |-----------|--------------|---------| 363 | | todo-1.md | 2024-08-17 | Title 1 | 364 | | todo-2.md | 2024-08-18 | Title 2 | 365 | | todo-3.md | 2024-08-19 | Title 3 | 366 | | todo-4.md | 2024-08-20 | Title 4 | 367 | | todo-5.md | 2024-08-21 | Title 5 | 368 | ``` 369 | 370 | You can also use the `TABLE NO ID` statement to create a table without the ID/File column: 371 | `TABLE NO ID file.cday AS "Date created", title AS "Title" FROM todos/` 372 | 373 | That would return a table like this: 374 | ``` 375 | | Date created | Title | 376 | |--------------|---------| 377 | | 2024-08-17 | Title 1 | 378 | | 2024-08-18 | Title 2 | 379 | | 2024-08-19 | Title 3 | 380 | | 2024-08-20 | Title 4 | 381 | | 2024-08-21 | Title 5 | 382 | ``` 383 | 384 | And an example with metadata conditionals: 385 | `TABLE NO ID file.cday AS "Date created", title AS "Title" FROM todos/ WHERE [title] IS "Title 2"` 386 | 387 | That would return a table like this: 388 | ``` 389 | | Date created | Title | 390 | |--------------|---------| 391 | | 2024-08-18 | Title 2 | 392 | ``` 393 | 394 | The AS statement is optional. If you don't provide an alias, the metadata 395 | key will be used as the column name. 396 | 397 | Sorting is also supported in tables. You can sort by any present metadata key 398 | in either ascending or descending order. 399 | 400 | We'll take a previous example and sort it by the title in descending order: 401 | `TABLE NO ID file.cday AS "Date created", title AS "Title" FROM todos/ SORT [title] DESC` 402 | 403 | That would return a table like this: 404 | 405 | ``` 406 | | Date created | Title | 407 | |--------------|---------| 408 | | 2024-08-21 | Title 5 | 409 | | 2024-08-20 | Title 4 | 410 | | 2024-08-19 | Title 3 | 411 | | 2024-08-18 | Title 2 | 412 | | 2024-08-17 | Title 1 | 413 | ``` 414 | 415 | If you want to sort by multiple columns, you can do so by separating the columns with a comma: 416 | 417 | `TABLE NO ID file.cday AS "Date created", title AS "Title" FROM todos/ SORT [title] ASC, [file.cday] DESC` 418 | 419 | ### Deprecation 420 | 421 | > [!WARNING] 422 | > Before TABLE NO ID, there was TABLE_NO_ID that is now **DEPRECATED**. 423 | > A warning will be shown if you try using TABLE_NO_ID but it will still show the results. 424 | > This syntax will be removed at a later date, so please update your queries until then!** 425 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | "unicode" 16 | "unicode/utf8" 17 | ) 18 | 19 | var version string = "0.2.1" 20 | 21 | type TokenType int 22 | 23 | const ( 24 | TOKEN_KEYWORD TokenType = iota 25 | TOKEN_IDENTIFIER 26 | TOKEN_FUNCTION 27 | TOKEN_NOT 28 | TOKEN_LOGICAL_OP 29 | TOKEN_STRING 30 | TOKEN_NUMBER 31 | TOKEN_COMMA 32 | TOKEN_EOF 33 | TOKEN_TABLE 34 | TOKEN_TABLE_NO_ID // DEPRECATED: Use 'TABLE NO ID' syntax instead. 35 | TOKEN_AS 36 | TOKEN_METADATA 37 | TOKEN_GROUP 38 | TOKEN_BY 39 | TOKEN_SORT 40 | ) 41 | 42 | var TokenTypeNames = map[TokenType]string{ 43 | TOKEN_KEYWORD: "TOKEN_KEYWORD", 44 | TOKEN_IDENTIFIER: "TOKEN_IDENTIFIER", 45 | TOKEN_FUNCTION: "TOKEN_FUNCTION", 46 | TOKEN_NOT: "TOKEN_NOT", 47 | TOKEN_LOGICAL_OP: "TOKEN_LOGICAL_OP", 48 | TOKEN_STRING: "TOKEN_STRING", 49 | TOKEN_NUMBER: "TOKEN_NUMBER", 50 | TOKEN_COMMA: "TOKEN_COMMA", 51 | TOKEN_EOF: "TOKEN_EOF", 52 | TOKEN_TABLE: "TOKEN_TABLE", 53 | TOKEN_TABLE_NO_ID: "TOKEN_TABLE_NO_ID", 54 | TOKEN_AS: "TOKEN_AS", 55 | TOKEN_METADATA: "TOKEN_METADATA", 56 | TOKEN_GROUP: "TOKEN_GROUP", 57 | TOKEN_BY: "TOKEN_BY", 58 | TOKEN_SORT: "TOKEN_SORT", 59 | } 60 | 61 | func (t TokenType) String() string { 62 | return TokenTypeNames[t] 63 | } 64 | 65 | type Token struct { 66 | Type TokenType 67 | Value string 68 | } 69 | 70 | type QueryType string 71 | 72 | type Metadata map[string]interface{} 73 | 74 | const ( 75 | LIST QueryType = "LIST" 76 | TASK QueryType = "TASK" 77 | PARAGRAPH QueryType = "PARAGRAPH" 78 | ORDEREDLIST QueryType = "ORDEREDLIST" 79 | UNORDEREDLIST QueryType = "UNORDEREDLIST" 80 | FENCEDCODE QueryType = "FENCEDCODE" 81 | TABLE QueryType = "TABLE" 82 | TABLE_NO_ID QueryType = "TABLE_NO_ID" 83 | ) 84 | 85 | type ColumnDefinition struct { 86 | Name string 87 | Alias string 88 | } 89 | 90 | type QueryNode struct { 91 | Type QueryType 92 | From []string 93 | Where *WhereNode 94 | GroupBy string 95 | GroupLimit int 96 | Limit int 97 | Columns []ColumnDefinition 98 | Sorts []SortNode 99 | } 100 | 101 | type SortNode struct { 102 | Metadata string 103 | SortDirection string 104 | } 105 | 106 | type WhereNode struct { 107 | Conditions []ConditionNode 108 | } 109 | 110 | type ConditionNode struct { 111 | IsNegated bool 112 | IsMetadata bool 113 | Field string // Metadata field 114 | Function string 115 | Value string 116 | LogicalOp string // "AND" or "OR" 117 | } 118 | 119 | func Lex(input string) []Token { 120 | var tokens []Token 121 | words := strings.Fields(input) 122 | 123 | // If word has comma suffix, split it into two tokens 124 | for i := 0; i < len(words); i++ { 125 | if strings.HasSuffix(words[i], ",") { 126 | words = append(words[:i+1], append([]string{","}, words[i+1:]...)...) 127 | words[i] = strings.TrimSuffix(words[i], ",") 128 | i++ 129 | } 130 | } 131 | 132 | got_from := false 133 | got_where := false 134 | got_sort := false 135 | insideQuotes := false 136 | var quotedString string 137 | 138 | for _, word := range words { 139 | // Handle metadata (e.g. [author]) 140 | if strings.HasPrefix(word, "[") && strings.HasSuffix(word, "]") { 141 | tokens = append(tokens, Token{Type: TOKEN_METADATA, Value: strings.Trim(word, "[]")}) 142 | // Handle quoted strings (even if they contain spaces) 143 | } else if strings.HasPrefix(word, "\"") && !insideQuotes { 144 | insideQuotes = true 145 | quotedString = word[1:] 146 | if strings.HasSuffix(word, "\"") && len(word) > 1 { 147 | insideQuotes = false 148 | quotedString = word[1 : len(word)-1] 149 | tokens = append(tokens, Token{Type: TOKEN_STRING, Value: quotedString}) 150 | } 151 | } else if insideQuotes { 152 | if strings.HasSuffix(word, "\"") { 153 | insideQuotes = false 154 | quotedString += " " + word[:len(word)-1] 155 | tokens = append(tokens, Token{Type: TOKEN_STRING, Value: quotedString}) 156 | } else { 157 | quotedString += " " + word 158 | } 159 | } else { 160 | switch strings.ToUpper(word) { 161 | case "TABLE": 162 | tokens = append(tokens, Token{Type: TOKEN_TABLE, Value: "TABLE"}) 163 | case "TABLE_NO_ID": 164 | // DEPRECATED: Use 'TABLE NO ID' syntax instead 165 | fmt.Fprintf(os.Stderr, "Warning: 'TABLE_NO_ID' token is deprecated. Use 'TABLE NO ID' syntax instead.\n") 166 | tokens = append(tokens, Token{Type: TOKEN_TABLE_NO_ID, Value: "TABLE_NO_ID"}) 167 | case "AS": 168 | tokens = append(tokens, Token{Type: TOKEN_AS, Value: "AS"}) 169 | case "LIST", "TASK", "PARAGRAPH", "ORDEREDLIST", "UNORDEREDLIST", "FENCEDCODE", "LIMIT", "CHECKED": 170 | tokens = append(tokens, Token{Type: TOKEN_KEYWORD, Value: strings.ToUpper(word)}) 171 | case "FROM": 172 | tokens = append(tokens, Token{Type: TOKEN_KEYWORD, Value: "FROM"}) 173 | got_from = true 174 | case "WHERE": 175 | tokens = append(tokens, Token{Type: TOKEN_KEYWORD, Value: "WHERE"}) 176 | got_where = true 177 | case "GROUP": 178 | tokens = append(tokens, Token{Type: TOKEN_GROUP, Value: "GROUP"}) 179 | case "SORT": 180 | tokens = append(tokens, Token{Type: TOKEN_SORT, Value: "SORT"}) 181 | got_sort = true 182 | case "BY": 183 | tokens = append(tokens, Token{Type: TOKEN_BY, Value: "BY"}) 184 | case ",": 185 | tokens = append(tokens, Token{Type: TOKEN_COMMA, Value: word}) 186 | case "CONTAINS": 187 | tokens = append(tokens, Token{Type: TOKEN_FUNCTION, Value: "CONTAINS"}) 188 | case "IS": 189 | tokens = append(tokens, Token{Type: TOKEN_FUNCTION, Value: "IS"}) 190 | case "NOT": 191 | tokens = append(tokens, Token{Type: TOKEN_NOT, Value: "NOT"}) 192 | case "AND", "OR": 193 | tokens = append(tokens, Token{Type: TOKEN_LOGICAL_OP, Value: strings.ToUpper(word)}) 194 | default: 195 | if _, err := strconv.Atoi(word); err == nil { 196 | tokens = append(tokens, Token{Type: TOKEN_NUMBER, Value: word}) 197 | // If previous token was 'TABLE' and current word is 'NO', uppercase it 198 | } else if len(tokens) > 0 && tokens[len(tokens)-1].Type == TOKEN_TABLE && strings.ToUpper(word) == "NO" { 199 | tokens = append(tokens, Token{Type: TOKEN_IDENTIFIER, Value: "NO"}) 200 | // If previous tokens were 'TABLE' and 'NO', and current word is 'ID', uppercase it 201 | } else if len(tokens) > 1 && tokens[len(tokens)-2].Type == TOKEN_TABLE && tokens[len(tokens)-1].Type == TOKEN_IDENTIFIER && strings.ToUpper(word) == "ID" { 202 | tokens = append(tokens, Token{Type: TOKEN_IDENTIFIER, Value: "ID"}) 203 | } else if got_from && !got_where && !got_sort { 204 | tokens = append(tokens, Token{Type: TOKEN_STRING, Value: word}) 205 | } else { 206 | tokens = append(tokens, Token{Type: TOKEN_IDENTIFIER, Value: word}) 207 | } 208 | } 209 | } 210 | } 211 | 212 | tokens = append(tokens, Token{Type: TOKEN_EOF, Value: ""}) 213 | return tokens 214 | } 215 | 216 | func Parse(tokens []Token) (*QueryNode, error) { 217 | query := &QueryNode{Limit: -1} 218 | 219 | i := 0 220 | 221 | if tokens[i].Type == TOKEN_TABLE { 222 | query.Type = TABLE 223 | // Check for 'NO ID' after 'TABLE' 224 | if i+2 < len(tokens) && 225 | tokens[i+1].Type == TOKEN_IDENTIFIER && tokens[i+1].Value == "NO" && 226 | tokens[i+2].Type == TOKEN_IDENTIFIER && tokens[i+2].Value == "ID" { 227 | query.Type = TABLE_NO_ID 228 | i += 3 229 | } else { 230 | i++ 231 | } 232 | } else if tokens[i].Type == TOKEN_TABLE_NO_ID { 233 | // DEPRECATED: Handle the old TOKEN_TABLE_NO_ID for backward compatibility 234 | query.Type = TABLE_NO_ID 235 | i++ 236 | } else { 237 | if tokens[i].Type != TOKEN_KEYWORD { 238 | return nil, fmt.Errorf("expected valid query type, got %s", tokens[i].Value) 239 | } 240 | query.Type = parseQueryType(tokens[i].Value) 241 | i++ 242 | } 243 | 244 | // Parse columns for TABLE queries 245 | if query.Type == TABLE || query.Type == TABLE_NO_ID { 246 | for i < len(tokens) && tokens[i].Type != TOKEN_KEYWORD { 247 | if tokens[i].Type == TOKEN_IDENTIFIER { 248 | columnName := tokens[i].Value 249 | i++ 250 | if i < len(tokens) && tokens[i].Type == TOKEN_AS { 251 | i++ 252 | if i >= len(tokens) || tokens[i].Type != TOKEN_STRING { 253 | return nil, fmt.Errorf("expected column alias, got %s", tokens[i].Value) 254 | } 255 | query.Columns = append(query.Columns, ColumnDefinition{ 256 | Name: columnName, 257 | Alias: tokens[i].Value, 258 | }) 259 | i++ 260 | } else { 261 | query.Columns = append(query.Columns, ColumnDefinition{ 262 | Name: columnName, 263 | Alias: columnName, 264 | }) 265 | } 266 | } else if tokens[i].Type == TOKEN_COMMA { 267 | i++ 268 | } else { 269 | return nil, fmt.Errorf("expected column name or comma, got %s", tokens[i].Value) 270 | } 271 | } 272 | } 273 | 274 | // Parse FROM clause 275 | if tokens[i].Value != "FROM" { 276 | return nil, fmt.Errorf("expected FROM, got %s", tokens[i].Value) 277 | } else { 278 | i++ 279 | } 280 | 281 | for i < len(tokens) && tokens[i].Type != TOKEN_KEYWORD { 282 | if tokens[i].Type == TOKEN_GROUP || tokens[i].Type == TOKEN_SORT { 283 | break 284 | } else if tokens[i].Type == TOKEN_STRING { 285 | query.From = append(query.From, tokens[i].Value) 286 | } 287 | i++ 288 | } 289 | 290 | // Parse WHERE clause 291 | if i < len(tokens) && tokens[i].Value == "WHERE" { 292 | whereNode, newIndex, err := parseWhereClause(tokens[i+1:]) 293 | if err != nil { 294 | return nil, fmt.Errorf("error parsing WHERE clause: %w", err) 295 | } 296 | query.Where = whereNode 297 | i += newIndex + 1 298 | } 299 | 300 | // Parse SORT clause 301 | if i < len(tokens) && tokens[i].Value == "SORT" { 302 | sortNodes, newIndex, err := parseSortClause(tokens[i+1:], query) 303 | if err != nil { 304 | return nil, fmt.Errorf("error parsing SORT clause: %w", err) 305 | } 306 | query.Sorts = sortNodes 307 | i += newIndex + 1 308 | } 309 | 310 | // Parse GROUP BY clause 311 | if i < len(tokens) && tokens[i].Type == TOKEN_GROUP { 312 | i++ 313 | if i < len(tokens) && tokens[i].Type == TOKEN_BY { 314 | i++ 315 | if i < len(tokens) && tokens[i].Type == TOKEN_NUMBER { 316 | query.GroupLimit, _ = strconv.Atoi(tokens[i].Value) 317 | i++ 318 | if i < len(tokens) && tokens[i].Type != TOKEN_METADATA { 319 | return nil, fmt.Errorf("expected metadata field after GROUP BY %s, got %s", tokens[i-1].Value, tokens[i].Value) 320 | } 321 | } 322 | if i < len(tokens) && tokens[i].Type == TOKEN_METADATA { 323 | query.GroupBy = tokens[i].Value 324 | i++ 325 | } else { 326 | return nil, fmt.Errorf("expected metadata field after GROUP BY, got %s", tokens[i].Value) 327 | } 328 | } else { 329 | return nil, fmt.Errorf("expected BY after GROUP, got %s", tokens[i].Value) 330 | } 331 | } 332 | 333 | // Parse LIMIT clause 334 | if i < len(tokens) && tokens[i].Value == "LIMIT" { 335 | if i+1 >= len(tokens) || tokens[i+1].Type != TOKEN_NUMBER { 336 | return nil, fmt.Errorf("invalid LIMIT clause") 337 | } 338 | limit, err := strconv.Atoi(tokens[i+1].Value) 339 | if err != nil { 340 | return nil, fmt.Errorf("invalid LIMIT value: %w", err) 341 | } 342 | query.Limit = limit 343 | } 344 | 345 | return query, nil 346 | } 347 | 348 | func parseQueryType(value string) QueryType { 349 | switch value { 350 | case "LIST": 351 | return LIST 352 | case "TASK": 353 | return TASK 354 | case "PARAGRAPH": 355 | return PARAGRAPH 356 | case "ORDEREDLIST": 357 | return ORDEREDLIST 358 | case "UNORDEREDLIST": 359 | return UNORDEREDLIST 360 | case "FENCEDCODE": 361 | return FENCEDCODE 362 | case "TABLE": 363 | return TABLE 364 | default: 365 | return "" 366 | } 367 | } 368 | 369 | func parseSortClause(tokens []Token, queryNode *QueryNode) ([]SortNode, int, error) { 370 | i := 0 371 | var gotGroup bool 372 | var gotLimit bool 373 | var sortNodes []SortNode 374 | var sortTokens []Token 375 | 376 | // Isolate the sort tokens here 377 | for i < len(tokens) && tokens[i].Value != "LIMIT" && tokens[i].Value != "GROUP" && tokens[i].Type != TOKEN_EOF { 378 | switch tokens[i].Type { 379 | case TOKEN_METADATA: 380 | sortTokens = append(sortTokens, tokens[i]) 381 | case TOKEN_COMMA: 382 | sortTokens = append(sortTokens, tokens[i]) 383 | case TOKEN_STRING, TOKEN_IDENTIFIER: 384 | if strings.ToUpper(tokens[i].Value) == "DESC" || strings.ToUpper(tokens[i].Value) == "ASC" { 385 | sortTokens = append(sortTokens, tokens[i]) 386 | } 387 | } 388 | 389 | i++ 390 | } 391 | 392 | // Split sortTokens into separate sortTokens based on commas 393 | i = 0 394 | newSortTokens := make([][]Token, 0) 395 | for i < len(sortTokens) { 396 | if sortTokens[i].Type == TOKEN_COMMA { 397 | i++ 398 | continue 399 | } 400 | var sortToken []Token 401 | for i < len(sortTokens) && sortTokens[i].Type != TOKEN_COMMA { 402 | sortToken = append(sortToken, sortTokens[i]) 403 | i++ 404 | } 405 | newSortTokens = append(newSortTokens, sortToken) 406 | } 407 | 408 | // Parse each sortToken and create a SortNode 409 | for _, sortToken := range newSortTokens { 410 | sortNode := SortNode{SortDirection: "ASC"} 411 | for _, token := range sortToken { 412 | if queryNode.Type == TABLE || queryNode.Type == TABLE_NO_ID { 413 | if token.Type == TOKEN_METADATA { 414 | sortNode.Metadata = token.Value 415 | } else if strings.ToUpper(token.Value) == "DESC" { 416 | if sortNode.Metadata != "" { 417 | sortNode.SortDirection = "DESC" 418 | } else { 419 | return sortNodes, 0, fmt.Errorf("expected metadata field before DESC, got DESC") 420 | } 421 | } else if strings.ToUpper(token.Value) == "ASC" { 422 | if sortNode.Metadata != "" { 423 | sortNode.SortDirection = "ASC" 424 | } else { 425 | return sortNodes, 0, fmt.Errorf("expected metadata field before ASC, got ASC") 426 | } 427 | } else if token.Value == "GROUP" { 428 | gotGroup = true 429 | break 430 | } else if token.Value == "LIMIT" { 431 | gotLimit = true 432 | break 433 | } else { 434 | return sortNodes, 0, fmt.Errorf("expected metadata field or DESC/ASC, got %s", token.Value) 435 | } 436 | } else { 437 | if token.Type == TOKEN_METADATA { 438 | return sortNodes, 0, fmt.Errorf("metadata field not allowed in non-TABLE queries") 439 | } else if strings.ToUpper(token.Value) == "DESC" { 440 | sortNode.SortDirection = "DESC" 441 | } else if strings.ToUpper(token.Value) == "ASC" { 442 | sortNode.SortDirection = "ASC" 443 | } else if token.Value == "GROUP" { 444 | gotGroup = true 445 | break 446 | } else if token.Value == "LIMIT" { 447 | gotLimit = true 448 | break 449 | } else { 450 | return sortNodes, 0, fmt.Errorf("expected DESC/ASC, got %s", token.Value) 451 | } 452 | } 453 | } 454 | 455 | if gotGroup || gotLimit { 456 | break 457 | } 458 | 459 | sortNodes = append(sortNodes, sortNode) 460 | } 461 | 462 | // If querytype not table or table_no_id, take the last sort node and return just that 463 | if queryNode.Type != TABLE && queryNode.Type != TABLE_NO_ID { 464 | if len(sortNodes) == 0 { 465 | sortNode := SortNode{SortDirection: "ASC"} 466 | sortNodes = append(sortNodes, sortNode) 467 | } else { 468 | sortNodes = sortNodes[len(sortNodes)-1:] 469 | } 470 | 471 | } 472 | 473 | return sortNodes, i, nil 474 | } 475 | 476 | func parseWhereClause(tokens []Token) (*WhereNode, int, error) { 477 | whereNode := &WhereNode{} 478 | i := 0 479 | var currentCondition ConditionNode 480 | var logicalOp string 481 | var gotGroup bool 482 | var gotSort bool 483 | 484 | for i < len(tokens) && tokens[i].Value != "LIMIT" { 485 | switch tokens[i].Type { 486 | case TOKEN_GROUP: 487 | gotGroup = true 488 | break 489 | case TOKEN_SORT: 490 | gotSort = true 491 | break 492 | case TOKEN_METADATA: 493 | currentCondition.IsMetadata = true 494 | currentCondition.Field = tokens[i].Value 495 | case TOKEN_NOT: 496 | currentCondition.IsNegated = true 497 | case TOKEN_FUNCTION: 498 | currentCondition.Function = tokens[i].Value 499 | case TOKEN_STRING: 500 | currentCondition.Value = tokens[i].Value 501 | currentCondition.LogicalOp = logicalOp 502 | whereNode.Conditions = append(whereNode.Conditions, currentCondition) 503 | currentCondition = ConditionNode{} 504 | logicalOp = "" 505 | case TOKEN_LOGICAL_OP: 506 | logicalOp = tokens[i].Value 507 | case TOKEN_KEYWORD: 508 | if tokens[i].Value == "CHECKED" { 509 | currentCondition.Function = "CHECKED" 510 | currentCondition.LogicalOp = logicalOp 511 | whereNode.Conditions = append(whereNode.Conditions, currentCondition) 512 | currentCondition = ConditionNode{} 513 | logicalOp = "" 514 | } 515 | } 516 | if gotGroup || gotSort { 517 | break 518 | } 519 | i++ 520 | } 521 | 522 | return whereNode, i, nil 523 | } 524 | 525 | func InterpretTableQuery(ast *QueryNode) (string, error) { 526 | var result strings.Builder 527 | var headers []string 528 | 529 | if ast.Type == TABLE { 530 | headers = append(headers, "File") 531 | } 532 | for _, col := range ast.Columns { 533 | headers = append(headers, col.Alias) 534 | } 535 | 536 | // Initialize maxWidths with the length of headers 537 | maxWidths := make([]int, len(headers)) 538 | for i, header := range headers { 539 | maxWidths[i] = utf8.RuneCountInString(header) 540 | } 541 | 542 | // Collect all rows and calculate max width for each column 543 | var rows [][]string 544 | var rowsMetadata []map[string]interface{} // Store metadata for sorting 545 | var paths []string 546 | 547 | for _, path := range ast.From { 548 | info, err := os.Stat(path) 549 | if err != nil { 550 | return "", err 551 | } 552 | 553 | if info.IsDir() { 554 | err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error { 555 | if err != nil { 556 | return err 557 | } 558 | if !info.IsDir() && strings.HasSuffix(info.Name(), ".md") { 559 | paths = append(paths, p) 560 | } 561 | return nil 562 | }) 563 | if err != nil { 564 | return "", err 565 | } 566 | } else { 567 | paths = append(paths, path) 568 | } 569 | } 570 | 571 | for _, path := range paths { 572 | _, metadata, err := parseMarkdownContent(path, ast.Type) 573 | if err != nil { 574 | return "", err 575 | } 576 | 577 | // Apply WHERE conditions to filter rows 578 | if ast.Where != nil { 579 | if !applyConditions("", metadata, ast.Where.Conditions) { 580 | continue 581 | } 582 | } 583 | 584 | var row []string 585 | if ast.Type == TABLE { 586 | row = append(row, filepath.Base(path)) 587 | } 588 | 589 | for _, colDef := range ast.Columns { 590 | colName := colDef.Name 591 | if value, ok := metadata[colName]; ok { 592 | row = append(row, fmt.Sprintf("%v", value)) 593 | } else { 594 | row = append(row, "") 595 | } 596 | } 597 | 598 | // Update maxWidths based on the current row 599 | for i, cell := range row { 600 | if utf8.RuneCountInString(cell) > maxWidths[i] { 601 | maxWidths[i] = utf8.RuneCountInString(cell) 602 | } 603 | } 604 | 605 | rows = append(rows, row) 606 | rowsMetadata = append(rowsMetadata, metadata) 607 | 608 | // HACK: This is here to ensure metadata is printed when query is TABLE. 609 | // Fix this by returning metadata from parseMarkdownContent and this function. 610 | // When refactored, this should be inside executeQuery function. 611 | if printMetadataFlag { 612 | printMetadata([]Metadata{metadata}) 613 | } 614 | } 615 | 616 | // Sort the rows based on multiple fields 617 | if len(ast.Sorts) > 0 { 618 | sort.Slice(rows, func(i, j int) bool { 619 | // Compare rows based on each sort criterion 620 | for _, sortNode := range ast.Sorts { 621 | // Find the column index for the metadata field 622 | colIndex := -1 623 | if sortNode.Metadata == "File" && ast.Type == TABLE { 624 | colIndex = 0 625 | } else { 626 | for idx, col := range ast.Columns { 627 | if col.Name == sortNode.Metadata { 628 | colIndex = idx 629 | if ast.Type == TABLE { 630 | colIndex++ // Adjust for File column 631 | } 632 | break 633 | } 634 | } 635 | } 636 | 637 | if colIndex == -1 { 638 | continue 639 | } 640 | 641 | // Get values to compare 642 | val1 := rows[i][colIndex] 643 | val2 := rows[j][colIndex] 644 | 645 | // Try to compare as numbers first 646 | num1, err1 := strconv.ParseFloat(val1, 64) 647 | num2, err2 := strconv.ParseFloat(val2, 64) 648 | 649 | var compareResult int 650 | if err1 == nil && err2 == nil { 651 | // Numeric comparison 652 | if num1 < num2 { 653 | compareResult = -1 654 | } else if num1 > num2 { 655 | compareResult = 1 656 | } 657 | } else { 658 | // String comparison 659 | compareResult = strings.Compare(val1, val2) 660 | } 661 | 662 | // If values are different, return the comparison result 663 | if compareResult != 0 { 664 | if sortNode.SortDirection == "DESC" { 665 | return compareResult > 0 666 | } 667 | return compareResult < 0 668 | } 669 | } 670 | return false // If all values are equal 671 | }) 672 | } 673 | 674 | // Write table headers 675 | for i, header := range headers { 676 | result.WriteString("| " + tablePadString(header, maxWidths[i]) + " ") 677 | } 678 | result.WriteString("|\n") 679 | 680 | // Write table header separator 681 | for _, width := range maxWidths { 682 | result.WriteString("|" + strings.Repeat("-", width+2)) 683 | } 684 | result.WriteString("|\n") 685 | 686 | // Write table rows 687 | for _, row := range rows { 688 | for i, cell := range row { 689 | result.WriteString("| " + tablePadString(cell, maxWidths[i]) + " ") 690 | } 691 | result.WriteString("|\n") 692 | } 693 | 694 | return result.String(), nil 695 | } 696 | 697 | func tablePadString(str string, length int) string { 698 | return str + strings.Repeat(" ", length-utf8.RuneCountInString(str)) 699 | } 700 | 701 | func Interpret(ast *QueryNode) (string, error) { 702 | if ast.Type == TABLE || ast.Type == TABLE_NO_ID { 703 | return InterpretTableQuery(ast) 704 | } 705 | 706 | content, metadataList, err := parseMarkdownFiles(ast.From, ast.Type) 707 | if err != nil { 708 | return "", err 709 | } 710 | 711 | // Sort content ASC or DESC 712 | // If it's not a table, ast.Sorts will only have one element 713 | // Here you can't sort by metadata, so just sort alphabetically 714 | // Use NaturalSort for sorting because it's nicer :) 715 | if len(ast.Sorts) > 0 { 716 | if ast.Sorts[0].SortDirection == "DESC" { 717 | sort.Slice(content, func(i, j int) bool { 718 | return NaturalSort(content[i], content[j]) 719 | }) 720 | } else { 721 | sort.Slice(content, func(i, j int) bool { 722 | sorted := NaturalSort(content[i], content[j]) 723 | return !sorted 724 | }) 725 | } 726 | } 727 | 728 | if ast.Where != nil { 729 | content, metadataList = filterContent(content, metadataList, ast.Where.Conditions) 730 | } 731 | 732 | if ast.GroupBy != "" { 733 | // This handles LIMIT too, that's why I can just return it 734 | return groupContent(content, metadataList, ast) 735 | } 736 | 737 | if ast.Limit >= 0 && ast.Limit < len(content) { 738 | content = content[:ast.Limit] 739 | } 740 | 741 | // HACK: This is here because metadata isn't returned far enough in the code. 742 | // Fix this by returning metadata from parseMarkdownContent. 743 | // When refactored, this should be inside executeQuery function. 744 | if printMetadataFlag { 745 | printMetadata(metadataList) 746 | } 747 | 748 | return strings.Join(content, "\n"), nil 749 | } 750 | 751 | func groupContent(content []string, metadataList []Metadata, ast *QueryNode) (string, error) { 752 | groups := make(map[string][]string) 753 | 754 | for i, item := range content { 755 | groupValue, ok := metadataList[i][ast.GroupBy] 756 | if !ok { 757 | groupValue = "Unknown" 758 | } 759 | groupKey := fmt.Sprintf("%v", groupValue) 760 | if ast.Limit > 0 && len(groups[groupKey]) >= ast.Limit { 761 | continue 762 | } 763 | groups[groupKey] = append(groups[groupKey], item) 764 | } 765 | 766 | var result strings.Builder 767 | keys := make([]string, 0, len(groups)) 768 | for k := range groups { 769 | keys = append(keys, k) 770 | } 771 | 772 | sort.Slice(keys, func(i, j int) bool { 773 | return NaturalSort(keys[i], keys[j]) 774 | }) 775 | 776 | if ast.GroupLimit > 0 && len(keys) > ast.GroupLimit { 777 | keys = keys[:ast.GroupLimit] 778 | } 779 | 780 | for _, key := range keys { 781 | result.WriteString(fmt.Sprintf("- %s\n", key)) 782 | for _, item := range groups[key] { 783 | switch ast.Type { 784 | case TASK, UNORDEREDLIST, ORDEREDLIST, PARAGRAPH, FENCEDCODE: 785 | result.WriteString(fmt.Sprintf(" %s\n", item)) 786 | } 787 | } 788 | result.WriteString("\n") 789 | } 790 | 791 | return result.String(), nil 792 | } 793 | 794 | func parseMarkdownContent(path string, queryType QueryType) ([]string, Metadata, error) { 795 | file, err := os.Open(path) 796 | if err != nil { 797 | return nil, nil, err 798 | } 799 | defer file.Close() 800 | 801 | scanner := bufio.NewScanner(file) 802 | var lines []string 803 | metadata := make(Metadata) 804 | inFrontMatter := false 805 | frontMatterLines := []string{} 806 | 807 | for scanner.Scan() { 808 | line := scanner.Text() 809 | lines = append(lines, line) 810 | 811 | trimmedLine := strings.TrimSpace(line) 812 | if trimmedLine == "---" { 813 | inFrontMatter = !inFrontMatter 814 | if !inFrontMatter { 815 | // Process YAML front matter 816 | for _, fmLine := range frontMatterLines { 817 | if strings.Contains(fmLine, ":") { 818 | parts := strings.SplitN(fmLine, ":", 2) 819 | key := strings.ToLower(strings.TrimSpace(parts[0])) 820 | value := strings.TrimSpace(parts[1]) 821 | value = strings.Trim(value, `"`) 822 | if i, err := strconv.Atoi(value); err == nil { 823 | metadata[key] = i 824 | } else if b, err := strconv.ParseBool(value); err == nil { 825 | metadata[key] = b 826 | } else { 827 | metadata[key] = value 828 | } 829 | } 830 | } 831 | } 832 | continue 833 | } 834 | 835 | if inFrontMatter { 836 | frontMatterLines = append(frontMatterLines, trimmedLine) 837 | } else { 838 | parseMetadataLine(trimmedLine, metadata) 839 | } 840 | } 841 | 842 | if err := scanner.Err(); err != nil { 843 | return nil, nil, err 844 | } 845 | 846 | // Add file-related metadata 847 | addFileMetadata(path, &metadata) 848 | 849 | // For TABLE and TABLE_NO_ID, no need to parse the content 850 | // Just return an empty slice for the content and the metadata 851 | if queryType == TABLE || queryType == TABLE_NO_ID { 852 | return []string{}, metadata, nil 853 | } 854 | 855 | // Strip YAML frontmatter from lines 856 | lines = stripYAMLFrontmatter(lines) 857 | 858 | var parsedContent []string 859 | switch queryType { 860 | case LIST: 861 | parsedContent = nil 862 | case TASK: 863 | parsedContent = parseTasks(lines) 864 | case PARAGRAPH: 865 | parsedContent = parseParagraphs(lines) 866 | case ORDEREDLIST: 867 | parsedContent = parseOrderedLists(lines) 868 | case UNORDEREDLIST: 869 | parsedContent = parseUnorderedLists(lines) 870 | case FENCEDCODE: 871 | parsedContent = parseFencedCode(lines) 872 | default: 873 | return nil, nil, fmt.Errorf("unsupported query type: %s", queryType) 874 | } 875 | 876 | return parsedContent, metadata, nil 877 | } 878 | 879 | func parseMetadataLine(line string, metadata Metadata) { 880 | // Check for metadata in the form of key:: value 881 | if !strings.Contains(line, "::") { 882 | return 883 | } else if strings.HasPrefix(line, "**") && strings.Contains(line, "::") { 884 | line = strings.Trim(line, "* ") 885 | parseMetadataPair(line, metadata) 886 | } else if strings.HasPrefix(line, "[") && strings.Contains(line, "::") { 887 | line = strings.Trim(line, "[] ") 888 | parts := strings.Split(line, "] | [") 889 | for _, part := range parts { 890 | parseMetadataPair(part, metadata) 891 | } 892 | } else if strings.Contains(line, "[") && strings.Contains(line, "::") { 893 | for strings.Contains(line, "[") && strings.Contains(line, "::") { 894 | start := strings.Index(line, "[") 895 | end := strings.Index(line, "]") 896 | if start != -1 && end != -1 && start < end { 897 | inlineMetadata := line[start+1 : end] 898 | parseMetadataPair(inlineMetadata, metadata) 899 | line = line[end+1:] 900 | } else { 901 | break 902 | } 903 | } 904 | } else { 905 | parseMetadataPair(line, metadata) 906 | } 907 | } 908 | 909 | func parseMetadataPair(pair string, metadata Metadata) { 910 | parts := strings.SplitN(pair, "::", 2) 911 | if len(parts) == 2 { 912 | // INFO: 913 | // Key has to adhere to the following rules: 914 | // - No leading or trailing spaces 915 | // - Has to be lowercase 916 | // - Only contain alphanumeric characters and hyphens 917 | key := strings.TrimSpace(parts[0]) 918 | key = strings.ToLower(key) 919 | key = strings.ReplaceAll(key, " ", "-") 920 | key = strings.ReplaceAll(key, "*", "") 921 | 922 | value := strings.TrimSpace(parts[1]) 923 | 924 | if i, err := strconv.Atoi(value); err == nil { 925 | metadata[key] = i 926 | } else if b, err := strconv.ParseBool(value); err == nil { 927 | metadata[key] = b 928 | } else { 929 | metadata[key] = value 930 | } 931 | } 932 | } 933 | 934 | func addFileMetadata(path string, metadata *Metadata) { 935 | fileInfo, err := os.Stat(path) 936 | if err == nil { 937 | (*metadata)["file.folder"] = filepath.Base(filepath.Dir(path)) 938 | (*metadata)["file.path"] = path 939 | (*metadata)["file.name"] = filepath.Base(path) 940 | (*metadata)["file.shortname"] = filepath.Base(path)[:len(filepath.Base(path))-3] 941 | (*metadata)["file.link"] = fmt.Sprintf("[%s](%s)", filepath.Base(path), path) 942 | (*metadata)["file.size"] = fileInfo.Size() 943 | (*metadata)["file.ctime"] = fileInfo.ModTime().Format(time.RFC3339) 944 | (*metadata)["file.cday"] = fileInfo.ModTime().Format("2006-01-02") 945 | (*metadata)["file.mtime"] = fileInfo.ModTime().Format(time.RFC3339) 946 | (*metadata)["file.mday"] = fileInfo.ModTime().Format("2006-01-02") 947 | } 948 | } 949 | 950 | func stripYAMLFrontmatter(lines []string) []string { 951 | if len(lines) > 0 && lines[0] == "---" { 952 | endIndex := -1 953 | for i := 1; i < len(lines); i++ { 954 | if lines[i] == "---" { 955 | endIndex = i 956 | break 957 | } 958 | } 959 | if endIndex != -1 { 960 | return lines[endIndex+1:] 961 | } 962 | } 963 | return lines 964 | } 965 | 966 | func parseTasks(lines []string) []string { 967 | var tasks []string 968 | for _, line := range lines { 969 | trimmedLine := strings.TrimLeft(line, " \t") 970 | if isTaskListItem(trimmedLine) { 971 | tasks = append(tasks, line) 972 | } 973 | } 974 | return tasks 975 | } 976 | 977 | func parseParagraphs(lines []string) []string { 978 | var paragraphs []string 979 | var inCodeBlock bool 980 | var inList bool 981 | var emptyLineCount int 982 | 983 | for _, line := range lines { 984 | // Skip fenced blocks and their content 985 | if strings.HasPrefix(line, "```") { 986 | inCodeBlock = !inCodeBlock 987 | continue 988 | } 989 | 990 | if inCodeBlock { 991 | continue 992 | } 993 | 994 | // Skip headings 995 | if strings.HasPrefix(line, "#") { 996 | continue 997 | } 998 | 999 | // Skip unordered list items and tasks 1000 | if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") { 1001 | inList = true 1002 | continue 1003 | } 1004 | 1005 | // Skip ordered list items 1006 | if isOrderedListItem(line) { 1007 | inList = true 1008 | continue 1009 | } 1010 | 1011 | // If we're in a list and the line is empty, we're done with the list 1012 | if inList && strings.TrimSpace(line) == "" { 1013 | inList = false 1014 | } 1015 | 1016 | // Skip indented lines if we're in a list 1017 | if inList && len(line)-len(strings.TrimLeft(line, " ")) > 0 { 1018 | continue 1019 | } 1020 | 1021 | // Handle multiple empty lines 1022 | if strings.TrimSpace(line) == "" { 1023 | emptyLineCount++ 1024 | // Allow only the first empty line, skip the rest 1025 | if emptyLineCount > 1 { 1026 | continue 1027 | } 1028 | } else { 1029 | emptyLineCount = 0 // Reset when a non-empty line is found 1030 | } 1031 | 1032 | paragraphs = append(paragraphs, line) 1033 | } 1034 | 1035 | // Remove the first element if it's an empty line 1036 | if len(paragraphs) > 0 && strings.TrimSpace(paragraphs[0]) == "" { 1037 | paragraphs = paragraphs[1:] 1038 | } 1039 | 1040 | // Remove the last element if it's an empty line 1041 | if len(paragraphs) > 0 && strings.TrimSpace(paragraphs[len(paragraphs)-1]) == "" { 1042 | paragraphs = paragraphs[:len(paragraphs)-1] 1043 | } 1044 | 1045 | return paragraphs 1046 | } 1047 | 1048 | func parseUnorderedLists(lines []string) []string { 1049 | var items []string 1050 | var currentItem []string 1051 | inList := false 1052 | indentLevel := 0 1053 | trailingEmptyLines := 0 1054 | 1055 | for i, line := range lines { 1056 | trimmedLine := strings.TrimSpace(line) 1057 | if isUnorderedListItem(trimmedLine) { 1058 | if len(currentItem) > 0 { 1059 | items = append(items, strings.Join(currentItem[:len(currentItem)-trailingEmptyLines], "\n")) 1060 | currentItem = nil 1061 | trailingEmptyLines = 0 1062 | } 1063 | currentItem = append(currentItem, line) 1064 | inList = true 1065 | indentLevel = len(line) - len(trimmedLine) 1066 | } else if inList && (isUnorderedListItem(line) || len(line)-len(strings.TrimLeft(line, " ")) > indentLevel) { 1067 | currentItem = append(currentItem[:len(currentItem)-trailingEmptyLines], line) 1068 | trailingEmptyLines = 0 1069 | } else if inList && trimmedLine == "" { 1070 | currentItem = append(currentItem, line) 1071 | trailingEmptyLines++ 1072 | } else { 1073 | if len(currentItem) > 0 { 1074 | items = append(items, strings.Join(currentItem[:len(currentItem)-trailingEmptyLines], "\n")) 1075 | currentItem = nil 1076 | trailingEmptyLines = 0 1077 | } 1078 | inList = false 1079 | indentLevel = 0 1080 | } 1081 | 1082 | // Handle the case when we reach the end of the file 1083 | if i == len(lines)-1 && len(currentItem) > 0 { 1084 | items = append(items, strings.Join(currentItem[:len(currentItem)-trailingEmptyLines], "\n")) 1085 | } 1086 | } 1087 | 1088 | return items 1089 | } 1090 | 1091 | func parseOrderedLists(lines []string) []string { 1092 | var items []string 1093 | var currentItem []string 1094 | inList := false 1095 | 1096 | for _, line := range lines { 1097 | trimmedLine := strings.TrimSpace(line) 1098 | if isOrderedListItem(trimmedLine) { 1099 | if inList && len(currentItem) > 0 { 1100 | items = append(items, strings.Join(currentItem, "\n")) 1101 | currentItem = nil 1102 | } 1103 | currentItem = append(currentItem, line) 1104 | inList = true 1105 | } else if inList && trimmedLine == "" { 1106 | if len(currentItem) > 0 { 1107 | items = append(items, strings.Join(currentItem, "\n")) 1108 | currentItem = nil 1109 | } 1110 | inList = false 1111 | } else if inList { 1112 | currentItem = append(currentItem, line) 1113 | } else { 1114 | inList = false 1115 | } 1116 | } 1117 | 1118 | if len(currentItem) > 0 { 1119 | items = append(items, strings.Join(currentItem, "\n")) 1120 | } 1121 | 1122 | return items 1123 | } 1124 | 1125 | func parseFencedCode(lines []string) []string { 1126 | var fencedCode []string 1127 | var currentCode []string 1128 | inCodeBlock := false 1129 | 1130 | for _, line := range lines { 1131 | if strings.HasPrefix(line, "```") { 1132 | if inCodeBlock { 1133 | fencedCode = append(fencedCode, strings.Join(currentCode, "\n")) 1134 | currentCode = nil 1135 | inCodeBlock = false 1136 | } else { 1137 | inCodeBlock = true 1138 | } 1139 | } else if inCodeBlock { 1140 | currentCode = append(currentCode, line) 1141 | } 1142 | } 1143 | 1144 | return fencedCode 1145 | } 1146 | 1147 | func parseMarkdownFiles(paths []string, queryType QueryType) ([]string, []Metadata, error) { 1148 | var results []string 1149 | var metadataList []Metadata 1150 | 1151 | for _, path := range paths { 1152 | if strings.HasPrefix(path, "~") { 1153 | path = filepath.Join(os.Getenv("HOME"), path[1:]) 1154 | } 1155 | 1156 | path = os.ExpandEnv(path) 1157 | fileInfo, err := os.Stat(path) 1158 | if err != nil { 1159 | return nil, nil, err 1160 | } 1161 | 1162 | if fileInfo.IsDir() { 1163 | err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { 1164 | if err != nil { 1165 | return err 1166 | } 1167 | if !info.IsDir() && filepath.Ext(filePath) == ".md" { 1168 | if queryType == LIST { 1169 | results = append(results, "- "+filepath.Base(filePath)) 1170 | _, metadata, err := parseMarkdownContent(filePath, queryType) 1171 | if err != nil { 1172 | return err 1173 | } 1174 | metadataList = append(metadataList, metadata) 1175 | } else { 1176 | content, metadata, err := parseMarkdownContent(filePath, queryType) 1177 | if err != nil { 1178 | return err 1179 | } 1180 | results = append(results, content...) 1181 | for range content { 1182 | metadataList = append(metadataList, metadata) 1183 | } 1184 | } 1185 | } 1186 | return nil 1187 | }) 1188 | if err != nil { 1189 | return nil, nil, err 1190 | } 1191 | } else { 1192 | if queryType == LIST { 1193 | results = append(results, "- "+filepath.Base(path)) 1194 | _, metadata, err := parseMarkdownContent(path, queryType) 1195 | if err != nil { 1196 | return nil, nil, err 1197 | } 1198 | metadataList = append(metadataList, metadata) 1199 | } else { 1200 | content, metadata, err := parseMarkdownContent(path, queryType) 1201 | if err != nil { 1202 | return nil, nil, err 1203 | } 1204 | results = append(results, content...) 1205 | for range content { 1206 | metadataList = append(metadataList, metadata) 1207 | } 1208 | } 1209 | } 1210 | } 1211 | 1212 | return results, metadataList, nil 1213 | } 1214 | 1215 | func isUnorderedListItem(line string) bool { 1216 | trimmedLine := strings.TrimSpace(line) 1217 | return (strings.HasPrefix(trimmedLine, "- ") || strings.HasPrefix(trimmedLine, "* ")) && 1218 | !strings.HasPrefix(trimmedLine, "- [ ]") && 1219 | !strings.HasPrefix(trimmedLine, "- [.]") && 1220 | !strings.HasPrefix(trimmedLine, "- [o]") && 1221 | !strings.HasPrefix(trimmedLine, "- [O]") && 1222 | !strings.HasPrefix(trimmedLine, "- [0]") && 1223 | !strings.HasPrefix(trimmedLine, "- [x]") && 1224 | !strings.HasPrefix(trimmedLine, "- [X]") 1225 | } 1226 | 1227 | func isOrderedListItem(line string) bool { 1228 | trimmedLine := strings.TrimSpace(line) 1229 | if len(trimmedLine) == 0 || !unicode.IsNumber(rune(trimmedLine[0])) { 1230 | return false 1231 | } 1232 | 1233 | for i, char := range trimmedLine { 1234 | if char == ' ' && i > 0 && trimmedLine[i-1] == '.' { 1235 | return true 1236 | } 1237 | if !unicode.IsNumber(char) && char != '.' { 1238 | return false 1239 | } 1240 | } 1241 | return false 1242 | } 1243 | 1244 | func isTaskListItem(line string) bool { 1245 | trimmedLine := strings.TrimSpace(line) 1246 | return strings.HasPrefix(trimmedLine, "- [ ]") || 1247 | strings.HasPrefix(trimmedLine, "- [x]") || 1248 | strings.HasPrefix(trimmedLine, "- [X]") || 1249 | strings.HasPrefix(trimmedLine, "- [.]") || 1250 | strings.HasPrefix(trimmedLine, "- [o]") || 1251 | strings.HasPrefix(trimmedLine, "- [O]") || 1252 | strings.HasPrefix(trimmedLine, "- [0]") 1253 | } 1254 | 1255 | func applyConditions(item string, metadata Metadata, conditions []ConditionNode) bool { 1256 | if len(conditions) == 0 { 1257 | return true 1258 | } 1259 | 1260 | result := true 1261 | for i, condition := range conditions { 1262 | conditionMet := false 1263 | var fieldValue string 1264 | 1265 | if condition.IsMetadata { 1266 | if value, ok := metadata[condition.Field]; ok { 1267 | fieldValue = fmt.Sprintf("%v", value) 1268 | } 1269 | } else { 1270 | fieldValue = item 1271 | } 1272 | 1273 | switch condition.Function { 1274 | case "CONTAINS": 1275 | conditionMet = strings.Contains(strings.ToLower(fieldValue), strings.ToLower(condition.Value)) 1276 | case "IS": 1277 | conditionMet = fieldValue == condition.Value 1278 | case "CHECKED": 1279 | isChecked := strings.Contains(fieldValue, "[x]") || strings.Contains(fieldValue, "[X]") 1280 | conditionMet = isChecked 1281 | } 1282 | 1283 | if condition.IsNegated { 1284 | conditionMet = !conditionMet 1285 | } 1286 | 1287 | if i == 0 { 1288 | result = conditionMet 1289 | } else if condition.LogicalOp == "OR" { 1290 | result = result || conditionMet 1291 | } else { 1292 | result = result && conditionMet 1293 | } 1294 | } 1295 | 1296 | return result 1297 | } 1298 | 1299 | func filterContent(content []string, metadata []Metadata, conditions []ConditionNode) ([]string, []Metadata) { 1300 | var filteredContent []string 1301 | var filteredMetadata []Metadata 1302 | 1303 | for i, item := range content { 1304 | if applyConditions(item, metadata[i], conditions) { 1305 | filteredContent = append(filteredContent, item) 1306 | filteredMetadata = append(filteredMetadata, metadata[i]) 1307 | } 1308 | } 1309 | 1310 | return filteredContent, filteredMetadata 1311 | } 1312 | 1313 | func readFromPipe() (string, error) { 1314 | bytes, err := io.ReadAll(os.Stdin) 1315 | if err != nil { 1316 | return "", err 1317 | } 1318 | return string(bytes), nil 1319 | } 1320 | 1321 | func executeQuery(query string, showAST bool) (string, error) { 1322 | tokens := Lex(query) 1323 | ast, err := Parse(tokens) 1324 | if err != nil { 1325 | return "", fmt.Errorf("failed to parse query: %w", err) 1326 | } 1327 | 1328 | if showAST { 1329 | printTokens(tokens) 1330 | } 1331 | 1332 | result, err := Interpret(ast) 1333 | if err != nil { 1334 | return "", fmt.Errorf("failed to execute query: %w", err) 1335 | } 1336 | 1337 | return result, nil 1338 | } 1339 | 1340 | func printMetadata(metadataList []Metadata) { 1341 | for _, metadata := range metadataList { 1342 | jsonData, err := json.MarshalIndent(metadata, "", " ") 1343 | if err != nil { 1344 | fmt.Println(err) 1345 | return 1346 | } 1347 | fmt.Println(string(jsonData)) 1348 | } 1349 | } 1350 | 1351 | func printTokens(tokens []Token) { 1352 | type jsonToken struct { 1353 | Type string `json:"Type"` 1354 | Value string `json:"Value"` 1355 | } 1356 | 1357 | var jsonTokens []jsonToken 1358 | 1359 | for _, token := range tokens { 1360 | jsonTokens = append(jsonTokens, jsonToken{ 1361 | Type: TokenTypeNames[token.Type], 1362 | Value: token.Value, 1363 | }) 1364 | } 1365 | 1366 | jsonData, err := json.MarshalIndent(jsonTokens, "", " ") 1367 | if err != nil { 1368 | fmt.Println(err) 1369 | return 1370 | } 1371 | 1372 | fmt.Println(string(jsonData)) 1373 | } 1374 | 1375 | var printMetadataFlag bool 1376 | 1377 | func main() { 1378 | var query string 1379 | var err error 1380 | versionFlag := flag.Bool("v", false, "print the version number") 1381 | longVersionFlag := flag.Bool("version", false, "print the version number") 1382 | 1383 | ShowASTFlag := flag.Bool("ast", false, "print the whole AST before showing the results") 1384 | flag.BoolVar(&printMetadataFlag, "metadata", false, "print metadata as JSON") 1385 | 1386 | flag.StringVar(&query, "query", "", "The query string to be processe") 1387 | flag.StringVar(&query, "q", "", "The query string to be processed (shorthand)") 1388 | 1389 | flag.Parse() 1390 | 1391 | if *versionFlag || *longVersionFlag { 1392 | fmt.Println("Version:", version) 1393 | os.Exit(0) 1394 | } 1395 | 1396 | stat, _ := os.Stdin.Stat() 1397 | if (stat.Mode() & os.ModeCharDevice) == 0 { 1398 | if query == "" { 1399 | // Data is being piped to stdin 1400 | query, err = readFromPipe() 1401 | if err != nil { 1402 | fmt.Println("ERROR: Unable to read from pipe:", err) 1403 | os.Exit(1) 1404 | } 1405 | } 1406 | } else if query == "" { 1407 | fmt.Println("No query provided. Use -q or --query to specify the query string.") 1408 | os.Exit(1) 1409 | } 1410 | 1411 | if err != nil { 1412 | fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) 1413 | os.Exit(1) 1414 | } 1415 | 1416 | result, err := executeQuery(query, *ShowASTFlag) 1417 | if err != nil { 1418 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 1419 | os.Exit(1) 1420 | } 1421 | 1422 | w := bufio.NewWriter(os.Stdout) 1423 | _, err = w.WriteString(result + "\n") 1424 | if err != nil { 1425 | fmt.Fprintf(os.Stderr, "Error writing result: %v\n", err) 1426 | os.Exit(1) 1427 | } 1428 | err = w.Flush() 1429 | if err != nil { 1430 | fmt.Fprintf(os.Stderr, "Error flushing output: %v\n", err) 1431 | os.Exit(1) 1432 | } 1433 | } 1434 | --------------------------------------------------------------------------------