From 552c9f527a778443a593d11e5ce991a8513a18c1 Mon Sep 17 00:00:00 2001 From: PÅ™emysl Eric Janouch Date: Mon, 25 Dec 2023 07:52:46 +0100 Subject: Tag search --- main.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++- public/gallery.js | 109 +++++++++++++++++++++++++++++++++++++++++++--- public/style.css | 24 ++++++---- 3 files changed, 246 insertions(+), 15 deletions(-) diff --git a/main.go b/main.go index 53c7e22..8179446 100644 --- a/main.go +++ b/main.go @@ -314,7 +314,7 @@ func getSubentries(tx *sql.Tx, parent int64) (entries []webEntry, err error) { entries = []webEntry{} for rows.Next() { var e webEntry - if err := rows.Scan( + if err = rows.Scan( &e.SHA1, &e.Name, &e.Modified, &e.ThumbW, &e.ThumbH); err != nil { return nil, err } @@ -899,6 +899,131 @@ func handleAPISimilar(w http.ResponseWriter, r *http.Request) { // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// NOTE: AND will mean MULTIPLY(IFNULL(ta.weight, 0)) per SHA1. +const searchCTE = `WITH + matches(sha1, thumbw, thumbh, score) AS ( + SELECT i.sha1, i.thumbw, i.thumbh, ta.weight AS score + FROM tag_assignment AS ta + JOIN image AS i ON i.sha1 = ta.sha1 + WHERE ta.tag = ? + ), + supertags(tag) AS ( + SELECT DISTINCT ta.tag + FROM tag_assignment AS ta + JOIN matches AS m ON m.sha1 = ta.sha1 + ), + scoredtags(tag, score) AS ( + SELECT st.tag, AVG(IFNULL(ta.weight, 0)) AS score + FROM supertags AS st, matches AS m + LEFT JOIN tag_assignment AS ta + ON ta.sha1 = m.sha1 AND ta.tag = st.tag + GROUP BY st.tag + ) +` + +type webTagMatch struct { + SHA1 string `json:"sha1"` + ThumbW int64 `json:"thumbW"` + ThumbH int64 `json:"thumbH"` + Score float32 `json:"score"` +} + +func getTagMatches(tag int64) (matches []webTagMatch, err error) { + rows, err := db.Query(searchCTE+` + SELECT sha1, IFNULL(thumbw, 0), IFNULL(thumbh, 0), score + FROM matches`, tag) + if err != nil { + return nil, err + } + defer rows.Close() + + matches = []webTagMatch{} + for rows.Next() { + var match webTagMatch + if err = rows.Scan(&match.SHA1, + &match.ThumbW, &match.ThumbH, &match.Score); err != nil { + return nil, err + } + matches = append(matches, match) + } + return matches, rows.Err() +} + +type webTagRelated struct { + Tag string `json:"tag"` + Score float32 `json:"score"` +} + +func getTagRelated(tag int64) (result map[string][]webTagRelated, err error) { + rows, err := db.Query(searchCTE+` + SELECT ts.name, t.name, st.score FROM scoredtags AS st + JOIN tag AS t ON st.tag = t.id + JOIN tag_space AS ts ON ts.id = t.space + ORDER BY st.score DESC`, tag) + if err != nil { + return nil, err + } + defer rows.Close() + + result = make(map[string][]webTagRelated) + for rows.Next() { + var ( + space string + r webTagRelated + ) + if err = rows.Scan(&space, &r.Tag, &r.Score); err != nil { + return nil, err + } + result[space] = append(result[space], r) + } + return result, rows.Err() +} + +func handleAPISearch(w http.ResponseWriter, r *http.Request) { + var params struct { + Query string + } + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var result struct { + Matches []webTagMatch `json:"matches"` + Related map[string][]webTagRelated `json:"related"` + } + + space, tag, _ := strings.Cut(params.Query, ":") + + var tagID int64 + err := db.QueryRow(` + SELECT t.id FROM tag AS t + JOIN tag_space AS ts ON t.space = ts.id + WHERE ts.name = ? AND t.name = ?`, space, tag).Scan(&tagID) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, err.Error(), http.StatusNotFound) + return + } else if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if result.Matches, err = getTagMatches(tagID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if result.Related, err = getTagRelated(tagID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := json.NewEncoder(w).Encode(result); err != nil { + log.Println(err) + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // cmdWeb runs a web UI against GD on ADDRESS. func cmdWeb(args []string) error { if len(args) != 2 { @@ -923,6 +1048,7 @@ func cmdWeb(args []string) error { http.HandleFunc("/api/orphans", handleAPIOrphans) http.HandleFunc("/api/info", handleAPIInfo) http.HandleFunc("/api/similar", handleAPISimilar) + http.HandleFunc("/api/search", handleAPISearch) host, port, err := net.SplitHostPort(address) if err != nil { diff --git a/public/gallery.js b/public/gallery.js index 52a92ab..3c64bac 100644 --- a/public/gallery.js +++ b/public/gallery.js @@ -43,6 +43,18 @@ let Header = { }, ], + search: [ + { + route: '/search', + render: () => m(m.route.Link, { + href: `/search/:key`, + params: {key: m.route.param('key')}, + class: m.route.get().startsWith('/search') + ? 'active' : undefined, + }, "Search"), + }, + ], + view(vnode) { const route = m.route.get() const main = this.global.map(x => @@ -54,6 +66,8 @@ let Header = { let context if (this.image.some(x => route.startsWith(x.route))) context = this.image.map(x => x.render()) + if (this.search.some(x => route.startsWith(x.route))) + context = this.search.map(x => x.render()) return m('.header', {}, [ m('nav', main), @@ -90,7 +104,7 @@ let BrowseModel = { this.entries = [] } - let resp = await call('browse', {path: path}) + let resp = await call('browse', {path}) this.subdirectories = resp.subdirectories this.entries = resp.entries.sort((a, b) => this.collator.compare(a.name, b.name)) @@ -175,8 +189,10 @@ let Browse = { return m('.container', {}, [ m(Header), m('.body', {}, [ - m('ul.sidebar', BrowseModel.getBrowseLinks().map(link => - m(BrowseBarLink, {link}))), + m('.sidebar', [ + m('ul.path', BrowseModel.getBrowseLinks() + .map(link => m(BrowseBarLink, {link}))), + ]), m(BrowseView), ]), ]) @@ -428,9 +444,14 @@ let ViewBar = { m("ul.tags", Object.entries(tags) .sort(([t1, w1], [t2, w2]) => (w2 - w1)) .map(([tag, weight]) => m("li", [ + // XXX: Duplicated with SearchRelated. m("meter[max=1.0]", {value: weight, title: weight}, weight), - ` ${tag}`, + ` `, + m(m.route.Link, { + href: `/search/:key`, + params: {key: `${group}:${tag}`}, + }, ` ${tag}`), ]))), ]), ]) @@ -541,6 +562,84 @@ let Similar = { // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +let SearchModel = { + query: undefined, + matches: [], + related: {}, + + async reload(query) { + if (this.query !== query) { + this.query = query + this.matches = [] + this.related = {} + } + + let resp = await call('search', {query}) + this.matches = resp.matches + this.related = resp.related + }, +} + +let SearchRelated = { + view(vnode) { + return Object.entries(SearchModel.related) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([space, tags]) => [ + m('h2', space), + m('ul.tags', tags + .sort((a, b) => (b.score - a.score)) + .map(({tag, score}) => m('li', [ + // XXX: Duplicated with ViewBar. + m("meter[max=1.0]", + {value: score, title: score}, score), + ` `, + m(m.route.Link, { + href: `/search/:key`, + params: {key: `${space}:${tag}`}, + }, ` ${tag}`), + ]))), + ]) + }, +} + +let SearchView = { + // See BrowseView. + oncreate(vnode) { vnode.dom.focus() }, + + view(vnode) { + return m('.browser[tabindex=0]', { + // Trying to force the oncreate on path changes. + key: SearchModel.path, + }, SearchModel.matches + .sort((a, b) => b.score - a.score) + .map(info => { + return m(m.route.Link, {href: `/view/${info.sha1}`}, + m(Thumbnail, {info, title: info.score})) + })) + }, +} + +let Search = { + oninit(vnode) { + SearchModel.reload(vnode.attrs.key) + }, + + view(vnode) { + return m('.container', {}, [ + m(Header), + m('.body', {}, [ + m('.sidebar', [ + m('p', SearchModel.query), + m(SearchRelated), + ]), + m(SearchView), + ]), + ]) + }, +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + window.addEventListener('load', () => { m.route(document.body, "/browse/", { // The path doesn't need to be escaped, perhaps change that (":key..."). @@ -554,6 +653,6 @@ window.addEventListener('load', () => { "/view/:key": View, "/similar/:key": Similar, - "/search/:key": undefined, + "/search/:key": Search, }) }) diff --git a/public/style.css b/public/style.css index 701dc8a..e8eb0bc 100644 --- a/public/style.css +++ b/public/style.css @@ -23,23 +23,27 @@ a { color: inherit; } .header nav a.active, .header nav a:hover { padding-bottom: .4rem; } .header .activity { padding: .25rem .5rem; align-self: center; color: #fff; } -ul.sidebar { margin: 0; padding: .5rem 0; background: var(--shade-color); - min-width: 10rem; overflow: auto; border-right: 1px solid #ccc; } - -ul.sidebar li { margin: 0; padding: 0; } -ul.sidebar li a { padding: .25rem .5rem; padding-left: 30px; +.sidebar { padding: .25rem .5rem; background: var(--shade-color); + border-right: 1px solid #ccc; overflow: auto; + min-width: 10rem; max-width: 20rem; flex-shrink: 0; } +.sidebar h2 { margin: 0.5em 0 0.25em 0; padding: 0; font-size: 1.2rem; } +.sidebar ul { margin: .5rem 0; padding: 0; } + +.sidebar .path { margin: .5rem -.5rem; } +.sidebar .path li { margin: 0; padding: 0; } +.sidebar .path li a { padding: .25rem .5rem; padding-left: 30px; display: block; text-decoration: none; white-space: nowrap; } -ul.sidebar li a:hover { background-color: rgb(0 0 0 / 10%); } +.sidebar .path li a:hover { background-color: rgb(0 0 0 / 10%); } -ul.sidebar li.parent a { +.sidebar .path li.parent a { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath d='M 4 14 10 8 16 14' stroke='%23888' stroke-width='4' fill='none' /%3E%3C/svg%3E%0A"); background-repeat: no-repeat; background-position: 5px center; } -ul.sidebar li.selected a { font-weight: bold; +.sidebar .path li.selected a { font-weight: bold; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='6' fill='%23888' /%3E%3C/svg%3E%0A"); background-repeat: no-repeat; background-position: 5px center; } -ul.sidebar li.child a { +.sidebar .path li.child a { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath d='M 4 6 10 12 16 6' stroke='%23888' stroke-width='4' fill='none' /%3E%3C/svg%3E%0A"); background-repeat: no-repeat; background-position: 5px center; } @@ -78,6 +82,8 @@ img.thumbnail, .thumbnail.missing { box-shadow: 0 0 3px rgba(0, 0, 0, 0.75); .viewbar ul { margin: 0; padding: 0 0 0 1.25em; list-style-type: "- "; } .viewbar ul.tags { padding: 0; list-style-type: none; } .viewbar li { margin: 0; padding: 0; } + +.sidebar meter, .viewbar meter { width: 1.25rem; /* background: white; border: 1px solid #ccc; */ } -- cgit v1.2.3-70-g09d2