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:
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" "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": { "esbuild": {
"version": "0.8.39", "version": "0.8.39",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.39.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.39.tgz",
@@ -66,6 +71,11 @@
"is-glob": "^4.0.1" "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": { "is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "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 m from "mithril"
import { openDB } from "idb"
import { lightFormat } from "date-fns"
const searchButton = document.querySelector("nav .search") const dbPromise = openDB("minoteaur", 1, {
const mountpoint = document.createElement("div") upgrade: (db, oldVersion) => {
document.querySelector("main").insertBefore(mountpoint, document.querySelector(".header")) if (oldVersion < 1) { db.createObjectStore("drafts") }
},
blocking: () => { window.location.reload() }
});
// debugging thing
dbPromise.then(x => { window.idb = x })
const state = { const searchState = {
showingSearchDialog: false, showingSearchDialog: false,
searchResults: [], searchResults: [],
searchError: null, searchError: null,
@@ -23,35 +30,36 @@ const urlForPage = (page, subpage) => {
} }
const handleHTTPError = e => { const handleHTTPError = e => {
if (e.code === 0) { return } window.lastError = e
let x = `Server error ${e.code}` console.warn(e)
if (e.message) { x += " " + e.message } let x = `HTTP error ${e.code}`
alert(x) if (e.message !== null) { x += " " + e.message }
searchState.searchError = x
} }
const onsearch = ev => { const onsearch = ev => {
const query = ev.target.value const query = ev.target.value
state.searchQuery = query searchState.searchQuery = query
m.request({ m.request({
url: "/api/search", url: "/api/search",
params: { q: query } params: { q: query }
}).then(x => { }).then(x => {
if (typeof x === "string") { // SQLite syntax error if (typeof x === "string") { // error from server
console.log("ERR", x) console.warn(x)
state.searchError = x searchState.searchError = x
} else { } else {
state.searchResults = x searchState.searchResults = x
state.searchError = null searchState.searchError = null
} }
}, e => handleHTTPError) }, handleHTTPError)
} }
const currentPage = slugToPage(decodeURIComponent(/^\/([^/]+)/.exec(location.pathname)[1]).replace(/\+/g, " ")) const currentPage = slugToPage(decodeURIComponent(/^\/([^/]+)/.exec(location.pathname)[1]).replace(/\+/g, " "))
const searchKeyHandler = ev => { 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 // 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) } if (otherResults[0]) { location.href = urlForPage(otherResults[0].page) }
} }
} }
@@ -59,27 +67,34 @@ const searchKeyHandler = ev => {
const SearchDialog = { const SearchDialog = {
view: () => m(".dialog.search", [ view: () => m(".dialog.search", [
m("h1", "Search"), m("h1", "Search"),
m("input[type=search]", { placeholder: "Query", oninput: onsearch, onkeydown: searchKeyHandler, value: state.searchQuery, oncreate: ({ dom }) => dom.focus() }), m("input[type=search]", { placeholder: "Query", oninput: onsearch, onkeydown: searchKeyHandler, value: searchState.searchQuery, oncreate: ({ dom }) => dom.focus() }),
state.searchError && m(".error", state.searchError), searchState.searchError && m(".error", searchState.searchError),
m("ul", state.searchResults.map(x => m("li", [ 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(".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])) m("", x.snippet.map(s => s[0] ? m("span.highlight", s[1]) : s[1]))
]))) ])))
]) ])
} }
const App = { const SearchApp = {
view: () => m("", state.showingSearchDialog ? m(SearchDialog) : null) 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 => { searchButton.addEventListener("click", e => {
state.showingSearchDialog = !state.showingSearchDialog searchState.showingSearchDialog = !searchState.showingSearchDialog
e.preventDefault() e.preventDefault()
m.redraw() m.redraw()
}) })
// basic keyboard shortcuts - search, switch to view/edit/revs
document.body.addEventListener("keydown", e => { 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") { if (e.key === "e") {
location.pathname = urlForPage(currentPage, "edit") location.pathname = urlForPage(currentPage, "edit")
} else if (e.key === "v") { } else if (e.key === "v") {
@@ -87,11 +102,197 @@ document.body.addEventListener("keydown", e => {
} else if (e.key === "r") { } else if (e.key === "r") {
location.pathname = urlForPage(currentPage, "revisions") location.pathname = urlForPage(currentPage, "revisions")
} else if (e.key === "/") { } else if (e.key === "/") {
state.showingSearchDialog = !state.showingSearchDialog searchState.showingSearchDialog = !searchState.showingSearchDialog
e.preventDefault() e.preventDefault()
m.redraw() 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) (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. 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 ( CREATE TABLE pages (
@@ -53,10 +55,23 @@ CREATE VIRTUAL TABLE pages_fts USING fts5 (
""" """
CREATE TABLE links ( CREATE TABLE links (
uid INTEGER PRIMARY KEY, uid INTEGER PRIMARY KEY,
from TEXT NOT NULL, fromPage TEXT NOT NULL REFERENCES pages(page),
to TEXT NOT NULL, toPage TEXT NOT NULL,
linkText 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 page*: string
rank*: float rank*: float
snippet*: seq[(bool, string)] snippet*: seq[(bool, string)]
Page* = object
page*, content*: string
created*, updated*: Time
uid*: int64
Backlink* = object
fromPage*, text*, context*: string
var logger = newConsoleLogger() var logger = newConsoleLogger()
@@ -93,12 +114,6 @@ proc migrate*(db: DbConn) =
db.exec("PRAGMA user_version = " & $mid) db.exec("PRAGMA user_version = " & $mid)
logger.log(lvlDebug, "DB ready") 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 parse*(s: string, T: typedesc): T = fromJson(result, parseJSON(s), Joptions(allowExtraKeys: true, allowMissingKeys: true))
proc processFullRevisionRow(row: ResultRow): (RevisionMeta, string) = 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 # 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 # alternative definitions may include dropping number-only words, and/or splitting at full stops too
func wordCount(s: string): int = func wordCount(s: string): int =
for word in splitWhitespace(s): for word in splitWhitespace(s):
if len(word) == 0: continue if len(word) == 0: continue
for bytechar in word: for bytechar in word:
if not (bytechar in {'#', '*', '-', '>', '`', '|', '-'}): if not (bytechar in {'#', '*', '-', '>', '`', '|', '+', '[', ']'}):
inc result inc result
break break
proc updatePage*(db: DbConn, page: string, content: string) = proc updatePage*(db: DbConn, page: string, content: string) =
echo parsePage(content) let parsed = parsePage(content)
let previous = fetchPage(db, page) let previous = fetchPage(db, page)
# if there is no previous content, empty string instead # if there is no previous content, empty string instead
let previousContent = previous.map(p => p.content).get("") 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) 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 # 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) 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: else:
db.exec("INSERT INTO pages VALUES (?, ?, ?, ?, ?)", pageID, page, ts, ts, content) 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 pages_fts (rowid, page, content) VALUES (?, ?, ?)", pageID, page, content)
db.exec("INSERT INTO revisions VALUES (?, ?, ?, ?, ?)", revisionID, page, ts, meta, data) 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] = 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 = 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 # 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 # unfortunately it does not expose a findAll thing which returns the *positions* of everything for some weird reason
import regex import regex
from strutils import join, find from strutils import join, find, startsWith, endsWith
import unicode import unicode
import sets import sets
@@ -148,19 +148,19 @@ proc findParagraphParent(node: BorrowedNode): BorrowedNode =
type type
Link* = object Link* = object
page*, text*, context*: string target*, text*, context*: string
ParsedPage* = object ParsedPage* = object
links*: seq[Link] links*: seq[Link]
fullText: string #fullText*: string
# Generates context for a link given the surrounding string and its position in it # 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 # Takes a given quantity of space-separated words from both sides
# If not enough exist on one side, takes more from the other # If not enough exist on one side, takes more from the other
# TODO: treat a wikilink as one token # TODO: treat a wikilink as one token
proc linkContext(str: string, startPos: int, endPos: int, lookaround: int): string = 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 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 let bdlook = lookaround * 2
result = result =
# both are longer than necessary so take tokens symmetrically # 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(" ") earlierToks[^lookaround..^1].join(" ") & linkText & laterToks[0..<lookaround].join(" ")
# later is shorter than wanted, take more from earlier # later is shorter than wanted, take more from earlier
elif earlierToks.len >= lookaround and laterToks.len < lookaround: 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 # mirrored version of previous case
elif earlierToks.len < lookaround and laterToks.len >= lookaround: elif earlierToks.len < lookaround and laterToks.len >= lookaround:
earlierToks.join(" ") & linkText & laterToks[0..<(bdlook - earlierToks.len)].join(" ") earlierToks.join(" ") & linkText & laterToks[0..<(bdlook - earlierToks.len)].join(" ")
# both too short, use all of both # both too short, use all of both
else: earlierToks.join(" ") & linkText & laterToks.join(" ") 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 = proc parsePage*(input: string): ParsedPage =
let wlRegex = wlRegex() 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 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)) let paragraph = textContent(findParagraphParent(node))
var matchEnd = 0 var matchEnd = 0
for match in matches: for match in matches:
echo $match
let page = ntext[match.captures[0][0]] let page = ntext[match.captures[0][0]]
let linkText = let linkText =
if len(match.captures[1]) > 0: ntext[match.captures[1][0]] 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 # 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 fullLink = ntext[match.boundaries]
let matchInParagraph = find(paragraph, fullLink, matchEnd) let matchInParagraph = find(paragraph, fullLink, matchEnd)
matchEnd = matchInParagraph + fullLink.len matchEnd = matchInParagraph + fullLink.len - 1
let context = linkContext(paragraph, matchInParagraph, matchEnd, 12) let context = linkContext(paragraph, matchInParagraph, matchEnd, 12)
# add to wikilinks list, and deduplicate # 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) 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 sugar
import std/jsonutils import std/jsonutils
import strutils import strutils
import prologue/middlewares/csrf
from ./domain import nil from ./domain import nil
from ./md 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 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): let vnode = buildHtml(html):
head: head:
link(rel="stylesheet", href="/static/style.css") 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") meta(name="viewport", content="width=device-width,initial-scale=1.0")
title: text title title: text title
body: body:
main: main(class=sidebarClass):
nav: nav:
a(class="link-button search", href=""): text "Search" a(class="link-button search", href=""): text "Search"
for n in navItems: n for n in navItems: n
tdiv(class="header"): tdiv(class="header"):
h1: text title h1: text title
bodyItems bodyItems
if sidebar.isSome: tdiv(class="sidebar"): get sidebar
$vnode $vnode
block: 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 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) 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.} = proc edit(ctx: AppContext) {.async.} =
let page = slugToPage(decodeUrl(ctx.getPathParams("page"))) let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
let pageData = domain.fetchPage(ctx.db, page) let pageData = domain.fetchPage(ctx.db, page)
let html = let html =
buildHtml(form(`method`="post", class="edit-form")): # autocomplete=off disables some sort of session history caching mechanism which interferes with draft handling
input(`type`="submit", value="Save", name="action", class="save") buildHtml(form(`method`="post", class="edit-form", id="edit-form", autocomplete="off")):
textarea(name="content"): text pageData.map(p => p.content).get("") 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 " 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.} = proc revisions(ctx: AppContext) {.async.} =
let page = slugToPage(decodeUrl(ctx.getPathParams("page"))) let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
@@ -120,6 +121,11 @@ proc handleEdit(ctx: AppContext) {.async.} =
domain.updatePage(ctx.db, page, ctx.getFormParams("content")) domain.updatePage(ctx.db, page, ctx.getFormParams("content"))
resp redirect(pageUrlFor(ctx, "view-page", page), Http303) 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.} = proc view(ctx: AppContext) {.async.} =
let page = slugToPage(decodeUrl(ctx.getPathParams("page"))) let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
let rawRevision = ctx.getQueryParams("ts") let rawRevision = ctx.getQueryParams("ts")
@@ -134,6 +140,8 @@ proc view(ctx: AppContext) {.async.} =
let pageData = get pageData let pageData = get pageData
let mainBody = if viewSource: buildHtml(pre): text pageData.content else: verbatim md.renderToHtml(pageData.content) let mainBody = if viewSource: buildHtml(pre): text pageData.content else: verbatim md.renderToHtml(pageData.content)
if revisionTs.isNone: if revisionTs.isNone:
# current revision
let backlinks = domain.backlinks(ctx.db, page)
let html = let html =
buildHtml(tdiv): buildHtml(tdiv):
tdiv(class="timestamp"): tdiv(class="timestamp"):
@@ -143,8 +151,18 @@ proc view(ctx: AppContext) {.async.} =
text "Created " text "Created "
text displayTime(pageData.created) text displayTime(pageData.created)
tdiv(class="md"): mainBody 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) resp base(page, @[pageButton(ctx, "edit-page", page, "Edit"), pageButton(ctx, "page-revisions", page, "Revisions")], html)
else: else:
# old revision
let rts = get revisionTs let rts = get revisionTs
let (next, prev) = domain.adjacentRevisions(ctx.db, page, rts) let (next, prev) = domain.adjacentRevisions(ctx.db, page, rts)
let html = 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")] 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 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 })) if prev.isSome: buttons.add(pageButton(ctx, "prev-page", page, "Previous", { "ts": timestampToStr (get prev).time }))
resp base(page, buttons, html) resp base(page, buttons, html)
proc search(ctx: AppContext) {.async.} = proc search(ctx: AppContext) {.async.} =
@@ -172,12 +191,13 @@ proc favicon(ctx: Context) {.async.} = resp error404()
proc index(ctx: Context) {.async.} = resp "bee(s)" proc index(ctx: Context) {.async.} = resp "bee(s)"
var app = newApp(settings = settings) 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("/", index)
app.get("/favicon.ico", favicon) app.get("/favicon.ico", favicon)
app.get("/api/search", search, name="search") app.get("/api/search", search, name="search")
app.get("/{page}/edit", edit, name="edit-page") app.get("/{page}/edit", edit, name="edit-page")
app.get("/{page}/revisions", revisions, name="page-revisions") app.get("/{page}/revisions", revisions, name="page-revisions")
app.post("/{page}/edit", handleEdit, name="handle-edit") 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.get("/{page}/", view, name="view-page")
app.run() app.run()

View File

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

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 toDbValue*(t: Time): DbValue = DbValue(kind: sqliteInteger, intVal: timeToTimestamp(t))
proc fromDbValue*(value: DbValue, T: typedesc[Time]): Time = timestampToTime(value.intVal) 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 = template autoInitializedThreadvar*(name: untyped, typ: typedesc, initialize: typed): untyped =
var data* {.threadvar.}: Option[typ] var data* {.threadvar.}: Option[typ]
proc `name`(): typ = proc `name`(): typ =
@@ -52,10 +42,13 @@ const SIMPLEFLAKE_EPOCH = 946702800
const SIMPLEFLAKE_RANDOM_LENGTH = 21 const SIMPLEFLAKE_RANDOM_LENGTH = 21
let now = times.getTime() 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 = 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() let now = times.getTime().toUnixFloat()
var ts = int64((now - SIMPLEFLAKE_EPOCH) * 1000) var ts = int64((now - SIMPLEFLAKE_EPOCH) * 1000)
let randomBits = int64(rng.rand(2 ^ SIMPLEFLAKE_RANDOM_LENGTH)) 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 #!/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