Implements search.
This commit is contained in:
parent
705f212118
commit
3e03ffe76d
9 changed files with 228 additions and 49 deletions
|
|
@ -51,7 +51,6 @@ $logo-height: $navbar-height - 20px;
|
|||
.search-input {
|
||||
@extend .form-input;
|
||||
border-color: $navbar-border-color-dark;
|
||||
display: none; // TODO: Make search work and remove this.
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
|
|
@ -292,6 +291,15 @@ $views-color: #545d70;
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.thread-title {
|
||||
width: 100%;
|
||||
|
||||
a > div {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.post-username {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import auth, email, utils, buildcss
|
|||
|
||||
import frontend/threadlist except User
|
||||
import frontend/[
|
||||
category, postlist, error, header, post, profile, user, karaxutils
|
||||
category, postlist, error, header, post, profile, user, karaxutils, search
|
||||
]
|
||||
|
||||
from htmlgen import tr, th, td, span, input
|
||||
|
|
@ -1378,6 +1378,35 @@ routes:
|
|||
createTFD()
|
||||
resp genPostsRSS(c), "application/atom+xml"
|
||||
|
||||
get "/search.json":
|
||||
cond "q" in request.params
|
||||
let q = @"q"
|
||||
cond q.len > 0
|
||||
|
||||
var results: seq[SearchResult] = @[]
|
||||
|
||||
const queryFT = "fts.sql".slurp.sql
|
||||
let data = [
|
||||
q, q, $ThreadsPerPage, $0, q,
|
||||
q, $ThreadsPerPage, $0, q
|
||||
]
|
||||
for rowFT in fastRows(db, queryFT, data):
|
||||
var content = rowFT[3]
|
||||
try: content = content.rstToHtml() except EParseError: discard
|
||||
results.add(
|
||||
SearchResult(
|
||||
kind: SearchResultKind(rowFT[^1].parseInt()),
|
||||
threadId: rowFT[0].parseInt(),
|
||||
threadTitle: rowFT[1],
|
||||
postId: rowFT[2].parseInt(),
|
||||
postContent: content,
|
||||
creation: rowFT[4].parseInt(),
|
||||
author: selectUser(rowFT[5 .. 9]),
|
||||
)
|
||||
)
|
||||
|
||||
resp Http200, $(%results), "application/json"
|
||||
|
||||
get re"/(.*)":
|
||||
cond request.matches[0].splitFile.ext == ""
|
||||
resp karaxHtml
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import strformat, times, options, json, tables, sugar, httpcore, uri
|
|||
from dom import window, Location
|
||||
|
||||
include karax/prelude
|
||||
import jester/patterns
|
||||
import jester/[patterns]
|
||||
|
||||
import threadlist, postlist, header, profile, newthread, error, about
|
||||
import resetpassword, activateemail
|
||||
import resetpassword, activateemail, search
|
||||
import karaxutils
|
||||
|
||||
type
|
||||
|
|
@ -16,6 +16,7 @@ type
|
|||
about: About
|
||||
resetPassword: ResetPassword
|
||||
activateEmail: ActivateEmail
|
||||
search: Search
|
||||
|
||||
proc copyLocation(loc: Location): Location =
|
||||
# TODO: It sucks that I had to do this. We need a nice way to deep copy in JS.
|
||||
|
|
@ -37,7 +38,8 @@ proc newState(): State =
|
|||
newThread: newNewThread(),
|
||||
about: newAbout(),
|
||||
resetPassword: newResetPassword(),
|
||||
activateEmail: newActivateEmail()
|
||||
activateEmail: newActivateEmail(),
|
||||
search: newSearch()
|
||||
)
|
||||
|
||||
var state = newState()
|
||||
|
|
@ -65,7 +67,8 @@ proc route(routes: openarray[Route]): VNode =
|
|||
let prefix = if appName == "/": "" else: appName
|
||||
for route in routes:
|
||||
let pattern = (prefix & route.n).parsePattern()
|
||||
let (matched, params) = pattern.match(path)
|
||||
var (matched, params) = pattern.match(path)
|
||||
parseUrlQuery($state.url.search, params)
|
||||
if matched:
|
||||
return route.p(params)
|
||||
|
||||
|
|
@ -125,6 +128,11 @@ proc render(): VNode =
|
|||
render(state.resetPassword)
|
||||
)
|
||||
),
|
||||
r("/search",
|
||||
(params: Params) => (
|
||||
render(state.search, params["q"], getLoggedInUser())
|
||||
)
|
||||
),
|
||||
r("/404",
|
||||
(params: Params) => render404()
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ type
|
|||
|
||||
when defined(js):
|
||||
include karax/prelude
|
||||
import karax / [kajax]
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import login, signup, usermenu
|
||||
import karaxutils
|
||||
|
||||
from dom import setTimeout, window, document, getElementById, focus
|
||||
from dom import
|
||||
setTimeout, window, document, getElementById, focus
|
||||
|
||||
|
||||
type
|
||||
State = ref object
|
||||
|
|
@ -74,22 +76,28 @@ when defined(js):
|
|||
proc isLoggedIn*(): bool =
|
||||
not getLoggedInUser().isNone
|
||||
|
||||
proc onKeyDown(e: Event, n: VNode) =
|
||||
let event = cast[KeyboardEvent](e)
|
||||
if event.key == "Enter":
|
||||
navigateTo(makeUri("/search", ("q", $n.value), reuseSearch=false))
|
||||
|
||||
proc renderHeader*(): VNode =
|
||||
if state.data.isNone and state.status == Http200:
|
||||
getStatus()
|
||||
|
||||
let user = state.data.map(x => x.user).flatten
|
||||
result = buildHtml(tdiv()): # TODO: Why do some buildHtml's need this?
|
||||
result = buildHtml(tdiv()):
|
||||
header(id="main-navbar"):
|
||||
tdiv(class="navbar container grid-xl"):
|
||||
section(class="navbar-section"):
|
||||
a(href=makeUri("/")):
|
||||
img(src="/images/logo.png", id="img-logo") # TODO: Customisation.
|
||||
img(src="/images/logo.png", id="img-logo")
|
||||
section(class="navbar-section"):
|
||||
tdiv(class="input-group input-inline"):
|
||||
input(class="search-input input-sm",
|
||||
`type`="text", placeholder="search",
|
||||
id="search-box")
|
||||
id="search-box",
|
||||
onKeyDown=onKeyDown)
|
||||
if state.loading:
|
||||
tdiv(class="loading")
|
||||
elif user.isNone:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import strutils, options, strformat, parseutils
|
||||
import strutils, options, strformat, parseutils, tables
|
||||
|
||||
proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} =
|
||||
## parses `s` into an integer in the range `validRange`. If successful,
|
||||
|
|
@ -27,7 +27,7 @@ when defined(js):
|
|||
include karax/prelude
|
||||
import karax / [kdom]
|
||||
|
||||
import dom except window
|
||||
from dom import nil
|
||||
|
||||
const appName* = "/"
|
||||
|
||||
|
|
@ -53,7 +53,8 @@ when defined(js):
|
|||
(if includeHash: $window.location.hash else: "")
|
||||
|
||||
proc makeUri*(relative: string, params: varargs[(string, string)],
|
||||
appName=appName, includeHash=false): string =
|
||||
appName=appName, includeHash=false,
|
||||
reuseSearch=true): string =
|
||||
var query = ""
|
||||
for i in 0 ..< params.len:
|
||||
let param = params[i]
|
||||
|
|
@ -61,7 +62,7 @@ when defined(js):
|
|||
query.add(param[0] & "=" & param[1])
|
||||
|
||||
if query.len > 0:
|
||||
var search = $window.location.search
|
||||
var search = if reuseSearch: $window.location.search else: ""
|
||||
if search.len != 0: search.add("&")
|
||||
search.add(query)
|
||||
if search[0] != '?': search = "?" & search
|
||||
|
|
@ -74,13 +75,13 @@ when defined(js):
|
|||
dom.pushState(dom.window.history, 0, cstring"", uri)
|
||||
|
||||
# Fire the popState event.
|
||||
dom.window.dispatchEvent(newEvent("popstate"))
|
||||
dom.dispatchEvent(dom.window, dom.newEvent("popstate"))
|
||||
|
||||
proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb?
|
||||
e.preventDefault()
|
||||
|
||||
# TODO: Why does Karax have it's own Node type? That's just silly.
|
||||
let url = cast[dom.Node](n.dom).getAttribute(cstring"href")
|
||||
let url = n.getAttr("href")
|
||||
|
||||
navigateTo(url)
|
||||
|
||||
|
|
@ -99,4 +100,22 @@ when defined(js):
|
|||
makeUri(fmt"/profile/{username}")
|
||||
|
||||
proc renderPostUrl*(threadId, postId: int): string =
|
||||
makeUri(fmt"/t/{threadId}#{postId}")
|
||||
makeUri(fmt"/t/{threadId}#{postId}")
|
||||
|
||||
proc parseUrlQuery*(query: string, result: var Table[string, string])
|
||||
{.deprecated: "use stdlib".} =
|
||||
## Based on copy from Jester. Use stdlib when
|
||||
## https://github.com/nim-lang/Nim/pull/7761 is merged.
|
||||
var i = 0
|
||||
i = query.skip("?")
|
||||
while i < query.len()-1:
|
||||
var key = ""
|
||||
var val = ""
|
||||
i += query.parseUntil(key, '=', i)
|
||||
if query[i] != '=':
|
||||
raise newException(ValueError, "Expected '=' at " & $i &
|
||||
" but got: " & $query[i])
|
||||
inc(i) # Skip =
|
||||
i += query.parseUntil(val, '&', i)
|
||||
inc(i) # Skip &
|
||||
result[$decodeUri(key)] = $decodeUri(val)
|
||||
|
|
@ -10,7 +10,7 @@ type
|
|||
thread*: Thread
|
||||
history*: seq[Thread] ## If the thread was edited this will contain the
|
||||
## older versions of the thread (title/category
|
||||
## changes).
|
||||
## changes). TODO
|
||||
posts*: seq[Post]
|
||||
|
||||
when defined(js):
|
||||
|
|
@ -225,15 +225,7 @@ when defined(js):
|
|||
tdiv(class="post-title"):
|
||||
tdiv(class="post-username"):
|
||||
text post.author.name
|
||||
if post.isModerated:
|
||||
italic(class="fas fa-eye-slash",
|
||||
title="User is moderated")
|
||||
if post.author.rank == Moderator:
|
||||
italic(class="fas fa-shield-alt",
|
||||
title="User is a moderator")
|
||||
if post.author.rank == Admin:
|
||||
italic(class="fas fa-chess-knight",
|
||||
title="User is an admin")
|
||||
renderUserRank(post.author)
|
||||
tdiv(class="post-metadata"):
|
||||
if post.replyingTo.isSome():
|
||||
let replyingTo = post.replyingTo.get()
|
||||
|
|
|
|||
102
src/frontend/search.nim
Normal file
102
src/frontend/search.nim
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
|
||||
import user, options, httpcore, json, times
|
||||
type
|
||||
SearchResultKind* = enum
|
||||
ThreadMatch, PostMatch
|
||||
|
||||
SearchResult* = object
|
||||
kind*: SearchResultKind
|
||||
threadId*: int
|
||||
postId*: int
|
||||
threadTitle*: string
|
||||
postContent*: string
|
||||
author*: User
|
||||
creation*: int64
|
||||
|
||||
proc isModerated*(searchResult: SearchResult): bool =
|
||||
return searchResult.author.rank <= Moderated
|
||||
|
||||
when defined(js):
|
||||
from dom import nil
|
||||
|
||||
include karax/prelude
|
||||
import karax / [vstyles, kajax, kdom]
|
||||
|
||||
import karaxutils, error, threadlist, sugar
|
||||
|
||||
type
|
||||
Search* = ref object
|
||||
list: Option[seq[SearchResult]]
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
query: string
|
||||
|
||||
proc newSearch*(): Search =
|
||||
Search(
|
||||
list: none[seq[SearchResult]](),
|
||||
loading: false,
|
||||
status: Http200,
|
||||
query: ""
|
||||
)
|
||||
|
||||
proc onList(httpStatus: int, response: kstring, state: Search) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
let list = to(parsed, seq[SearchResult])
|
||||
|
||||
state.list = some(list)
|
||||
|
||||
proc genSearchResult(searchResult: SearchResult): VNode =
|
||||
let url = renderPostUrl(searchResult.threadId, searchResult.postId)
|
||||
result = buildHtml():
|
||||
tdiv(class="post", id = $searchResult.postId):
|
||||
tdiv(class="post-icon"):
|
||||
render(searchResult.author, "post-avatar")
|
||||
tdiv(class="post-main"):
|
||||
tdiv(class="post-title"):
|
||||
tdiv(class="thread-title"):
|
||||
a(href=url):
|
||||
verbatim(searchResult.threadTitle)
|
||||
tdiv(class="post-username"):
|
||||
text searchResult.author.name
|
||||
renderUserRank(searchResult.author)
|
||||
tdiv(class="post-metadata"):
|
||||
# TODO: History and replying to.
|
||||
let title = searchResult.creation.fromUnix().local.
|
||||
format("MMM d, yyyy HH:mm")
|
||||
a(href=url, title=title):
|
||||
text renderActivity(searchResult.creation)
|
||||
tdiv(class="post-content"):
|
||||
verbatim(searchResult.postContent)
|
||||
|
||||
proc render*(state: Search, query: string, currentUser: Option[User]): VNode =
|
||||
if state.list.isNone() or state.query != query:
|
||||
state.list = none[seq[SearchResult]]()
|
||||
state.status = Http200
|
||||
state.query = query
|
||||
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve search results.", state.status)
|
||||
|
||||
if state.list.isNone:
|
||||
var params = @[("q", state.query)]
|
||||
let uri = makeUri("search.json", params)
|
||||
ajaxGet(uri, @[], (s: int, r: kstring) => onList(s, r, state))
|
||||
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let list = state.list.get()
|
||||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
tdiv(class="title"):
|
||||
p(): text "Search results"
|
||||
tdiv(class="searchresults"):
|
||||
if list.len == 0:
|
||||
renderMessage("No results found", "", "fa-exclamation")
|
||||
else:
|
||||
for searchResult in list:
|
||||
if not searchResult.visibleTo(currentUser): continue
|
||||
genSearchResult(searchResult)
|
||||
|
|
@ -49,4 +49,22 @@ when defined(js):
|
|||
a(class="user-mention",
|
||||
href=makeUri("/profile/" & user.name),
|
||||
onClick=anchorCB):
|
||||
text "@" & user.name
|
||||
text "@" & user.name
|
||||
|
||||
proc renderUserRank*(user: User): VNode =
|
||||
result = buildHtml():
|
||||
case user.rank
|
||||
of Spammer, Troll, Banned:
|
||||
italic(class="fas fa-eye-ban",
|
||||
title="User is banned")
|
||||
of Rank.User, EmailUnconfirmed:
|
||||
span()
|
||||
of Moderated:
|
||||
italic(class="fas fa-eye-slash",
|
||||
title="User is moderated")
|
||||
of Moderator:
|
||||
italic(class="fas fa-shield-alt",
|
||||
title="User is a moderator")
|
||||
of Admin:
|
||||
italic(class="fas fa-chess-knight",
|
||||
title="User is an admin")
|
||||
35
src/fts.sql
35
src/fts.sql
|
|
@ -4,19 +4,21 @@
|
|||
SELECT
|
||||
thread_id,
|
||||
snippet(thread_fts, '<b>', '</b>', '<b>...</b>') AS thread,
|
||||
0 AS post_id,
|
||||
'' AS header,
|
||||
'' AS content,
|
||||
person.name AS author,
|
||||
post_id,
|
||||
post_content,
|
||||
cdate,
|
||||
author_id,
|
||||
person.name AS author,
|
||||
person.email AS email,
|
||||
strftime('%s', person.lastOnline) AS lastOnline,
|
||||
person.status AS status,
|
||||
person.isDeleted as person_isDeleted,
|
||||
0 AS what
|
||||
FROM (
|
||||
SELECT
|
||||
thread_fts.id AS thread_id,
|
||||
post.id AS post_id,
|
||||
post.creation AS cdate,
|
||||
post.content AS post_content,
|
||||
strftime('%s', post.creation) AS cdate,
|
||||
MIN(post.creation) AS cdate,
|
||||
post.author AS author_id
|
||||
FROM thread_fts
|
||||
|
|
@ -28,7 +30,7 @@ SELECT
|
|||
FROM post_fts JOIN post USING(id)
|
||||
WHERE post_fts MATCH ?
|
||||
)
|
||||
LIMIT ? OFFSET (? - 1) * ?
|
||||
LIMIT ? OFFSET ?
|
||||
)
|
||||
JOIN thread_fts ON thread_fts.id=thread_id
|
||||
JOIN person ON person.id=author_id
|
||||
|
|
@ -40,30 +42,23 @@ SELECT
|
|||
thread.name AS thread,
|
||||
post.id AS post_id,
|
||||
CASE what WHEN 1
|
||||
THEN snippet(post_fts, '<b>', '</b>', '...', what)
|
||||
ELSE post_fts.header END AS header,
|
||||
CASE what WHEN 2
|
||||
THEN snippet(post_fts, '**', '**', '...', what, -45)
|
||||
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
|
||||
person.name AS author,
|
||||
cdate,
|
||||
post.author AS author_id,
|
||||
person.name AS author,
|
||||
person.email AS email,
|
||||
strftime('%s', person.lastOnline) AS lastOnline,
|
||||
person.status AS status,
|
||||
person.isDeleted as person_isDeleted,
|
||||
what
|
||||
FROM post_fts JOIN (
|
||||
-- inner query, selects ids of matching posts, orders and limits them,
|
||||
-- so snippets only for limited count of posts are created (in outer query)
|
||||
SELECT id, post.creation AS cdate, thread, 1 AS what, post.author AS author
|
||||
FROM post_fts JOIN post USING(id)
|
||||
WHERE post_fts.header MATCH ?
|
||||
GROUP BY post.header
|
||||
HAVING SUBSTR(post.header,1,3)<>'Re:'
|
||||
UNION
|
||||
SELECT id, post.creation AS cdate, thread, 2 AS what, post.author AS author
|
||||
SELECT id, strftime('%s', post.creation) AS cdate, thread, 1 AS what, post.author AS author
|
||||
FROM post_fts JOIN post USING(id)
|
||||
WHERE post_fts.content MATCH ?
|
||||
ORDER BY what, cdate DESC
|
||||
LIMIT ? OFFSET (? - 1) * ?
|
||||
LIMIT ? OFFSET ?
|
||||
) AS post USING(id)
|
||||
JOIN thread ON thread.id=thread
|
||||
JOIN person ON person.id=author
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue