gosora/common/menus.go

514 lines
14 KiB
Go
Raw Normal View History

package common
import (
"bytes"
"database/sql"
"fmt"
"io"
"io/ioutil"
"strconv"
"strings"
"github.com/Azareal/Gosora/common/phrases"
tmpl "github.com/Azareal/Gosora/common/templates"
qgen "github.com/Azareal/Gosora/query_gen"
)
type MenuItemList []MenuItem
type MenuListHolder struct {
MenuID int
List MenuItemList
Variations map[int]menuTmpl // 0 = Guest Menu, 1 = Member Menu, 2 = Super Mod Menu, 3 = Admin Menu
}
type menuPath struct {
Path string
Index int
}
type menuTmpl struct {
RenderBuffer [][]byte
VariableIndices []int
PathMappings []menuPath
}
type MenuItem struct {
ID int
MenuID int
Name string
HTMLID string
CSSClass string
Position string
Path string
Aria string
Tooltip string
Order int
TmplName string
GuestOnly bool
MemberOnly bool
SuperModOnly bool
AdminOnly bool
}
// TODO: Move the menu item stuff to it's own file
type MenuItemStmts struct {
update *sql.Stmt
insert *sql.Stmt
delete *sql.Stmt
updateOrder *sql.Stmt
}
var menuItemStmts MenuItemStmts
func init() {
DbInits.Add(func(acc *qgen.Accumulator) error {
mi := "menu_items"
menuItemStmts = MenuItemStmts{
update: acc.Update(mi).Set("name=?,htmlID=?,cssClass=?,position=?,path=?,aria=?,tooltip=?,tmplName=?,guestOnly=?,memberOnly=?,staffOnly=?,adminOnly=?").Where("miid=?").Prepare(),
insert: acc.Insert(mi).Columns("mid, name, htmlID, cssClass, position, path, aria, tooltip, tmplName, guestOnly, memberOnly, staffOnly, adminOnly").Fields("?,?,?,?,?,?,?,?,?,?,?,?,?").Prepare(),
delete: acc.Delete(mi).Where("miid=?").Prepare(),
updateOrder: acc.Update(mi).Set("order=?").Where("miid=?").Prepare(),
}
return acc.FirstError()
})
}
func (i MenuItem) Commit() error {
_, e := menuItemStmts.update.Exec(i.Name, i.HTMLID, i.CSSClass, i.Position, i.Path, i.Aria, i.Tooltip, i.TmplName, i.GuestOnly, i.MemberOnly, i.SuperModOnly, i.AdminOnly, i.ID)
Menus.Load(i.MenuID)
return e
}
func (i MenuItem) Create() (int, error) {
res, e := menuItemStmts.insert.Exec(i.MenuID, i.Name, i.HTMLID, i.CSSClass, i.Position, i.Path, i.Aria, i.Tooltip, i.TmplName, i.GuestOnly, i.MemberOnly, i.SuperModOnly, i.AdminOnly)
if e != nil {
return 0, e
}
Menus.Load(i.MenuID)
miid64, e := res.LastInsertId()
return int(miid64), e
}
func (i MenuItem) Delete() error {
_, e := menuItemStmts.delete.Exec(i.ID)
Menus.Load(i.MenuID)
return e
}
func (h *MenuListHolder) LoadTmpl(name string) (t MenuTmpl, e error) {
data, e := ioutil.ReadFile("./templates/" + name + ".html")
if e != nil {
return t, e
}
return h.Parse(name, []byte(tmpl.Minify(string(data)))), nil
}
// TODO: Make this atomic, maybe with a transaction or store the order on the menu itself?
func (h *MenuListHolder) UpdateOrder(updateMap map[int]int) error {
for miid, order := range updateMap {
_, e := menuItemStmts.updateOrder.Exec(order, miid)
if e != nil {
return e
}
}
Menus.Load(h.MenuID)
return nil
}
func (h *MenuListHolder) LoadTmpls() (tmpls map[string]MenuTmpl, e error) {
tmpls = make(map[string]MenuTmpl)
load := func(name string) error {
menuTmpl, e := h.LoadTmpl(name)
if e != nil {
return e
}
tmpls[name] = menuTmpl
return nil
}
e = load("menu_item")
if e != nil {
return tmpls, e
}
e = load("menu_alerts")
return tmpls, e
}
// TODO: Run this in main, sync ticks, when the phrase file changes (need to implement the sync for that first), and when the settings are changed
func (h *MenuListHolder) Preparse() error {
tmpls, err := h.LoadTmpls()
if err != nil {
return err
}
addVariation := func(index int, callback func(i MenuItem) bool) {
renderBuffer, variableIndices, pathList := h.Scan(tmpls, callback)
h.Variations[index] = menuTmpl{renderBuffer, variableIndices, pathList}
}
// Guest Menu
addVariation(0, func(i MenuItem) bool {
return !i.MemberOnly
})
// Member Menu
addVariation(1, func(i MenuItem) bool {
return !i.SuperModOnly && !i.GuestOnly
})
// Super Mod Menu
addVariation(2, func(i MenuItem) bool {
return !i.AdminOnly && !i.GuestOnly
})
// Admin Menu
addVariation(3, func(i MenuItem) bool {
return !i.GuestOnly
})
return nil
}
func nextCharIs(tmplData []byte, i int, expects byte) bool {
if len(tmplData) <= (i + 1) {
return false
}
return tmplData[i+1] == expects
}
func peekNextChar(tmplData []byte, i int) byte {
if len(tmplData) <= (i + 1) {
return 0
}
return tmplData[i+1]
}
func skipUntilIfExists(tmplData []byte, i int, expects byte) (newI int, hasIt bool) {
j := i
for ; j < len(tmplData); j++ {
if tmplData[j] == expects {
return j, true
You can now manage the attachments for an opening post by hitting edit. The update system now uses the database as the source of truth for the last version rather than lastSchema.json Refactored several structs and bits of code, so we can avoid allocations for contexts where we never use a relative time. Clicking on the relative times on the topic list and the forum page should now take you to the post on the last page rather than just the last page. Added the reltime template function. Fixed some obsolete bits of code. Fixed some spelling mistakes. Fixed a bug where MaxBytesReader was capped at the maxFileSize rather than r.ContentLength. All of the client side templates should work again now. Shortened some statement names to save some horizontal space. accUpdateBuilder and SimpleUpdate now use updatePrebuilder behind the scenes to simplify things. Renamed selectItem to builder in AccSelectBuilder. Added a Total() method to accCountBuilder to reduce the amount of boilerplate used for row count queries. The "_builder" strings have been replaced with empty strings to help save memory, to make things slightly faster and to open the door to removing the query name in many contexts down the line. Added the open_edit and close_edit client hooks. Removed many query name checks. Split the attachment logic into separate functions and de-duplicated it between replies and topics. Improved the UI for editing topics in Nox. Used type aliases to reduce the amount of boilerplate in tables.go and patches.go Reduced the amount of boilerplate in the action post logic. Eliminated a map and a slice in the topic page for users who haven't given any likes. E.g. Guests. Fixed some long out-dated parts of the update instructions. Updated the update instructions to remove mention of the obsolete lastSchema.json Fixed a bug in init.js where /api/me was being loaded for guests. Added the MiniTopicGet, GlobalCount and CountInTopic methods to AttachmentStore. Added the MiniAttachment struct. Split the mod floaters out into their own template to reduce duplication. Removed a couple of redundant ParseForms. Added the common.skipUntilIfExistsOrLine function. Added the NotFoundJS and NotFoundJSQ functions. Added the lastReplyID and attachCount columns to the topics table.
2018-12-27 05:42:41 +00:00
}
}
return j, false
}
func skipUntilIfExistsOrLine(tmplData []byte, i int, expects byte) (newI int, hasIt bool) {
j := i
for ; j < len(tmplData); j++ {
if tmplData[j] == 10 {
return j, false
} else if tmplData[j] == expects {
return j, true
}
}
return j, false
}
func skipUntilCharsExist(tmplData []byte, i int, expects []byte) (newI int, hasIt bool) {
j := i
expectIndex := 0
for ; j < len(tmplData) && expectIndex < len(expects); j++ {
//fmt.Println("tmplData[j]: ", string(tmplData[j]))
if tmplData[j] != expects[expectIndex] {
return j, false
}
//fmt.Printf("found %+v at %d\n", string(expects[expectIndex]), expectIndex)
expectIndex++
}
return j, true
}
func skipAllUntilCharsExist(tmplData []byte, i int, expects []byte) (newI int, hasIt bool) {
j := i
expectIndex := 0
for ; j < len(tmplData) && expectIndex < len(expects); j++ {
if tmplData[j] == expects[expectIndex] {
//fmt.Printf("expects[expectIndex]: %+v - %d\n", string(expects[expectIndex]), expectIndex)
expectIndex++
if len(expects) <= expectIndex {
break
}
} else {
/*if expectIndex != 0 {
fmt.Println("broke expectations")
fmt.Println("expected: ", string(expects[expectIndex]))
fmt.Println("got: ", string(tmplData[j]))
fmt.Println("next: ", string(peekNextChar(tmplData, j)))
fmt.Println("next: ", string(peekNextChar(tmplData, j+1)))
fmt.Println("next: ", string(peekNextChar(tmplData, j+2)))
fmt.Println("next: ", string(peekNextChar(tmplData, j+3)))
}*/
expectIndex = 0
}
}
return j, len(expects) == expectIndex
}
type menuRenderItem struct {
Type int // 0: text, 1: variable
Index int
}
type MenuTmpl struct {
Name string
TextBuffer [][]byte
VariableBuffer [][]byte
RenderList []menuRenderItem
}
func menuDumpSlice(outerSlice [][]byte) {
for sliceID, slice := range outerSlice {
fmt.Print(strconv.Itoa(sliceID) + ":[")
for _, ch := range slice {
fmt.Print(string(ch))
}
fmt.Print("] ")
}
}
func (h *MenuListHolder) Parse(name string, tmplData []byte) (menuTmpl MenuTmpl) {
var textBuffer, variableBuffer [][]byte
var renderList []menuRenderItem
var subBuffer []byte
// ? We only support simple properties on MenuItem right now
addVariable := func(name []byte) {
// TODO: Check if the subBuffer has any items or is empty
textBuffer = append(textBuffer, subBuffer)
subBuffer = nil
variableBuffer = append(variableBuffer, name)
renderList = append(renderList, menuRenderItem{0, len(textBuffer) - 1})
renderList = append(renderList, menuRenderItem{1, len(variableBuffer) - 1})
}
tmplData = bytes.Replace(tmplData, []byte("{{"), []byte("{"), -1)
tmplData = bytes.Replace(tmplData, []byte("}}"), []byte("}}"), -1)
for i := 0; i < len(tmplData); i++ {
char := tmplData[i]
if char == '{' {
dotIndex, hasDot := skipUntilIfExists(tmplData, i, '.')
if !hasDot {
// Template function style
langIndex, hasChars := skipUntilCharsExist(tmplData, i+1, []byte("lang"))
if hasChars {
startIndex, hasStart := skipUntilIfExists(tmplData, langIndex, '"')
endIndex, hasEnd := skipUntilIfExists(tmplData, startIndex+1, '"')
if hasStart && hasEnd {
fenceIndex, hasFence := skipUntilIfExists(tmplData, endIndex, '}')
if !hasFence || !nextCharIs(tmplData, fenceIndex, '}') {
break
}
//fmt.Println("tmplData[startIndex:endIndex]: ", tmplData[startIndex+1:endIndex])
prefix := []byte("lang.")
addVariable(append(prefix, tmplData[startIndex+1:endIndex]...))
i = fenceIndex + 1
continue
}
}
break
}
fenceIndex, hasFence := skipUntilIfExists(tmplData, dotIndex, '}')
if !hasFence {
break
}
addVariable(tmplData[dotIndex:fenceIndex])
i = fenceIndex + 1
continue
}
subBuffer = append(subBuffer, char)
}
if len(subBuffer) > 0 {
// TODO: Have a property in renderList which holds the byte slice since variableBuffers and textBuffers have the same underlying implementation?
textBuffer = append(textBuffer, subBuffer)
renderList = append(renderList, menuRenderItem{0, len(textBuffer) - 1})
}
return MenuTmpl{name, textBuffer, variableBuffer, renderList}
}
func (h *MenuListHolder) Scan(tmpls map[string]MenuTmpl, showItem func(i MenuItem) bool) (renderBuffer [][]byte, variableIndices []int, pathList []menuPath) {
for _, mitem := range h.List {
// Do we want this item in this variation of the menu?
if !showItem(mitem) {
continue
}
renderBuffer, variableIndices = h.ScanItem(tmpls, mitem, renderBuffer, variableIndices)
pathList = append(pathList, menuPath{mitem.Path, len(renderBuffer) - 1})
}
// TODO: Need more coalescing in the renderBuffer
return renderBuffer, variableIndices, pathList
}
// Note: This doesn't do a visibility check like hold.Scan() does
func (h *MenuListHolder) ScanItem(tmpls map[string]MenuTmpl, mitem MenuItem, renderBuffer [][]byte, variableIndices []int) ([][]byte, []int) {
menuTmpl, ok := tmpls[mitem.TmplName]
if !ok {
menuTmpl = tmpls["menu_item"]
}
for _, renderItem := range menuTmpl.RenderList {
if renderItem.Type == 0 {
renderBuffer = append(renderBuffer, menuTmpl.TextBuffer[renderItem.Index])
continue
}
variable := menuTmpl.VariableBuffer[renderItem.Index]
dotAt, hasDot := skipUntilIfExists(variable, 0, '.')
if !hasDot {
continue
}
if bytes.Equal(variable[:dotAt], []byte("lang")) {
renderBuffer = append(renderBuffer, []byte(phrases.GetTmplPhrase(string(bytes.TrimPrefix(variable[dotAt:], []byte("."))))))
continue
}
var renderItem []byte
switch string(variable) {
case ".ID":
renderItem = []byte(strconv.Itoa(mitem.ID))
case ".Name":
renderItem = []byte(mitem.Name)
case ".HTMLID":
renderItem = []byte(mitem.HTMLID)
case ".CSSClass":
renderItem = []byte(mitem.CSSClass)
case ".Position":
renderItem = []byte(mitem.Position)
case ".Path":
renderItem = []byte(mitem.Path)
case ".Aria":
renderItem = []byte(mitem.Aria)
case ".Tooltip":
renderItem = []byte(mitem.Tooltip)
case ".CSSActive":
renderItem = []byte("{dyn.active}")
}
_, hasInnerVar := skipUntilIfExists(renderItem, 0, '{')
if hasInnerVar {
DebugLog("inner var: ", string(renderItem))
dotAt, hasDot := skipUntilIfExists(renderItem, 0, '.')
endFence, hasEndFence := skipUntilIfExists(renderItem, dotAt, '}')
if !hasDot || !hasEndFence || (endFence-dotAt) <= 1 {
renderBuffer = append(renderBuffer, renderItem)
variableIndices = append(variableIndices, len(renderBuffer)-1)
continue
}
if bytes.Equal(renderItem[1:dotAt], []byte("lang")) {
//fmt.Println("lang var: ", string(renderItem[dotAt+1:endFence]))
renderBuffer = append(renderBuffer, []byte(phrases.GetTmplPhrase(string(renderItem[dotAt+1:endFence]))))
} else {
fmt.Println("other var: ", string(variable[:dotAt]))
if len(renderItem) > 0 {
renderBuffer = append(renderBuffer, renderItem)
variableIndices = append(variableIndices, len(renderBuffer)-1)
}
}
continue
}
if len(renderItem) > 0 {
renderBuffer = append(renderBuffer, renderItem)
}
}
return renderBuffer, variableIndices
}
// TODO: Pre-render the lang stuff
func (h *MenuListHolder) Build(w io.Writer, user *User, pathPrefix string) error {
var mTmpl menuTmpl
if !user.Loggedin {
mTmpl = h.Variations[0]
} else if user.IsAdmin {
mTmpl = h.Variations[3]
} else if user.IsSuperMod {
mTmpl = h.Variations[2]
} else {
mTmpl = h.Variations[1]
}
if pathPrefix == "" {
pathPrefix = Config.DefaultPath
}
if len(mTmpl.VariableIndices) == 0 {
for _, renderItem := range mTmpl.RenderBuffer {
w.Write(renderItem)
}
return nil
}
nearIndex := 0
for index, renderItem := range mTmpl.RenderBuffer {
if index != mTmpl.VariableIndices[nearIndex] {
w.Write(renderItem)
continue
}
variable := renderItem
// ? - I can probably remove this check now that I've kicked it upstream, or we could keep it here for safety's sake?
if len(variable) == 0 {
continue
}
prevIndex := 0
for i := 0; i < len(renderItem); i++ {
fenceStart, hasFence := skipUntilIfExists(variable, i, '{')
if !hasFence {
continue
}
i = fenceStart
fenceEnd, hasFence := skipUntilIfExists(variable, fenceStart, '}')
if !hasFence {
continue
}
i = fenceEnd
dotAt, hasDot := skipUntilIfExists(variable, fenceStart, '.')
if !hasDot {
continue
}
switch string(variable[fenceStart+1 : dotAt]) {
case "me":
w.Write(variable[prevIndex:fenceStart])
switch string(variable[dotAt+1 : fenceEnd]) {
case "Link":
w.Write([]byte(user.Link))
case "Session":
w.Write([]byte(user.Session))
}
prevIndex = fenceEnd
// TODO: Optimise this
case "dyn":
w.Write(variable[prevIndex:fenceStart])
var pmi int
for ii, pathItem := range mTmpl.PathMappings {
pmi = ii
if pathItem.Index > index {
break
}
}
if len(mTmpl.PathMappings) != 0 {
path := mTmpl.PathMappings[pmi].Path
if path == "" || path == "/" {
path = Config.DefaultPath
}
if strings.HasPrefix(path, pathPrefix) {
w.Write([]byte(" menu_active"))
}
}
prevIndex = fenceEnd
}
}
w.Write(variable[prevIndex : len(variable)-1])
if len(mTmpl.VariableIndices) > (nearIndex + 1) {
nearIndex++
}
}
return nil
}