Implements search.

This commit is contained in:
Dominik Picheta 2018-05-22 14:58:29 +01:00
commit 3e03ffe76d
9 changed files with 228 additions and 49 deletions

View file

@ -51,7 +51,6 @@ $logo-height: $navbar-height - 20px;
.search-input { .search-input {
@extend .form-input; @extend .form-input;
border-color: $navbar-border-color-dark; border-color: $navbar-border-color-dark;
display: none; // TODO: Make search work and remove this.
} }
.search-input:focus { .search-input:focus {
@ -292,6 +291,15 @@ $views-color: #545d70;
text-decoration: none; text-decoration: none;
} }
.thread-title {
width: 100%;
a > div {
display: inline-block;
}
}
.post-username { .post-username {
font-weight: bold; font-weight: bold;
display: inline-block; display: inline-block;

View file

@ -18,7 +18,7 @@ import auth, email, utils, buildcss
import frontend/threadlist except User import frontend/threadlist except User
import frontend/[ 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 from htmlgen import tr, th, td, span, input
@ -1378,6 +1378,35 @@ routes:
createTFD() createTFD()
resp genPostsRSS(c), "application/atom+xml" 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"/(.*)": get re"/(.*)":
cond request.matches[0].splitFile.ext == "" cond request.matches[0].splitFile.ext == ""
resp karaxHtml resp karaxHtml

View file

@ -2,10 +2,10 @@ import strformat, times, options, json, tables, sugar, httpcore, uri
from dom import window, Location from dom import window, Location
include karax/prelude include karax/prelude
import jester/patterns import jester/[patterns]
import threadlist, postlist, header, profile, newthread, error, about import threadlist, postlist, header, profile, newthread, error, about
import resetpassword, activateemail import resetpassword, activateemail, search
import karaxutils import karaxutils
type type
@ -16,6 +16,7 @@ type
about: About about: About
resetPassword: ResetPassword resetPassword: ResetPassword
activateEmail: ActivateEmail activateEmail: ActivateEmail
search: Search
proc copyLocation(loc: Location): Location = proc copyLocation(loc: Location): Location =
# TODO: It sucks that I had to do this. We need a nice way to deep copy in JS. # 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(), newThread: newNewThread(),
about: newAbout(), about: newAbout(),
resetPassword: newResetPassword(), resetPassword: newResetPassword(),
activateEmail: newActivateEmail() activateEmail: newActivateEmail(),
search: newSearch()
) )
var state = newState() var state = newState()
@ -65,7 +67,8 @@ proc route(routes: openarray[Route]): VNode =
let prefix = if appName == "/": "" else: appName let prefix = if appName == "/": "" else: appName
for route in routes: for route in routes:
let pattern = (prefix & route.n).parsePattern() 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: if matched:
return route.p(params) return route.p(params)
@ -125,6 +128,11 @@ proc render(): VNode =
render(state.resetPassword) render(state.resetPassword)
) )
), ),
r("/search",
(params: Params) => (
render(state.search, params["q"], getLoggedInUser())
)
),
r("/404", r("/404",
(params: Params) => render404() (params: Params) => render404()
), ),

View file

@ -8,12 +8,14 @@ type
when defined(js): when defined(js):
include karax/prelude include karax/prelude
import karax / [kajax] import karax / [kajax, kdom]
import login, signup, usermenu import login, signup, usermenu
import karaxutils import karaxutils
from dom import setTimeout, window, document, getElementById, focus from dom import
setTimeout, window, document, getElementById, focus
type type
State = ref object State = ref object
@ -74,22 +76,28 @@ when defined(js):
proc isLoggedIn*(): bool = proc isLoggedIn*(): bool =
not getLoggedInUser().isNone 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 = proc renderHeader*(): VNode =
if state.data.isNone and state.status == Http200: if state.data.isNone and state.status == Http200:
getStatus() getStatus()
let user = state.data.map(x => x.user).flatten 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"): header(id="main-navbar"):
tdiv(class="navbar container grid-xl"): tdiv(class="navbar container grid-xl"):
section(class="navbar-section"): section(class="navbar-section"):
a(href=makeUri("/")): 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"): section(class="navbar-section"):
tdiv(class="input-group input-inline"): tdiv(class="input-group input-inline"):
input(class="search-input input-sm", input(class="search-input input-sm",
`type`="text", placeholder="search", `type`="text", placeholder="search",
id="search-box") id="search-box",
onKeyDown=onKeyDown)
if state.loading: if state.loading:
tdiv(class="loading") tdiv(class="loading")
elif user.isNone: elif user.isNone:

View file

