aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPřemysl Eric Janouch <p@janouch.name>2023-12-25 07:52:46 +0100
committerPřemysl Eric Janouch <p@janouch.name>2023-12-25 09:36:40 +0100
commit552c9f527a778443a593d11e5ce991a8513a18c1 (patch)
tree2e82862b29377d255b5573daa08423a3ed4cec17
parentbc037315470431ac1f1ba2c7365ca634895e113f (diff)
downloadgallery-552c9f527a778443a593d11e5ce991a8513a18c1.tar.gz
gallery-552c9f527a778443a593d11e5ce991a8513a18c1.tar.xz
gallery-552c9f527a778443a593d11e5ce991a8513a18c1.zip
Tag search
-rw-r--r--main.go128
-rw-r--r--public/gallery.js109
-rw-r--r--public/style.css24
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(&params); 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; */ }