Sync editor state per blog using websockets and cache in database

This commit is contained in:
Jan-Lukas Else 2022-10-15 22:51:36 +02:00
parent f9d568acc8
commit 903d5a265a
11 changed files with 270 additions and 123 deletions

View File

@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"github.com/samber/lo"
"github.com/spf13/viper"
@ -97,9 +98,13 @@ type configBlog struct {
Map *configGeoMap `mapstructure:"map"`
Contact *configContact `mapstructure:"contact"`
Announcement *configAnnouncement `mapstructure:"announcement"`
// Configs read from database
hideOldContentWarning bool
hideShareButton bool
hideTranslateButton bool
// Editor state WebSockets
esws sync.Map
esm sync.Mutex
}
type configSection struct {

View File

@ -33,7 +33,7 @@ func (a *goBlog) serveEditorPreview(w http.ResponseWriter, r *http.Request) {
}
c.SetReadLimit(1 << 20) // 1MB
defer c.Close(ws.StatusNormalClosure, "")
ctx, cancel := context.WithTimeout(r.Context(), time.Minute*60)
ctx, cancel := context.WithTimeout(r.Context(), time.Hour*6)
defer cancel()
for {
// Retrieve content

103
editorState.go Normal file
View File

@ -0,0 +1,103 @@
package main
import (
"context"
"io"
"net/http"
"time"
"github.com/google/uuid"
ws "nhooyr.io/websocket"
)
func (a *goBlog) serveEditorStateSync(w http.ResponseWriter, r *http.Request) {
// Get blog
blog, bc := a.getBlog(r)
// Open websocket connection
c, err := ws.Accept(w, r, &ws.AcceptOptions{CompressionMode: ws.CompressionContextTakeover})
if err != nil {
return
}
c.SetReadLimit(1 << 20) // 1MB
defer c.Close(ws.StatusNormalClosure, "")
// Store connection to be able to send updates
connectionId := uuid.NewString()
bc.esws.Store(connectionId, c)
defer bc.esws.Delete(connectionId)
// Set cancel context
ctx, cancel := context.WithTimeout(r.Context(), time.Hour*6)
defer cancel()
// Send initial content
if r.URL.Query().Get("initial") == "1" {
initialState, err := a.getEditorStateFromDatabase(ctx, blog)
if err != nil {
return
}
if initialState != nil {
w, err := c.Writer(ctx, ws.MessageText)
if err != nil {
return
}
_, err = w.Write(initialState)
if err != nil {
return
}
_ = w.Close()
}
}
// Listen for new messages
for {
// Retrieve content
mt, message, err := c.Reader(ctx)
if err != nil {
break
}
if mt != ws.MessageText {
continue
}
messageBytes, err := io.ReadAll(message)
if err != nil {
break
}
// Save editor state
bc.esm.Lock()
a.updateEditorStateInDatabase(ctx, blog, messageBytes)
bc.esm.Unlock()
// Send editor state to other connections
a.sendNewEditorStateToAllConnections(ctx, bc, connectionId, messageBytes)
}
}
func (a *goBlog) sendNewEditorStateToAllConnections(ctx context.Context, bc *configBlog, origin string, state []byte) {
bc.esws.Range(func(key, value any) bool {
if key == origin {
return true
}
c, ok := value.(*ws.Conn)
if !ok {
return true
}
w, err := c.Writer(ctx, ws.MessageText)
if err != nil {
bc.esws.Delete(key)
return true
}
defer w.Close()
_, err = w.Write(state)
if err != nil {
bc.esws.Delete(key)
return true
}
return true
})
}
const editorStateCacheKey = "editorstate_"
func (a *goBlog) updateEditorStateInDatabase(ctx context.Context, blog string, state []byte) {
_ = a.db.cachePersistentlyContext(ctx, editorStateCacheKey+blog, state)
}
func (a *goBlog) getEditorStateFromDatabase(ctx context.Context, blog string) ([]byte, error) {
return a.db.retrievePersistentCacheContext(ctx, editorStateCacheKey+blog)
}

View File

@ -353,6 +353,7 @@ func (a *goBlog) blogEditorRouter(_ *configBlog) func(r chi.Router) {
r.Get("/deleted"+feedPath, a.serveDeleted)
r.Get("/deleted"+paginationPath, a.serveDeleted)
r.HandleFunc("/preview", a.serveEditorPreview)
r.HandleFunc("/sync", a.serveEditorStateSync)
}
}

View File

@ -20,6 +20,7 @@ drafts: "Entwürfe"
draftsdesc: "Posts mit dem Status `draft`."
editor: "Editor"
editorpostdesc: "💡 Leere Parameter werden automatisch entfernt. Mehr mögliche Parameter: %s. Mögliche Zustände für `%s` und `%s`: %s und %s."
editorusetemplate: "Benutze Vorlage"
emailopt: "E-Mail (optional)"
fileuses: "Datei-Verwendungen"
general: "Allgemein"

View File

@ -23,6 +23,7 @@ drafts: "Drafts"
draftsdesc: "Posts with status `draft`."
editor: "Editor"
editorpostdesc: "💡 Empty parameters are removed automatically. More possible parameters: %s. Possible states for `%s` and `%s`: %s and %s."
editorusetemplate: "Use template"
emailopt: "Email (optional)"
feed: "Feed"
fileuses: "file uses"

View File

@ -0,0 +1,124 @@
(function () {
// Preview
function openPreviewWS(element) {
// Get preview container
let previewContainer = document.getElementById(element.dataset.preview)
if (!previewContainer) {
return
}
// Get websocket path
let wsUrl = element.dataset.previewws
if (!wsUrl) {
return
}
// Create and open websocket
let ws = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + wsUrl)
ws.onopen = function () {
console.log("Preview-Websocket opened")
previewContainer.classList.add('preview')
previewContainer.classList.remove('hide')
if (ws) {
ws.send(element.value)
}
}
ws.onclose = function () {
console.log("Preview-Websocket closed, try to reopen in 1 second")
previewContainer.classList.add('hide')
previewContainer.classList.remove('preview')
previewContainer.innerHTML = ''
ws = null
setTimeout(function () { openPreviewWS(element) }, 1000);
}
ws.onmessage = function (evt) {
// Set preview HTML
previewContainer.innerHTML = evt.data
}
ws.onerror = function (evt) {
console.log("Preview-Websocket error: " + evt.data)
}
// Add listener
let timeout = null
element.addEventListener('input', function () {
clearTimeout(timeout)
timeout = setTimeout(function () {
if (ws) {
ws.send(element.value)
}
}, 500)
})
}
Array.from(document.querySelectorAll('#editor-create, #editor-update')).forEach(element => openPreviewWS(element))
// Sync state
function openSyncStateWS(element, initial) {
// Get websocket path
let wsUrl = element.dataset.syncws
if (!wsUrl) {
return
}
// Create and open websocket
let ws = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + wsUrl + '?initial=' + initial)
ws.onopen = function () {
console.log("Sync-Websocket opened")
}
ws.onclose = function () {
console.log("Sync-Websocket closed, try to reopen in 1 second")
ws = null
setTimeout(function () { openSyncStateWS(element, "0") }, 1000);
}
ws.onmessage = function (evt) {
element.value = evt.data
}
ws.onerror = function (evt) {
console.log("Sync-Websocket error: " + evt.data)
}
// Add listener
let timeout = null
element.addEventListener('input', function () {
clearTimeout(timeout)
timeout = setTimeout(function () {
if (ws) {
ws.send(element.value)
}
}, 100)
})
// Clear on submit
element.form.addEventListener('submit', function () {
if (ws) {
ws.send('')
}
})
}
Array.from(document.querySelectorAll('#editor-create')).forEach(element => openSyncStateWS(element, "1"))
// Geo button
let geoBtn = document.querySelector('#geobtn')
geoBtn.addEventListener('click', function () {
let status = document.querySelector('#geostatus')
status.classList.add('hide')
status.value = ''
function success(position) {
let latitude = position.coords.latitude
let longitude = position.coords.longitude
status.value = `geo:${latitude},${longitude}`
status.classList.remove('hide')
}
function error() {
alert(geoBtn.dataset.failed)
}
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(success, error)
} else {
alert(geoBtn.dataset.notsupported)
}
})
// Template button
document.querySelector('#templatebtn').addEventListener('click', function () {
let area = document.querySelector('#editor-create')
area.value = area.dataset.template;
})
})()

