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:
parent
19297cb6c6
commit
0331628a1e
|
@ -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",
|
||||
|
|
253
src/client.js
253
src/client.js
|
@ -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()
|
||||
}
|
|
@ -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 =
|
||||
|
|
23
src/md.nim
23
src/md.nim
|
@ -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)))
|
|
@ -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()
|
|
@ -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
|
17
src/util.nim
17
src/util.nim
|
@ -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
|
@ -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 */
|
||||
|
|
|
@ -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"}
|
|
@ -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
|
Loading…
Reference in New Issue