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 {
@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;

View file

@ -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

View file

@ -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()
),

View file

@ -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:

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.} =
## 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)

View file

@ -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
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",
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")

View file

@ -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