View File

@ -1,19 +0,0 @@
(function () {
const fc = 'formcache'
Array.from(document.querySelectorAll('form .' + fc)).forEach(element => {
let elementName = fc + '-' + location.pathname + '#' + element.id
// Load from cache
let cached = localStorage.getItem(elementName)
if (cached != null) {
element.value = cached
}
// Auto save to cache
element.addEventListener('input', function () {
localStorage.setItem(elementName, element.value)
})
// Clear on submit
element.form.addEventListener('submit', function () {
localStorage.removeItem(elementName)
})
})
})()

View File

@ -1,26 +0,0 @@
(function () {
let geoBtn = document.querySelector('#geobtn')
function geo() {
let status = document.querySelector('#geostatus')
status.classList.add('hide')
status.value = ''
function success(position) {
let latitude = position.coords.latitude
let longitude = position.coords.longitude
status.value = `geo:${latitude},${longitude}`
status.classList.remove('hide')
}
function error() {
alert(geoBtn.dataset.failed)
}
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(success, error)
} else {
alert(geoBtn.dataset.notsupported)
}
}
geoBtn.addEventListener('click', geo)
})()

View File

@ -1,48 +0,0 @@
(function () {
Array.from(document.querySelectorAll('.mdpreview')).forEach(element => {
// Get preview container
let previewContainer = document.getElementById(element.dataset.preview)
if (!previewContainer) {
return
}
// Get websocket path
let wsUrl = element.dataset.previewws
if (!wsUrl) {
return
}
// Create and open websocket
let ws = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + wsUrl)
ws.onopen = function () {
console.log("Preview-Websocket opened")
previewContainer.classList.add('preview')
previewContainer.classList.remove('hide')
if (ws) {
ws.send(element.value)
}
}
ws.onclose = function () {
console.log("Preview-Websocket closed")
previewContainer.classList.add('hide')
previewContainer.classList.remove('preview')
previewContainer.innerHTML = ''
ws = null
}
ws.onmessage = function (evt) {
// Set preview HTML
previewContainer.innerHTML = evt.data
}
ws.onerror = function (evt) {
console.log("Preview-Websocket error: " + evt.data)
}
// Add listener
let timeout = null
element.addEventListener('input', function () {
clearTimeout(timeout)
timeout = setTimeout(function () {
if (ws) {
ws.send(element.value)
}
}, 500)
})
})
})()