@ -1,4 +1,4 @@
import strutils, options, strformat, parseutils import strutils, options, strformat, parseutils, tables
proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} = proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} =
## parses `s` into an integer in the range `validRange`. If successful, ## parses `s` into an integer in the range `validRange`. If successful,
@ -27,7 +27,7 @@ when defined(js):
include karax/prelude include karax/prelude
import karax / [kdom] import karax / [kdom]
import dom except window from dom import nil
const appName* = "/" const appName* = "/"
@ -53,7 +53,8 @@ when defined(js):
(if includeHash: $window.location.hash else: "") (if includeHash: $window.location.hash else: "")
proc makeUri*(relative: string, params: varargs[(string, string)], proc makeUri*(relative: string, params: varargs[(string, string)],
appName=appName, includeHash=false): string = appName=appName, includeHash=false,
reuseSearch=true): string =
var query = "" var query = ""
for i in 0 ..< params.len: for i in 0 ..< params.len:
let param = params[i] let param = params[i]
@ -61,7 +62,7 @@ when defined(js):
query.add(param[0] & "=" & param[1]) query.add(param[0] & "=" & param[1])
if query.len > 0: if query.len > 0:
var search = $window.location.search var search = if reuseSearch: $window.location.search else: ""
if search.len != 0: search.add("&") if search.len != 0: search.add("&")
search.add(query) search.add(query)
if search[0] != '?': search = "?" & search if search[0] != '?': search = "?" & search
@ -74,13 +75,13 @@ when defined(js):
dom.pushState(dom.window.history, 0, cstring"", uri) dom.pushState(dom.window.history, 0, cstring"", uri)
# Fire the popState event. # 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? proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb?
e.preventDefault() e.preventDefault()
# TODO: Why does Karax have it's own Node type? That's just silly. # 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) navigateTo(url)
@ -99,4 +100,22 @@ when defined(js):
makeUri(fmt"/profile/{username}") makeUri(fmt"/profile/{username}")
proc renderPostUrl*(threadId, postId: int): string = 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)

View file

@ -10,7 +10,7 @@ type
thread*: Thread thread*: Thread
history*: seq[Thread] ## If the thread was edited this will contain the history*: seq[Thread] ## If the thread was edited this will contain the
## older versions of the thread (title/category ## older versions of the thread (title/category
## changes). ## changes). TODO
posts*: seq[Post] posts*: seq[Post]
when defined(js): when defined(js):
@ -225,15 +225,7 @@ when defined(js):
tdiv(class="post-title"): tdiv(class="post-title"):
tdiv(class="post-username"): tdiv(class="post-username"):
text post.author.name text post.author.name
if post.isModerated: renderUserRank(post.author)
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")
tdiv(class="post-metadata"): tdiv(class="post-metadata"):
if post.replyingTo.isSome(): if post.replyingTo.isSome():
let replyingTo = post.replyingTo.get() let replyingTo = post.replyingTo.get()

102
src/frontend/search.nim Normal file
View 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)

View file

@ -49,4 +49,22 @@ when defined(js):
a(class="user-mention", a(class="user-mention",
href=makeUri("/profile/" & user.name), href=makeUri("/profile/" & user.name),
onClick=anchorCB): 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")

View file

@ -4,19 +4,21 @@
SELECT SELECT
thread_id, thread_id,
snippet(thread_fts, '<b>', '</b>', '<b>...</b>') AS thread, snippet(thread_fts, '<b>', '</b>', '<b>...</b>') AS thread,
0 AS post_id, post_id,
'' AS header, post_content,
'' AS content,
person.name AS author,
cdate, cdate,
author_id, person.name AS author,
person.email AS email, person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,
person.status AS status,
person.isDeleted as person_isDeleted,
0 AS what 0 AS what
FROM ( FROM (
SELECT SELECT
thread_fts.id AS thread_id, thread_fts.id AS thread_id,
post.id AS post_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, MIN(post.creation) AS cdate,
post.author AS author_id post.author AS author_id
FROM thread_fts FROM thread_fts
@ -28,7 +30,7 @@ SELECT
FROM post_fts JOIN post USING(id) FROM post_fts JOIN post USING(id)
WHERE post_fts MATCH ? WHERE post_fts MATCH ?
) )
LIMIT ? OFFSET (? - 1) * ? LIMIT ? OFFSET ?
) )
JOIN thread_fts ON thread_fts.id=thread_id JOIN thread_fts ON thread_fts.id=thread_id
JOIN person ON person.id=author_id JOIN person ON person.id=author_id
@ -40,30 +42,23 @@ SELECT
thread.name AS thread, thread.name AS thread,
post.id AS post_id, post.id AS post_id,
CASE what WHEN 1 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) THEN snippet(post_fts, '**', '**', '...', what, -45)
ELSE SUBSTR(post_fts.content, 1, 200) END AS content, ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
person.name AS author,
cdate, cdate,
post.author AS author_id, person.name AS author,
person.email AS email, person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,
person.status AS status,
person.isDeleted as person_isDeleted,
what what
FROM post_fts JOIN ( FROM post_fts JOIN (
-- inner query, selects ids of matching posts, orders and limits them, -- inner query, selects ids of matching posts, orders and limits them,
-- so snippets only for limited count of posts are created (in outer query) -- 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 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.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
FROM post_fts JOIN post USING(id) FROM post_fts JOIN post USING(id)
WHERE post_fts.content MATCH ? WHERE post_fts.content MATCH ?
ORDER BY what, cdate DESC ORDER BY what, cdate DESC
LIMIT ? OFFSET (? - 1) * ? LIMIT ? OFFSET ?
) AS post USING(id) ) AS post USING(id)
JOIN thread ON thread.id=thread JOIN thread ON thread.id=thread
JOIN person ON person.id=author JOIN person ON person.id=author