diff --git a/.gitignore b/.gitignore index 8e6a209..3047903 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,5 @@ dist # Serverless Webpack directories .webpack/ + +*_templ.go diff --git a/NOTE.md b/NOTE.md new file mode 100644 index 0000000..9346b9f --- /dev/null +++ b/NOTE.md @@ -0,0 +1,85 @@ +# Spellfix1 + +missing module from `mattn/sqlite`, need to use https://github.com/mattn/go-sqlite3/issues/1160 + +1. ensure spellfix is available (likely compile tags) + +2. have bookmark table + - `CREATE VIRTUAL TABLE IF NOT EXISTS Bookmarks_vocabulary USING spellfix1` +3. populate vocab table with all bookmarks (title, tags, description) + - will need a 'migration' capability +4. add better ui info showing that we are doing a multi-stage setup process + - [ ] possibly stream in the results if it doesn't happen in time? + + +Example: +``` +-- User searches: "javascrpt tutorial" +-- 1. FTS5 search (no results) +SELECT * FROM Bookmarks_fts WHERE Bookmarks_fts MATCH 'javascrpt tutorial'; +-- 2. Spellfix1 correction +SELECT word FROM spellfix WHERE word MATCH 'javascrpt'; -- returns "javascript" +-- 3. Retry FTS5 with corrected query +SELECT * FROM Bookmarks_fts WHERE Bookmarks_fts MATCH 'javascript tutorial'; +``` + + +```go +func CorrectSpelling(db *DB, query string) string { + words := strings.Fields(query) + correctedWords := make([]string, len(words)) + + for i, word := range words { + // Skip operators + if word == "AND" || word == "OR" || word == "NOT" { + correctedWords[i] = word + continue + } + + // Try to find spelling correction + var suggestion string + err := db.QueryRow(` + SELECT word FROM Bookmarks_vocabulary + WHERE word MATCH ? + AND top = 1 + LIMIT 1; + `, word).Scan(&suggestion) + + if err == nil && suggestion != "" { + correctedWords[i] = suggestion + } else { + correctedWords[i] = word + } + } + return strings.Join(correctedWords, " ") +} +``` + + +pseudocode: + +```go +func AutoCorrectingSearch(db *DB, query string) ([]Bookmark, error) { + // Tier 1: Try exact FTS match + results, err := SearchBookmarks(db, query) + if err == nil && len(results) > 0 { + return results, nil + } + + // Tier 2: Try spell correction + correctedQuery := CorrectSpelling(db, query) + + // Only retry if correction actually changed something + if correctedQuery != query { + results, err = SearchBookmarks(db, correctedQuery) + if err == nil && len(results) > 0 { + return results, nil + } + } + + // Tier 3: do vector search matching + + // Return whatever we have (may be empty) + return results, err +} +``` diff --git a/cmd/searchTui.go b/cmd/searchTui.go deleted file mode 100644 index b54b9a1..0000000 --- a/cmd/searchTui.go +++ /dev/null @@ -1,148 +0,0 @@ -/* -Copyright © 2025 Lukas Werner -*/ -package cmd - -import ( - "fmt" - "log" - - _ "embed" - - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/lukasmwerner/mark/store" - "github.com/muesli/termenv" - "github.com/spf13/cobra" -) - -//go:embed logo.ascii -var markascii string - -var blueLink = lipgloss.NewStyle().Foreground(lipgloss.Color("#8FA8FF")) -var tagsStyling = lipgloss.NewStyle().Foreground(lipgloss.Color("#DBBC7F")) - -type tuiAppModel struct { - db *store.DB - searchBox textinput.Model - viewport viewport.Model - bookmarks []store.Bookmark - width int - height int - ready bool -} - -func (m tuiAppModel) Init() tea.Cmd { return nil } - -func (m tuiAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - if !m.ready { - m.viewport = viewport.New(msg.Width, msg.Height-2) - m.viewport.YPosition = 2 - m.ready = true - } else { - m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - 2 - } - case tea.KeyMsg: - switch msg.String() { - case "enter": - m.searchBox.Blur() - bookmarks, err := store.SearchBookmarks(m.db, m.searchBox.Value()) - if err != nil { - log.Fatalln("search failed") - } - m.bookmarks = bookmarks - - results := "" - for _, bm := range m.bookmarks { - results += fmt.Sprintf("%s <%s>\n%s", blueLink.Render(termenv.Hyperlink(bm.Url, bm.Title)), tagsStyling.Render(fmt.Sprint(bm.Tags)), bm.Description) - results += "\n---\n" - } - m.viewport.SetContent(results) - } - } - if m.searchBox.Focused() { - var cmd tea.Cmd - m.searchBox, cmd = m.searchBox.Update(msg) - cmds = append(cmds, cmd) - } else { - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) -} -func (m tuiAppModel) View() string { - out := "" - if m.searchBox.Focused() { - searchBox := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - Render(m.searchBox.View()) - out = lipgloss.JoinVertical(lipgloss.Center, markascii, searchBox) - out = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, out) - } else { - out := "mark: results for> " + m.searchBox.Value() - out = lipgloss.NewStyle(). - Border(lipgloss.BlockBorder()). - BorderBottom(true). - BorderLeft(false). - BorderRight(false). - BorderTop(false). - Width(m.width). - Render(out) - return out + "\n" + m.viewport.View() - } - - return out - -} - -// searchCmd represents the search tui command -var searchCmd = &cobra.Command{ - Use: "search", - Short: `[EXPERIMENTAL] google search like tui intended for hosting via gotty or ttyd`, - Run: func(cmd *cobra.Command, args []string) { - db, err := store.Open() - if err != nil { - log.Fatalln("unable to connect to backing store") - } - input := textinput.New() - input.Placeholder = "Search" - input.Prompt = "" - input.Focus() - input.Width = 60 - - m := tuiAppModel{ - db: db, - searchBox: input, - } - - prog := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) - - if _, err := prog.Run(); err != nil { - fmt.Println("Error running program:", err.Error()) - return - } - }, -} - -func init() { - rootCmd.AddCommand(searchCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // searchTuiCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // searchTuiCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") -} diff --git a/cmd/sql.go b/cmd/sql.go index a94363d..ef3aa4f 100644 --- a/cmd/sql.go +++ b/cmd/sql.go @@ -7,6 +7,7 @@ import ( "bufio" "database/sql" "fmt" + "log" "os" "strings" @@ -51,8 +52,14 @@ var sqlCmd = &cobra.Command{ defer db.Close() if len(args) > 0 { + fmt.Println(args) query := strings.Join(args, " ") - executeQuery(db, query) + fmt.Println(query) + rows, err := executeQuery(db, query) + if err != nil { + log.Fatalln(err.Error()) + } + renderResults(rows) return } reader := bufio.NewReader(os.Stdin) diff --git a/cmd/web-search.go b/cmd/web-search.go new file mode 100644 index 0000000..10e49ac --- /dev/null +++ b/cmd/web-search.go @@ -0,0 +1,167 @@ +/* +Copyright © 2025 Lukas Werner +*/ +package cmd + +import ( + "fmt" + "log" + "net/http" + "strings" + + _ "embed" + + "github.com/a-h/templ" + "github.com/lukasmwerner/mark/store" + "github.com/lukasmwerner/mark/web" + "github.com/lukasmwerner/mark/web/static" + "github.com/spf13/cobra" +) + +type RankingMethod string + +const ( + recency RankingMethod = "rowid DESC, rank DESC" // rank based on rowid will be an issue when going across users + ftsRank RankingMethod = "rank DESC, rowid DESC" +) + +func WebSearchBookmarks(db *store.DB, query string, ranker RankingMethod) ([]store.Bookmark, error) { + bookmarks := []store.Bookmark{} + + partials := "" + for _, field := range strings.Fields(query) { + partial := "" + switch field { + case "NOT": + partial = field + case "OR": + partial = field + case "AND": + partial = field + default: + partial = field + "*" + + } + partials += " " + partial + } + + rows, err := db.Query( + ` + SELECT + url, + title, + description, + rowid, + tags + FROM ( + SELECT + url, + title, + description, + tags, + rowid, + rank, + 1 AS priority + FROM Bookmarks_fts + WHERE Bookmarks_fts MATCH ? + + UNION + + SELECT + url, + title, + description, + tags, + rowid, + rank, + 2 AS priority + FROM Bookmarks_fts + WHERE Bookmarks_fts MATCH ?) + + GROUP BY url + ORDER BY MIN(priority) ASC, `+string(ranker)+`;`, + query, partials) + + // rows, err := db.Query( + // `SELECT url, title, description, tags FROM Bookmarks_fts WHERE Bookmarks_fts MATCH ? ORDER BY rowid DESC, rank DESC;`, + // query) + if err != nil { + return bookmarks, err + } + defer rows.Close() + + for rows.Next() { + var b store.Bookmark + var tags string + var id int + err := rows.Scan(&b.Url, &b.Title, &b.Description, &id, &tags) + if err != nil { + return bookmarks, err + } + b.Tags = strings.Split(tags, ", ") + bookmarks = append(bookmarks, b) + } + + return bookmarks, nil +} + +var searchCmd = &cobra.Command{ + Use: "search", + Short: `[EXPERIMENTAL] google search like interface`, + Run: func(cmd *cobra.Command, args []string) { + db, err := store.Open() + if err != nil { + log.Println(err.Error()) + return + } + + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static.FS)))) + http.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q == "" { + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + unsafe_order := r.URL.Query().Get("order") + order := recency + orderBy := "Recency" + if unsafe_order == string("fts") { + order = ftsRank + orderBy = "FTS" + } + results, err := WebSearchBookmarks(db, q, order) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, "oops we had something go wrong.") + fmt.Fprintln(w, err.Error()) + return + } + templ.Handler(web.ResultsPage("lukaswerner.com", q, orderBy, results)).ServeHTTP(w, r) + }) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + n := store.CountBookmarks(db) + templ.Handler(web.LandingPage("lukaswerner.com", n)).ServeHTTP(w, r) + }) + + err = http.ListenAndServe(":1995", nil) + if err != nil { + log.Println(err.Error()) + return + } + + }, +} + +func init() { + rootCmd.AddCommand(searchCmd) + + // Here you will define your flags and configuration settings. + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // searchTuiCmd.PersistentFlags().String("foo", "", "A help for foo") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // searchTuiCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} diff --git a/go.mod b/go.mod index 33ed68a..233370d 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( ) require ( + github.com/a-h/templ v0.3.960 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect ) @@ -45,7 +46,7 @@ require ( github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/net v0.24.0 // indirect + golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect diff --git a/go.sum b/go.sum index 724533d..1641345 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= +github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -114,6 +116,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/store/database.go b/store/database.go index 02d5821..0573abe 100644 --- a/store/database.go +++ b/store/database.go @@ -245,7 +245,7 @@ func GetBookmark(db *DB, query_url string) (Bookmark, error) { func SearchBookmarks(db *DB, query string) ([]Bookmark, error) { bookmarks := []Bookmark{} query = strings.Join(strings.Fields(query), "* ") + "*" - rows, err := db.Query(`SELECT url, title, description, tags FROM Bookmarks_fts WHERE Bookmarks_fts MATCH ?;`, query) + rows, err := db.Query(`SELECT url, title, description, tags FROM Bookmarks_fts WHERE Bookmarks_fts MATCH ? ORDER BY bm25(Bookmarks_fts) DESC;`, query) if err != nil { return bookmarks, err } diff --git a/web/landing.templ b/web/landing.templ new file mode 100644 index 0000000..434b182 --- /dev/null +++ b/web/landing.templ @@ -0,0 +1,60 @@ +package web + +import "strconv" +import "math/rand" + +func Humanize(num int) string { + if num >= 1000 { + n := float64(num) / 1000 + return strconv.FormatFloat(n, 'g', -1, 32) + "k" + } + return strconv.Itoa(num) +} + +func Tagline() string { + taglines := []string{ + "a remarkable internet", + "a web for humans", + "an indie web champion", + "a vibrant internet hypothesis", + } + return taglines[rand.Intn(len(taglines))] +} + +templ LandingPage(markHostname string, numBookmarks int) { + @Page() { + @Head("mark: " + markHostname) + @Body() { + + @StatusBar("bottom") { + @StatusBarGroup("") { + @StatusBarItem(markHostname, "#A7C080") + } + @StatusBarGroup("") { + @StatusBarItem(Humanize(numBookmarks)+" Bookmarks", "#D3C6AA") + } + } + + } + } +} diff --git a/web/page.templ b/web/page.templ new file mode 100644 index 0000000..e192f8d --- /dev/null +++ b/web/page.templ @@ -0,0 +1,29 @@ +package web + +templ Page() { + + { children... } + +} + +templ Head(title string) { + + { title } + + + + + + + + + { children... } + +} + +templ Body() { + + { children... } + + +} diff --git a/web/results.templ b/web/results.templ new file mode 100644 index 0000000..c51f15a --- /dev/null +++ b/web/results.templ @@ -0,0 +1,73 @@ +package web + +import ( + "github.com/lukasmwerner/mark/store" + "net/url" +) + +func DomainOnly(u string) string { + ur, err := url.Parse(u) + if err != nil { + return "" + } + return ur.Hostname() + +} + +templ ResultsPage(markHostname string, query string, orderBy string, results []store.Bookmark) { + @Page() { + @Head(query + " - mark: " + markHostname) + @Body() { + @StatusBar("nav") { + @StatusBarGroup("width: 100%;") { +
mark
+ + } + @StatusBarGroup("") { +
+
Order By: { orderBy }
+ +
+ @StatusBarItem(Humanize(len(results))+" Results", "#A7C080") + @StatusBarItem(markHostname, "#4A545C") + } + } + if len(results) == 0 { +
+

No results, yet.

+
+ } +
+ for _, result := range results { +
+ { result.Title } +
+ { DomainOnly(result.Url) } +

{ result.Description }

+
+ } +
+ + } + } +} diff --git a/web/static/chips.js b/web/static/chips.js new file mode 100644 index 0000000..9a78a45 --- /dev/null +++ b/web/static/chips.js @@ -0,0 +1,62 @@ +document.addEventListener("DOMContentLoaded", function() { + ["chip", "a.chip"].forEach((selec) => { + const chips = document.querySelectorAll(selec); + chips.forEach((chip) => { + let fullUrl = ''; + let title = ''; + if (selec == "chip") { + fullUrl = chip.textContent.trim(); + title = chip.title; + } else { + fullUrl = chip.href; + title = chip.textContent.trim(); + } + try { + const url = new URL(fullUrl); + const domain = url.hostname.replace("www.", ""); + if (title == "") { + chip.textContent = domain; + } else { + chip.textContent = title; + } + if (selec == "chip") { + chip.addEventListener("click", () => { + window.open(fullUrl, "_self"); + }); + } + + const faviconUrl = + `https://www.google.com/s2/favicons?sz=64&domain=${domain}`; + + const img = new Image(); + img.onload = function() { + const style = document.createElement("style"); + if (selec == "chip") { + style.textContent = ` + chip[data-domain="${domain}"]::before { + background-image: url(${faviconUrl}); + background-color: transparent; + } + `; + } else { + style.textContent = ` + a.chip[data-domain="${domain}"]::before { + background-image: url(${faviconUrl}); + background-color: transparent; + } + `; + } + document.head.appendChild(style); + chip.setAttribute("data-domain", domain); + chip.classList.add("favicon-loaded"); + }; + img.onerror = function() { + console.log(`Favicon not found for ${domain}, using fallback`); + }; + img.src = faviconUrl; + } catch (e) { + console.error("Invalid URL in chip:", fullUrl); + } + }); + }) +}); diff --git a/web/static/embed.go b/web/static/embed.go new file mode 100644 index 0000000..ce62ea1 --- /dev/null +++ b/web/static/embed.go @@ -0,0 +1,6 @@ +package static + +import "embed" + +//go:embed * +var FS embed.FS diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..0d6dd6e --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,146 @@ +body { + margin: 0; + color: #D3C6AA; + background: #2C353A; + font-family: 'IBM Plex Mono', monospace; +} + +a { + color: #D3C6AA; +} +a:link { + color: #D3C6AA; +} +a:visited { + color: #BEAA81; +} + +.bottom { + position: absolute; + width: 100%; + bottom: 0; +} + +.center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + +} + +.searchbox { + color: #D3C6AA; + h1 { + font-size: 70pt; + font-weight: 800; + margin: 2pt; + } + h3 { + font-size: 20pt; + margin: 2pt; + } + input[type=search] { + font-size: 30pt; + font-family: 'IBM Plex Mono', monospace; + padding: 6pt; + color: #9DA9A0; + width: 60vw; + background: #232A2E; + border: 3pt solid #4F585E; + margin: 2em; + } + input[type=search]:focus { + outline-width: 0; + } +} + +.nav { + position: fixed; + top: 0; + right: 0; + left: 0; + color: #232A2E; + input[type=search] { + font-family: 'IBM Plex Mono', monospace; + color: #9DA9A0; + background: #232A2E; + margin: 0; + padding: 1em; + border: 0; + width: 100%; + } + input[type=search]:focus { + outline-width: 0; + } + a { + color: #232A2E; + text-decoration: none; + } +} + +.statusbar { + background: #232A2E; + color: #232A2E; + display: flex; + justify-content: space-between; + align-items: stretch; + + .statusbar-group { + display: flex; + align-items: stretch; + } + + a { + display: flex; + align-items: stretch; + } + + .statusbar-item { + margin: 0; + padding: 1em; + position: relative; + display: flex; + align-items: center; + } +} + +.dropdown { + position: fixed; + top: 40px; +} + +.dropdown ul { + background: #D3C6AA; + padding: 1em; + margin: 0; +} + +.results { + margin: 4em; + + .result { + .title { + font-size: 1.5rem; + } + .domain { + color: #D3C6AA; + background: #3D484D; + border-radius: 50px; + font-size: 0.9rem; + padding: 0.5em; + padding-left: 1em; + padding-right: 1em; + margin-top: 0.5em; + margin-bottom: 0.5em; + display: inline-flex; + } + p { + color: #C4C0B9; + margin-top: 0.5em + margin-bottom: 0.5em; + } + } +} diff --git a/web/statusbar.templ b/web/statusbar.templ new file mode 100644 index 0000000..294e470 --- /dev/null +++ b/web/statusbar.templ @@ -0,0 +1,17 @@ +package web + +templ StatusBar(class string) { +
+ { children... } +
+} + +templ StatusBarGroup(style string) { +
+ { children... } +
+} + +templ StatusBarItem(value string, backgroundColor string) { +
{ value }
+}