19
ui.go
View File

@ -1378,15 +1378,21 @@ func (a *goBlog) renderEditor(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
_ = a.renderMarkdownToWriter(hb, a.editorPostDesc(rd.Blog), false)
hb.WriteElementOpen("form", "method", "post", "class", "fw p")
hb.WriteElementOpen("input", "type", "hidden", "name", "h", "value", "entry")
hb.WriteElementOpen(
"input", "id", "templatebtn", "type", "button",
"value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "editorusetemplate"),
)
hb.WriteElementOpen(
"textarea",
"id", "editor-create",
"name", "content",
"class", "monospace h400p formcache mdpreview",
"class", "monospace h400p",
"id", "create-input",
"data-preview", "post-preview",
"data-previewws", rd.Blog.getRelativePath("/editor/preview"),
"data-syncws", rd.Blog.getRelativePath("/editor/sync"),
"data-template", a.editorPostTemplate(rd.BlogString, rd.Blog),
)
hb.WriteEscaped(a.editorPostTemplate(rd.BlogString, rd.Blog))
hb.WriteElementClose("textarea")
hb.WriteElementOpen("div", "id", "post-preview", "class", "hide")
hb.WriteElementClose("div")
@ -1403,8 +1409,9 @@ func (a *goBlog) renderEditor(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
hb.WriteElementOpen("input", "type", "hidden", "name", "url", "value", edrd.updatePostUrl)
hb.WriteElementOpen(
"textarea",
"id", "editor-update",
"name", "content",
"class", "monospace h400p mdpreview",
"class", "monospace h400p",
"data-preview", "update-preview",
"data-previewws", rd.Blog.getRelativePath("/editor/preview"),
)
@ -1484,11 +1491,9 @@ func (a *goBlog) renderEditor(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
hb.WriteElementClose("main")
// Scripts
for _, script := range []string{"js/mdpreview.js", "js/geohelper.js", "js/formcache.js"} {
hb.WriteElementOpen("script", "src", a.assetFileName(script), "defer", "")
// Script
hb.WriteElementOpen("script", "src", a.assetFileName("js/editor.js"), "defer", "")
hb.WriteElementClose("script")
}
},
)
}