add features, introduce bugs

Backlinks are tracked.
The client has a nice editor UI (somewhat) now and also drafts
File upload has been vaguely started on
This commit is contained in:
osmarks 2021-02-17 23:37:42 +00:00
parent 19297cb6c6
commit 0331628a1e
12 changed files with 365 additions and 89 deletions

10
package-lock.json generated
View File

@ -39,6 +39,11 @@
"readdirp": "~3.5.0"
}
},
"date-fns": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.17.0.tgz",
"integrity": "sha512-ZEhqxUtEZeGgg9eHNSOAJ8O9xqSgiJdrL0lzSSfMF54x6KXWJiOH/xntSJ9YomJPrYH/p08t6gWjGWq1SDJlSA=="
},
"esbuild": {
"version": "0.8.39",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.39.tgz",
@ -66,6 +71,11 @@
"is-glob": "^4.0.1"
}
},
"idb": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/idb/-/idb-6.0.0.tgz",
"integrity": "sha512-+M367poGtpzAylX4pwcrZIa7cFQLfNkAOlMMLN2kw/2jGfJP6h+TB/unQNSVYwNtP8XqkLYrfuiVnxLQNP1tjA=="
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",

View File

@ -1,10 +1,17 @@
import m from "mithril"
import { openDB } from "idb"
import { lightFormat } from "date-fns"
const searchButton = document.querySelector("nav .search")
const mountpoint = document.createElement("div")
document.querySelector("main").insertBefore(mountpoint, document.querySelector(".header"))
const dbPromise = openDB("minoteaur", 1, {
upgrade: (db, oldVersion) => {
if (oldVersion < 1) { db.createObjectStore("drafts") }
},
blocking: () => { window.location.reload() }
});
// debugging thing
dbPromise.then(x => { window.idb = x })
const state = {
const searchState = {
showingSearchDialog: false,
searchResults: [],
searchError: null,
@ -23,35 +30,36 @@ const urlForPage = (page, subpage) => {
}
const handleHTTPError = e => {
if (e.code === 0) { return }
let x = `Server error ${e.code}`
if (e.message) { x += " " + e.message }
alert(x)
window.lastError = e
console.warn(e)
let x = `HTTP error ${e.code}`
if (e.message !== null) { x += " " + e.message }
searchState.searchError = x
}
const onsearch = ev => {
const query = ev.target.value
state.searchQuery = query
searchState.searchQuery = query
m.request({
url: "/api/search",
params: { q: query }
}).then(x => {
if (typeof x === "string") { // SQLite syntax error
console.log("ERR", x)
state.searchError = x
if (typeof x === "string") { // error from server
console.warn(x)
searchState.searchError = x
} else {
state.searchResults = x
state.searchError = null
searchState.searchResults = x
searchState.searchError = null
}
}, e => handleHTTPError)
}, handleHTTPError)
}
const currentPage = slugToPage(decodeURIComponent(/^\/([^/]+)/.exec(location.pathname)[1]).replace(/\+/g, " "))
const searchKeyHandler = ev => {
if (ev.keyCode === 13) { // enter key
if (ev.code === "Enter") { // enter key
// not very useful to just navigate to the same page
const otherResults = state.searchResults.filter(r => r.page !== currentPage)
const otherResults = searchState.searchResults.filter(r => r.page !== currentPage)
if (otherResults[0]) { location.href = urlForPage(otherResults[0].page) }
}
}
@ -59,27 +67,34 @@ const searchKeyHandler = ev => {
const SearchDialog = {
view: () => m(".dialog.search", [
m("h1", "Search"),
m("input[type=search]", { placeholder: "Query", oninput: onsearch, onkeydown: searchKeyHandler, value: state.searchQuery, oncreate: ({ dom }) => dom.focus() }),
state.searchError && m(".error", state.searchError),
m("ul", state.searchResults.map(x => m("li", [
m("input[type=search]", { placeholder: "Query", oninput: onsearch, onkeydown: searchKeyHandler, value: searchState.searchQuery, oncreate: ({ dom }) => dom.focus() }),
searchState.searchError && m(".error", searchState.searchError),
m("ul", searchState.searchResults.map(x => m("li", [
m(".flex-space", [ m("a.wikilink", { href: urlForPage(x.page) }, x.page), m("", x.rank.toFixed(3)) ]),
m("", x.snippet.map(s => s[0] ? m("span.highlight", s[1]) : s[1]))
])))
])
}
const App = {
view: () => m("", state.showingSearchDialog ? m(SearchDialog) : null)
const SearchApp = {
view: () => m("", searchState.showingSearchDialog ? m(SearchDialog) : null)
}
const mountpoint = document.createElement("div")
document.querySelector("main").insertBefore(mountpoint, document.querySelector(".header"))
m.mount(mountpoint, SearchApp)
const searchButton = document.querySelector("nav .search")
searchButton.addEventListener("click", e => {
state.showingSearchDialog = !state.showingSearchDialog
searchState.showingSearchDialog = !searchState.showingSearchDialog
e.preventDefault()
m.redraw()
})
// basic keyboard shortcuts - search, switch to view/edit/revs
document.body.addEventListener("keydown", e => {
if (e.target === document.body) { // maybe use alt instead? or right shift or something - this just detects unfocused keypresses
if (e.target === document.body) { // detect only unfocused keypresses - ctrl+key seems to cause issues when copy/pasting
if (e.key === "e") {
location.pathname = urlForPage(currentPage, "edit")
} else if (e.key === "v") {
@ -87,11 +102,197 @@ document.body.addEventListener("keydown", e => {
} else if (e.key === "r") {
location.pathname = urlForPage(currentPage, "revisions")
} else if (e.key === "/") {
state.showingSearchDialog = !state.showingSearchDialog
searchState.showingSearchDialog = !searchState.showingSearchDialog
e.preventDefault()
m.redraw()
}
}
})
m.mount(mountpoint, App)
const debounce = (fn, timeout = 250) => {
let timer;
return () => {
clearTimeout(timer)
timer = setTimeout(fn, timeout)
}
}
const dispDateTime = dt => lightFormat(dt, "yyyy-MM-dd HH:mm:ss")
const wordCount = s => {
let words = 0
for (const possibleWord of s.split(/\s+/)) {
if (/[^#*+>|`-]/.test(possibleWord)) { words += 1 }
}
return words
}
const lineCount = s => s.split("\n").length
const editor = document.querySelector(".edit-form textarea")
if (editor) {
const editorUIState = {
keypresses: 0,
draftSelected: false
}
const mountpoint = document.createElement("div")
document.querySelector(".sidebar").appendChild(mountpoint)
// automatic resize of textareas upon typing
// this is slightly "efficient" in that it avoids constantly setting the height to 0 and back in a few situations, which is seemingly quite expensive
let lengthWas = Infinity
const resize = () => {
const scrolltop = document.body.scrollTop
const targetHeight = editor.scrollHeight + 2
if (targetHeight != editor.style.height.slice(0, -2) || lengthWas > editor.value.length) {
editor.style.height = 0
editor.style.height = editor.scrollHeight + 2
document.body.scrollTop = scrolltop
}
lengthWas = editor.value.length
}
// retrieve last edit timestamp from field
const lastEditTime = parseInt(document.querySelector("input[name=last-edit]").value)
const serverValue = editor.value
// load in the initially loaded draft
const swapInDraft = () => {
if (!editorUIState.initialDraft) { return }
editorUIState.draftSelected = true
editor.value = editorUIState.initialDraft.text
resize()
}
// load in the initial page from the server
const swapInServer = () => {
console.log("server value swapped in, allegedly?")
editorUIState.draftSelected = false
console.log(editor.value, serverValue)
editor.value = serverValue
resize()
}
dbPromise.then(db => db.get("drafts", currentPage)).then(draft => {
editorUIState.initialDraft = draft
console.log("loaded memetic/beemetic entity ", draft)
// if the draft is newer than the server page, load it in (the user can override this)
if (draft.ts > lastEditTime) {
swapInDraft()
}
m.redraw()
})
const DraftInfo = {
view: () => editorUIState.initialDraft == null ? "No draft" : [
m(editorUIState.draftSelected ? ".selected" : "", { onclick: swapInDraft }, `Draft from ${dispDateTime(editorUIState.initialDraft.ts)}`),
lastEditTime > 0 && m(editorUIState.draftSelected ? "" : ".selected", { onclick: swapInServer }, `Page from ${dispDateTime(lastEditTime)}`)
]
}
const EditorUIApp = {
view: () => [
m("", `${editorUIState.chars} chars`),
m("", `${editorUIState.words} words`),
m("", `${editorUIState.lines} lines`),
m("", `${editorUIState.keypresses} keypresses`),
m(DraftInfo)
]
}
const updateCounts = text => {
editorUIState.words = wordCount(text)
editorUIState.lines = lineCount(text)
editorUIState.chars = text.length // incorrect for some unicode, but doing it correctly would be more complex and slow
}
updateCounts(editor.value)
m.mount(mountpoint, EditorUIApp)
editor.addEventListener("keypress", ev => {
const selStart = editor.selectionStart
const selEnd = editor.selectionEnd
if (selStart !== selEnd) return // text is actually selected; these shortcuts are not meant for that situation
const search = "\n" + editor.value.substr(0, selStart)
const lastLineStart = search.lastIndexOf("\n") + 1 // drop the \n
const nextLineStart = selStart + (editor.value.substr(selStart) + "\n").indexOf("\n")
if (ev.code === "Enter") { // enter
// save on ctrl+enter
if (ev.ctrlKey) {
editor.parentElement.submit()
return
}
const line = search.substr(lastLineStart)
// detect lists on the previous line to continue on the next one
const match = /^(\s*)(([*+-])|(\d+)([).]))(\s*)/.exec(line)
if (match) {
// if it is an unordered list, just take the bullet type + associated whitespace
// if it is an ordered list, increment the number and take the dot/paren and whitespace
const lineStart = match[1] + (match[4] ? (parseInt(match[4]) + 1).toString() + match[5] : match[2]) + match[6]
// get everything after the cursor on the same line
const contentAfterCursor = editor.value.slice(selStart, nextLineStart)
// all the content of the textbox preceding where the cursor should now be
const prev = editor.value.substr(0, selStart) + "\n" + lineStart
// update editor
editor.value = prev + contentAfterCursor + editor.value.substr(nextLineStart)
editor.selectionStart = editor.selectionEnd = prev.length
resize()
ev.preventDefault()
}
}
})
editor.addEventListener("keydown", ev => {
const selStart = editor.selectionStart
const selEnd = editor.selectionEnd
if (selStart !== selEnd) return
const search = "\n" + editor.value.substr(0, selStart)
// this is missing the + 1 that the enter key listener has. I forgot why. Good luck working out this!
const lastLineStart = search.lastIndexOf("\n")
const nextLineStart = selStart + (editor.value.substr(selStart) + "\n").indexOf("\n")
if (ev.code === "Backspace") {
// detect if backspacing the start of a list line
const re = /^\s*([*+-]|\d+[).])\s*$/y
if (re.test(editor.value.slice(lastLineStart, selStart))) {
// if so, remove entire list line start at once
const before = editor.value.substr(0, lastLineStart)
const after = editor.value.substr(selStart)
editor.value = before + after
editor.selectionStart = editor.selectionEnd = before.length
resize()
ev.preventDefault()
}
} else if (ev.code === "Tab") {
// indent/dedent lists by 2 spaces, depending on shift key
const match = /^(\s*)([*+-]|\d+[).])/.exec(editor.value.slice(lastLineStart, nextLineStart))
let line = editor.value.substr(lastLineStart)
if (ev.shiftKey) {
line = line.replace(/^ /, "")
} else {
line = " " + line
}
if (match) {
editor.value = editor.value.substr(0, lastLineStart) + line
editor.selectionStart = editor.selectionEnd = selStart + (ev.shiftKey ? -2 : 2)
resize()
ev.preventDefault()
}
}
editorUIState.keypresses++
m.redraw()
})
const saveDraft = debounce(() => {
dbPromise.then(idb => idb.put("drafts", { text: editor.value, ts: Date.now() }, currentPage))
console.log("saved")
})
editor.addEventListener("input", () => {
resize()
updateCounts(editor.value)
saveDraft()
})
resize()
}

View File

@ -26,6 +26,8 @@ let migrations = @[
(currently, the entire page content, zstd-compressed)
rowids (INTEGER PRIMARY KEY) are explicitly extant here due to FTS external content requiring them to be stable to work but are not to be used much.
Links' toPage is not a foreign key as it's valid for the page to not exist.
]#
"""
CREATE TABLE pages (
@ -53,10 +55,23 @@ CREATE VIRTUAL TABLE pages_fts USING fts5 (
"""
CREATE TABLE links (
uid INTEGER PRIMARY KEY,
from TEXT NOT NULL,
to TEXT NOT NULL,
fromPage TEXT NOT NULL REFERENCES pages(page),
toPage TEXT NOT NULL,
linkText TEXT NOT NULL,
context TEXT NOT NULL
context TEXT NOT NULL,
UNIQUE (fromPage, toPage)
);
""",
"""
CREATE TABLE files (
uid INTEGER PRIMARY KEY,
page TEXT NOT NULL REFERENCES pages(page),
filename TEXT NOT NULL,
storagePath TEXT NOT NULL,
mimeType TEXT NOT NULL,
metadata TEXT NOT NULL,
uploadedTime INTEGER NOT NULL,
UNIQUE (page, filename)
);
"""
]
@ -80,6 +95,12 @@ type
page*: string
rank*: float
snippet*: seq[(bool, string)]
Page* = object
page*, content*: string
created*, updated*: Time
uid*: int64
Backlink* = object
fromPage*, text*, context*: string
var logger = newConsoleLogger()
@ -93,12 +114,6 @@ proc migrate*(db: DbConn) =
db.exec("PRAGMA user_version = " & $mid)
logger.log(lvlDebug, "DB ready")
type
Page = object
page*, content*: string
created*, updated*: Time
uid*: int64
proc parse*(s: string, T: typedesc): T = fromJson(result, parseJSON(s), Joptions(allowExtraKeys: true, allowMissingKeys: true))
proc processFullRevisionRow(row: ResultRow): (RevisionMeta, string) =
@ -128,18 +143,23 @@ proc fetchPage*(db: DbConn, page: string, revision: Time): Option[Page] =
)
)
proc backlinks*(db: DbConn, page: string): seq[Backlink] =
db.all("SELECT fromPage, linkText, context FROM links WHERE toPage = ?", page).map(proc(row: ResultRow): Backlink =
let (fromPage, text, context) = row.unpack((string, string, string))
Backlink(fromPage: fromPage, text: text, context: context))
# count words, defined as things separated by whitespace which are not purely Markdown-ish punctuation characters
# alternative definitions may include dropping number-only words, and/or splitting at full stops too
func wordCount(s: string): int =
for word in splitWhitespace(s):
if len(word) == 0: continue
for bytechar in word:
if not (bytechar in {'#', '*', '-', '>', '`', '|', '-'}):
if not (bytechar in {'#', '*', '-', '>', '`', '|', '+', '[', ']'}):
inc result
break
proc updatePage*(db: DbConn, page: string, content: string) =
echo parsePage(content)
let parsed = parsePage(content)
let previous = fetchPage(db, page)
# if there is no previous content, empty string instead
let previousContent = previous.map(p => p.content).get("")
@ -166,11 +186,16 @@ proc updatePage*(db: DbConn, page: string, content: string) =
db.exec("UPDATE pages SET content = ?, updated = ? WHERE uid = ?", content, ts, pageID)
# pages_fts is an external content FTS table, so deletion has to be done like this
db.exec("INSERT INTO pages_fts (pages_fts, rowid, page, content) VALUES ('delete', ?, ?, ?)", pageID, page, previousContent)
# delete existing links from the page
db.exec("DELETE FROM links WHERE fromPage = ?", page)
else:
db.exec("INSERT INTO pages VALUES (?, ?, ?, ?, ?)", pageID, page, ts, ts, content)
# push to full text search index
# push to full text search index - TODO perhaps use the parsed text content (as used for context) instead of the raw markdown
db.exec("INSERT INTO pages_fts (rowid, page, content) VALUES (?, ?, ?)", pageID, page, content)
db.exec("INSERT INTO revisions VALUES (?, ?, ?, ?, ?)", revisionID, page, ts, meta, data)
# insert new set of links
for link in parsed.links:
db.exec("INSERT INTO links VALUES (?, ?, ?, ?, ?)", snowflake(), page, link.target, link.text, link.context)
proc fetchRevisions*(db: DbConn, page: string): seq[Revision] =
db.all("SELECT timestamp, meta FROM revisions WHERE page = ? ORDER BY timestamp DESC", page).map(proc (row: ResultRow): Revision =

View File

@ -3,7 +3,7 @@ import cmark/native as cmark except Node, Parser
# the builtin re library would probably be better for this - it can directly take cstrings (so better perf when dealing with the cstrings from cmark) and may be faster
# unfortunately it does not expose a findAll thing which returns the *positions* of everything for some weird reason
import regex
from strutils import join, find
from strutils import join, find, startsWith, endsWith
import unicode
import sets
@ -148,19 +148,19 @@ proc findParagraphParent(node: BorrowedNode): BorrowedNode =
type
Link* = object
page*, text*, context*: string
target*, text*, context*: string
ParsedPage* = object
links*: seq[Link]
fullText: string
#fullText*: string
# Generates context for a link given the surrounding string and its position in it
# Takes a given quantity of space-separated words from both sides
# If not enough exist on one side, takes more from the other
# TODO: treat a wikilink as one token
proc linkContext(str: string, startPos: int, endPos: int, lookaround: int): string =
var earlierToks = splitWhitespace(str[0..<startPos])
var earlierToks = if startPos > 0: splitWhitespace(str[0..<startPos]) else: @[]
var linkText = str[startPos..endPos]
var laterToks = splitWhitespace(str[endPos + 1..^1])
var laterToks = if endPos < str.len: splitWhitespace(str[endPos + 1..^1]) else: @[]
let bdlook = lookaround * 2
result =
# both are longer than necessary so take tokens symmetrically
@ -168,13 +168,17 @@ proc linkContext(str: string, startPos: int, endPos: int, lookaround: int): stri
earlierToks[^lookaround..^1].join(" ") & linkText & laterToks[0..<lookaround].join(" ")
# later is shorter than wanted, take more from earlier
elif earlierToks.len >= lookaround and laterToks.len < lookaround:
earlierToks[^(bdlook - laterToks.len)..^1].join(" ") & linkText & laterToks.join(" ")
earlierToks[max(earlierToks.len - bdlook + laterToks.len, 0)..^1].join(" ") & linkText & laterToks.join(" ")
# mirrored version of previous case
elif earlierToks.len < lookaround and laterToks.len >= lookaround:
earlierToks.join(" ") & linkText & laterToks[0..<(bdlook - earlierToks.len)].join(" ")
# both too short, use all of both
else: earlierToks.join(" ") & linkText & laterToks.join(" ")
# TODO: optimize
if not result.startsWith(earlierToks.join(" ")): result = "... " & result
if not result.endsWith(laterToks.join(" ")): result = result & " ..."
proc parsePage*(input: string): ParsedPage =
let wlRegex = wlRegex()
let opt = CMARK_OPT_UNSAFE or CMARK_OPT_FOOTNOTES or CMARK_OPT_STRIKETHROUGH_DOUBLE_TILDE or CMARK_OPT_TABLE_PREFER_STYLE_ATTRIBUTES
@ -193,7 +197,6 @@ proc parsePage*(input: string): ParsedPage =
let paragraph = textContent(findParagraphParent(node))
var matchEnd = 0
for match in matches:
echo $match
let page = ntext[match.captures[0][0]]
let linkText =
if len(match.captures[1]) > 0: ntext[match.captures[1][0]]
@ -205,11 +208,11 @@ proc parsePage*(input: string): ParsedPage =
# kind of hacky but should work in any scenario which isn't deliberately constructed pathologically, especially since it will only return stuff after the last link
let fullLink = ntext[match.boundaries]
let matchInParagraph = find(paragraph, fullLink, matchEnd)
matchEnd = matchInParagraph + fullLink.len
matchEnd = matchInParagraph + fullLink.len - 1
let context = linkContext(paragraph, matchInParagraph, matchEnd, 12)
# add to wikilinks list, and deduplicate
wikilinks.add(Link(page: canonicalPage, text: linkText, context: context))
wikilinks.add(Link(target: canonicalPage, text: linkText, context: context))
seenPages.incl(canonicalPage)
ParsedPage(links: wikilinks, fullText: textContent(borrow(doc)))
ParsedPage(links: wikilinks) #fullText: textContent(borrow(doc)))

View File

@ -9,7 +9,6 @@ import times
import sugar
import std/jsonutils
import strutils
import prologue/middlewares/csrf
from ./domain import nil
from ./md import nil
@ -26,7 +25,8 @@ let
func navButton(content: string, href: string, class: string): VNode = buildHtml(a(class="link-button " & class, href=href)): text content
func base(title: string, navItems: seq[VNode], bodyItems: VNode): string =
func base(title: string, navItems: seq[VNode], bodyItems: VNode, sidebar: Option[VNode] = none(VNode)): string =
let sidebarClass = if sidebar.isSome: "has-sidebar" else: ""
let vnode = buildHtml(html):
head:
link(rel="stylesheet", href="/static/style.css")
@ -35,13 +35,14 @@ func base(title: string, navItems: seq[VNode], bodyItems: VNode): string =
meta(name="viewport", content="width=device-width,initial-scale=1.0")
title: text title
body:
main:
main(class=sidebarClass):
nav:
a(class="link-button search", href=""): text "Search"
for n in navItems: n
tdiv(class="header"):
h1: text title
bodyItems
if sidebar.isSome: tdiv(class="sidebar"): get sidebar
$vnode
block:
@ -82,18 +83,18 @@ proc displayTime(t: Time): string = t.format("uuuu-MM-dd HH:mm:ss", utc())
func pageUrlFor(ctx: AppContext, route: string, page: string, query: openArray[(string, string)] = @[]): string = ctx.urlFor(route, { "page": encodeUrl(pageToSlug(page)) }, query)
func pageButton(ctx: AppContext, route: string, page: string, label: string, query: openArray[(string, string)] = @[]): VNode = navButton(label, pageUrlFor(ctx, route, page, query), route)
proc formCsrfToken(ctx: AppContext): VNode = verbatim csrfToken(ctx)
proc edit(ctx: AppContext) {.async.} =
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
let pageData = domain.fetchPage(ctx.db, page)
let html =
buildHtml(form(`method`="post", class="edit-form")):
input(`type`="submit", value="Save", name="action", class="save")
let html =
# autocomplete=off disables some sort of session history caching mechanism which interferes with draft handling
buildHtml(form(`method`="post", class="edit-form", id="edit-form", autocomplete="off")):
textarea(name="content"): text pageData.map(p => p.content).get("")
formCsrfToken(ctx)
input(`type`="hidden", value=pageData.map(p => timestampToStr(p.updated)).get("0"), name="last-edit")
let sidebar = buildHtml(tdiv):
input(`type`="submit", value="Save", name="action", class="save", form="edit-form")
let verb = if pageData.isSome: "Editing " else: "Creating "
resp base(verb & page, @[pageButton(ctx, "view-page", page, "View"), pageButton(ctx, "page-revisions", page, "Revisions")], html)
resp base(verb & page, @[pageButton(ctx, "view-page", page, "View"), pageButton(ctx, "page-revisions", page, "Revisions")], html, some(sidebar))
proc revisions(ctx: AppContext) {.async.} =
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
@ -120,6 +121,11 @@ proc handleEdit(ctx: AppContext) {.async.} =
domain.updatePage(ctx.db, page, ctx.getFormParams("content"))
resp redirect(pageUrlFor(ctx, "view-page", page), Http303)
proc sendAttachedFile(ctx: AppContext) {.async.} =
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
echo "orbital bee strike → you"
resp "TODO"
proc view(ctx: AppContext) {.async.} =
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
let rawRevision = ctx.getQueryParams("ts")
@ -134,6 +140,8 @@ proc view(ctx: AppContext) {.async.} =
let pageData = get pageData
let mainBody = if viewSource: buildHtml(pre): text pageData.content else: verbatim md.renderToHtml(pageData.content)
if revisionTs.isNone:
# current revision
let backlinks = domain.backlinks(ctx.db, page)
let html =
buildHtml(tdiv):
tdiv(class="timestamp"):
@ -143,8 +151,18 @@ proc view(ctx: AppContext) {.async.} =
text "Created "
text displayTime(pageData.created)
tdiv(class="md"): mainBody
if backlinks.len > 0:
h2: text "Backlinks"
ul(class="backlinks"):
for backlink in backlinks:
li:
tdiv: a(class="wikilink", href=pageUrlFor(ctx, "view-page", backlink.fromPage)): text backlink.fromPage
tdiv: text backlink.context
resp base(page, @[pageButton(ctx, "edit-page", page, "Edit"), pageButton(ctx, "page-revisions", page, "Revisions")], html)
else:
# old revision
let rts = get revisionTs
let (next, prev) = domain.adjacentRevisions(ctx.db, page, rts)
let html =
@ -156,6 +174,7 @@ proc view(ctx: AppContext) {.async.} =
var buttons = @[pageButton(ctx, "edit-page", page, "Edit"), pageButton(ctx, "page-revisions", page, "Revisions"), pageButton(ctx, "view-page", page, "Latest")]
if next.isSome: buttons.add(pageButton(ctx, "next-page", page, "Next", { "ts": timestampToStr (get next).time }))
if prev.isSome: buttons.add(pageButton(ctx, "prev-page", page, "Previous", { "ts": timestampToStr (get prev).time }))
resp base(page, buttons, html)
proc search(ctx: AppContext) {.async.} =
@ -172,12 +191,13 @@ proc favicon(ctx: Context) {.async.} = resp error404()
proc index(ctx: Context) {.async.} = resp "bee(s)"
var app = newApp(settings = settings)
app.use(@[staticFileMiddleware("static"), sessionMiddleware(settings), extendContextMiddleware(AppContext), dbMiddleware(), headersMiddleware(), csrfMiddleware()])
app.use(@[staticFileMiddleware("static"), sessionMiddleware(settings), extendContextMiddleware(AppContext), dbMiddleware(), headersMiddleware()])
app.get("/", index)
app.get("/favicon.ico", favicon)
app.get("/api/search", search, name="search")
app.get("/{page}/edit", edit, name="edit-page")
app.get("/{page}/revisions", revisions, name="page-revisions")
app.post("/{page}/edit", handleEdit, name="handle-edit")
app.get("/{page}/file/{filename}", sendAttachedFile, name="send-attached-file")
app.get("/{page}/", view, name="view-page")
app.run()

View File

@ -7,15 +7,25 @@ html
body
font-family: "Fira Sans", "Noto Sans", "Segoe UI", Verdana, sans-serif
font-weight: 300
margin: 0
min-height: 100vh
margin: 0 0 1em 0
//min-height: 100vh
display: flex
flex-wrap: wrap
justify-content: space-around
main
max-width: 50em
padding: 0 1em 1em 1em
width: 100%
padding: 0 1em 0 1em
margin-left: auto
margin-right: auto
position: relative
&.has-sidebar
margin-right: 0
.sidebar
padding: 1em 1em 1em 1em
margin-right: auto
min-width: 16em
strong
font-weight: 600
@ -67,9 +77,18 @@ table
nav
margin-bottom: 0.5em
.error
color: red
img
max-width: 100%
.timestamp
color: gray
.selected
font-style: italic
a.wikilink
text-decoration: none
color: #0165fc
@ -116,6 +135,7 @@ input[type=search], input[type=text]
width: 100%
height: 70vh
border: 1px solid gray
margin-bottom: 0
.highlight
background-color: yellow
@ -128,10 +148,4 @@ input[type=search], input[type=text]
.flex-space
display: flex
justify-content: space-between
.error
color: red
img
max-width: 100%
justify-content: space-between

View File

@ -27,16 +27,6 @@ func timestampToStr*(t: Time): string = intToStr(int(timeToTimestamp(t)))
proc toDbValue*(t: Time): DbValue = DbValue(kind: sqliteInteger, intVal: timeToTimestamp(t))
proc fromDbValue*(value: DbValue, T: typedesc[Time]): Time = timestampToTime(value.intVal)
# count words, defined as things separated by whitespace which are not purely punctuation characters
# alternative definitions may include dropping number-only words, and/or splitting at full stops too
func wordCount(s: string): int =
for word in splitWhitespace(s):
if len(word) == 0: continue
for bytechar in word:
if not (bytechar in {'[', ']', ':', '.', '!'}):
inc result
break
template autoInitializedThreadvar*(name: untyped, typ: typedesc, initialize: typed): untyped =
var data* {.threadvar.}: Option[typ]
proc `name`(): typ =
@ -52,10 +42,13 @@ const SIMPLEFLAKE_EPOCH = 946702800
const SIMPLEFLAKE_RANDOM_LENGTH = 21
let now = times.getTime()
autoInitializedThreadvar(threadRNG, Rand, random.initRand(now.toUnix * 1_000_000_000 + now.nanosecond))
var rng {.threadvar.}: Rand
var rngInitialized {.threadvar.}: bool
proc snowflake*(): int64 =
var rng = threadRNG()
if not rngInitialized:
rng = random.initRand((now.toUnix * 1_000_000_000 + now.nanosecond) xor getThreadId())
rngInitialized = true
let now = times.getTime().toUnixFloat()
var ts = int64((now - SIMPLEFLAKE_EPOCH) * 1000)
let randomBits = int64(rng.rand(2 ^ SIMPLEFLAKE_RANDOM_LENGTH))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
html{scrollbar-color:#000 #d3d3d3}*{box-sizing:border-box}body{font-family:"Fira Sans","Noto Sans","Segoe UI",Verdana,sans-serif;font-weight:300;margin:0;min-height:100vh}main{max-width:50em;padding:0 1em 1em 1em;margin-left:auto;margin-right:auto;position:relative}strong{font-weight:600}h1,h2,h3,h4,h5,h6{margin:0 0 .5em 0;font-weight:500}h1:first-of-type,h2:first-of-type,h3:first-of-type,h4:first-of-type,h5:first-of-type,h6:first-of-type{border-bottom:1px solid gray}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{color:inherit}:not(pre)>code{background:#000;color:#fff;padding:.3em}ul,ol{padding-left:1em}ul{list-style-type:square}blockquote{border-left:.3em solid #000;padding-left:.3em}table{border-collapse:collapse}table th{background:#000;color:#fff;font-weight:normal}table td,table th{padding:.2em .5em}table td{border:1px solid gray}.rev-table{width:100%}.rev-table td{border:none}.rev-table td.ts{white-space:nowrap}.md{margin-top:.5em}.md>*,.md p{margin:0 0 .5em 0}nav{margin-bottom:.5em}.timestamp{color:gray}a.wikilink{text-decoration:none;color:#0165fc;font-style:italic}a.wikilink:hover{text-decoration:underline}.link-button,button,input[type=submit]{border:none;padding:.75em;background:gray;text-align:center;text-decoration:none;color:#000;display:inline-block;font-size:1rem}.link-button:hover,button:hover,input[type=submit]:hover{background-image:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2))}.link-button.view-page,button.view-page,input[type=submit].view-page{background-color:#76cd26}.link-button.edit-page,button.edit-page,input[type=submit].edit-page{background-color:#75bbfd}.link-button.page-revisions,button.page-revisions,input[type=submit].page-revisions{background-color:#f97306}.link-button.save,button.save,input[type=submit].save{background-color:#06c2ac}.link-button.next-page,button.next-page,input[type=submit].next-page{background-color:#5170d7}.link-button.prev-page,button.prev-page,input[type=submit].prev-page{background-color:#bc13fe}.link-button.search,button.search,input[type=submit].search{background-color:#fac205}input[type=search],input[type=text]{border:1px solid gray;padding:.75em;width:100%}.edit-form textarea{resize:vertical;width:100%;height:70vh;border:1px solid gray}.highlight{background-color:#ff0}.dialog{width:100%;background:#fff;padding:1em;border:1px solid gray}.flex-space{display:flex;justify-content:space-between}.error{color:red}img{max-width:100%}/*# sourceMappingURL=style.css.map */
html{scrollbar-color:#000 #d3d3d3}*{box-sizing:border-box}body{font-family:"Fira Sans","Noto Sans","Segoe UI",Verdana,sans-serif;font-weight:300;margin:0 0 1em 0;display:flex;flex-wrap:wrap;justify-content:space-around}main{max-width:50em;width:100%;padding:0 1em 0 1em;margin-left:auto;margin-right:auto}main.has-sidebar{margin-right:0}.sidebar{padding:1em 1em 1em 1em;margin-right:auto;min-width:16em}strong{font-weight:600}h1,h2,h3,h4,h5,h6{margin:0 0 .5em 0;font-weight:500}h1:first-of-type,h2:first-of-type,h3:first-of-type,h4:first-of-type,h5:first-of-type,h6:first-of-type{border-bottom:1px solid gray}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{color:inherit}:not(pre)>code{background:#000;color:#fff;padding:.3em}ul,ol{padding-left:1em}ul{list-style-type:square}blockquote{border-left:.3em solid #000;padding-left:.3em}table{border-collapse:collapse}table th{background:#000;color:#fff;font-weight:normal}table td,table th{padding:.2em .5em}table td{border:1px solid gray}.rev-table{width:100%}.rev-table td{border:none}.rev-table td.ts{white-space:nowrap}.md{margin-top:.5em}.md>*,.md p{margin:0 0 .5em 0}nav{margin-bottom:.5em}.error{color:red}img{max-width:100%}.timestamp{color:gray}.selected{font-style:italic}a.wikilink{text-decoration:none;color:#0165fc;font-style:italic}a.wikilink:hover{text-decoration:underline}.link-button,button,input[type=submit]{border:none;padding:.75em;background:gray;text-align:center;text-decoration:none;color:#000;display:inline-block;font-size:1rem}.link-button:hover,button:hover,input[type=submit]:hover{background-image:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2))}.link-button.view-page,button.view-page,input[type=submit].view-page{background-color:#76cd26}.link-button.edit-page,button.edit-page,input[type=submit].edit-page{background-color:#75bbfd}.link-button.page-revisions,button.page-revisions,input[type=submit].page-revisions{background-color:#f97306}.link-button.save,button.save,input[type=submit].save{background-color:#06c2ac}.link-button.next-page,button.next-page,input[type=submit].next-page{background-color:#5170d7}.link-button.prev-page,button.prev-page,input[type=submit].prev-page{background-color:#bc13fe}.link-button.search,button.search,input[type=submit].search{background-color:#fac205}input[type=search],input[type=text]{border:1px solid gray;padding:.75em;width:100%}.edit-form{margin-bottom:0}.edit-form textarea{resize:vertical;width:100%;height:70vh;border:1px solid gray}.highlight{background-color:#ff0}.dialog{width:100%;background:#fff;padding:1em;border:1px solid gray}.flex-space{display:flex;justify-content:space-between}/*# sourceMappingURL=style.css.map */

View File

@ -1 +1 @@
{"version":3,"sourceRoot":"","sources":["../src/style.sass"],"names":[],"mappings":"AAAA,KACI,6BAEJ,EACI,sBAEJ,KACI,kEACA,gBACA,SACA,iBAEJ,KACI,eACA,sBACA,iBACA,kBACA,kBAEJ,OACI,gBAEJ,kBAGI,kBACA,gBAHA,sGACI,6BAGJ,8BACI,cAER,eACI,gBACA,WACA,aAEJ,MACI,iBACJ,GACI,uBACJ,WACI,4BACA,kBACJ,MACI,yBAEA,SACI,gBACA,WACA,mBACJ,kBACI,kBACJ,SACI,sBAER,WACI,WACA,cACI,YACA,iBACI,mBAEZ,IACI,gBACA,YACI,kBAER,IACI,mBAEJ,WACI,WAEJ,WACI,qBACA,cACA,kBACA,iBACI,0BAER,uCACI,YACA,cACA,gBACA,kBACA,qBACA,WACA,qBACA,eACA,yDAEI,yEAEJ,qEACI,yBACJ,qEACI,yBACJ,oFACI,yBACJ,sDACI,yBACJ,qEACI,yBACJ,qEACI,yBACJ,4DACI,yBAER,oCACI,sBACA,cACA,WAGA,oBACI,gBACA,WACA,YACA,sBAER,WACI,sBAEJ,QACI,WACA,gBACA,YACA,sBAEJ,YACI,aACA,8BAEJ,OACI,UAEJ,IACI","file":"style.css"}
{"version":3,"sourceRoot":"","sources":["../src/style.sass"],"names":[],"mappings":"AAAA,KACI,6BAEJ,EACI,sBAEJ,KACI,kEACA,gBACA,iBAEA,aACA,eACA,6BAEJ,KACI,eACA,WACA,oBACA,iBACA,kBACA,iBACI,eAER,SACI,wBACA,kBACA,eAEJ,OACI,gBAEJ,kBAGI,kBACA,gBAHA,sGACI,6BAGJ,8BACI,cAER,eACI,gBACA,WACA,aAEJ,MACI,iBACJ,GACI,uBACJ,WACI,4BACA,kBACJ,MACI,yBAEA,SACI,gBACA,WACA,mBACJ,kBACI,kBACJ,SACI,sBAER,WACI,WACA,cACI,YACA,iBACI,mBAEZ,IACI,gBACA,YACI,kBAER,IACI,mBAEJ,OACI,UAEJ,IACI,eAEJ,WACI,WAEJ,UACI,kBAEJ,WACI,qBACA,cACA,kBACA,iBACI,0BAER,uCACI,YACA,cACA,gBACA,kBACA,qBACA,WACA,qBACA,eACA,yDAEI,yEAEJ,qEACI,yBACJ,qEACI,yBACJ,oFACI,yBACJ,sDACI,yBACJ,qEACI,yBACJ,qEACI,yBACJ,4DACI,yBAER,oCACI,sBACA,cACA,WAEJ,WAMI,gBALA,oBACI,gBACA,WACA,YACA,sBAGR,WACI,sBAEJ,QACI,WACA,gBACA,YACA,sBAEJ,YACI,aACA","file":"style.css"}

View File

@ -1,2 +1,2 @@
#!/bin/sh
npx -p sass sass --watch -s compressed src/style.sass:static/style.css & npx esbuild --bundle src/client.js --outfile=static/client.js --sourcemap --minify --watch
npx sass --watch -s compressed src/style.sass:static/style.css & npx esbuild --bundle src/client.js --outfile=static/client.js --sourcemap --minify --watch