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:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||||
|
253
src/client.js
253
src/client.js
@@ -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()
|
||||||
|
}
|
@@ -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 =
|
||||||
|
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
|
# 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)))
|
@@ -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()
|
@@ -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%
|
|
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 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
@@ -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
|
#!/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
|
Reference in New Issue
Block a user