github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/common/menus.go (about)

     1  package common
     2  
     3  import (
     4  	"bytes"
     5  	"database/sql"
     6  	"fmt"
     7  	"io"
     8  	"io/ioutil"
     9  	"strconv"
    10  	"strings"
    11  
    12  	"github.com/Azareal/Gosora/common/phrases"
    13  	tmpl "github.com/Azareal/Gosora/common/templates"
    14  	qgen "github.com/Azareal/Gosora/query_gen"
    15  )
    16  
    17  type MenuItemList []MenuItem
    18  
    19  type MenuListHolder struct {
    20  	MenuID     int
    21  	List       MenuItemList
    22  	Variations map[int]menuTmpl // 0 = Guest Menu, 1 = Member Menu, 2 = Super Mod Menu, 3 = Admin Menu
    23  }
    24  
    25  type menuPath struct {
    26  	Path  string
    27  	Index int
    28  }
    29  
    30  type menuTmpl struct {
    31  	RenderBuffer    [][]byte
    32  	VariableIndices []int
    33  	PathMappings    []menuPath
    34  }
    35  
    36  type MenuItem struct {
    37  	ID     int
    38  	MenuID int
    39  
    40  	Name     string
    41  	HTMLID   string
    42  	CSSClass string
    43  	Position string
    44  	Path     string
    45  	Aria     string
    46  	Tooltip  string
    47  	Order    int
    48  	TmplName string
    49  
    50  	GuestOnly    bool
    51  	MemberOnly   bool
    52  	SuperModOnly bool
    53  	AdminOnly    bool
    54  }
    55  
    56  // TODO: Move the menu item stuff to it's own file
    57  type MenuItemStmts struct {
    58  	update      *sql.Stmt
    59  	insert      *sql.Stmt
    60  	delete      *sql.Stmt
    61  	updateOrder *sql.Stmt
    62  }
    63  
    64  var menuItemStmts MenuItemStmts
    65  
    66  func init() {
    67  	DbInits.Add(func(acc *qgen.Accumulator) error {
    68  		mi := "menu_items"
    69  		menuItemStmts = MenuItemStmts{
    70  			update:      acc.Update(mi).Set("name=?,htmlID=?,cssClass=?,position=?,path=?,aria=?,tooltip=?,tmplName=?,guestOnly=?,memberOnly=?,staffOnly=?,adminOnly=?").Where("miid=?").Prepare(),
    71  			insert:      acc.Insert(mi).Columns("mid, name, htmlID, cssClass, position, path, aria, tooltip, tmplName, guestOnly, memberOnly, staffOnly, adminOnly").Fields("?,?,?,?,?,?,?,?,?,?,?,?,?").Prepare(),
    72  			delete:      acc.Delete(mi).Where("miid=?").Prepare(),
    73  			updateOrder: acc.Update(mi).Set("order=?").Where("miid=?").Prepare(),
    74  		}
    75  		return acc.FirstError()
    76  	})
    77  }
    78  
    79  func (i MenuItem) Commit() error {
    80  	_, 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)
    81  	Menus.Load(i.MenuID)
    82  	return e
    83  }
    84  
    85  func (i MenuItem) Create() (int, error) {
    86  	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)
    87  	if e != nil {
    88  		return 0, e
    89  	}
    90  	Menus.Load(i.MenuID)
    91  
    92  	miid64, e := res.LastInsertId()
    93  	return int(miid64), e
    94  }
    95  
    96  func (i MenuItem) Delete() error {
    97  	_, e := menuItemStmts.delete.Exec(i.ID)
    98  	Menus.Load(i.MenuID)
    99  	return e
   100  }
   101  
   102  func (h *MenuListHolder) LoadTmpl(name string) (t MenuTmpl, e error) {
   103  	data, e := ioutil.ReadFile("./templates/" + name + ".html")
   104  	if e != nil {
   105  		return t, e
   106  	}
   107  	return h.Parse(name, []byte(tmpl.Minify(string(data)))), nil
   108  }
   109  
   110  // TODO: Make this atomic, maybe with a transaction or store the order on the menu itself?
   111  func (h *MenuListHolder) UpdateOrder(updateMap map[int]int) error {
   112  	for miid, order := range updateMap {
   113  		_, e := menuItemStmts.updateOrder.Exec(order, miid)
   114  		if e != nil {
   115  			return e
   116  		}
   117  	}
   118  	Menus.Load(h.MenuID)
   119  	return nil
   120  }
   121  
   122  func (h *MenuListHolder) LoadTmpls() (tmpls map[string]MenuTmpl, e error) {
   123  	tmpls = make(map[string]MenuTmpl)
   124  	load := func(name string) error {
   125  		menuTmpl, e := h.LoadTmpl(name)
   126  		if e != nil {
   127  			return e
   128  		}
   129  		tmpls[name] = menuTmpl
   130  		return nil
   131  	}
   132  	e = load("menu_item")
   133  	if e != nil {
   134  		return tmpls, e
   135  	}
   136  	e = load("menu_alerts")
   137  	return tmpls, e
   138  }
   139  
   140  // 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
   141  func (h *MenuListHolder) Preparse() error {
   142  	tmpls, err := h.LoadTmpls()
   143  	if err != nil {
   144  		return err
   145  	}
   146  
   147  	addVariation := func(index int, callback func(i MenuItem) bool) {
   148  		renderBuffer, variableIndices, pathList := h.Scan(tmpls, callback)
   149  		h.Variations[index] = menuTmpl{renderBuffer, variableIndices, pathList}
   150  	}
   151  
   152  	// Guest Menu
   153  	addVariation(0, func(i MenuItem) bool {
   154  		return !i.MemberOnly
   155  	})
   156  	// Member Menu
   157  	addVariation(1, func(i MenuItem) bool {
   158  		return !i.SuperModOnly && !i.GuestOnly
   159  	})
   160  	// Super Mod Menu
   161  	addVariation(2, func(i MenuItem) bool {
   162  		return !i.AdminOnly && !i.GuestOnly
   163  	})
   164  	// Admin Menu
   165  	addVariation(3, func(i MenuItem) bool {
   166  		return !i.GuestOnly
   167  	})
   168  	return nil
   169  }
   170  
   171  func nextCharIs(tmplData []byte, i int, expects byte) bool {
   172  	if len(tmplData) <= (i + 1) {
   173  		return false
   174  	}
   175  	return tmplData[i+1] == expects
   176  }
   177  
   178  func peekNextChar(tmplData []byte, i int) byte {
   179  	if len(tmplData) <= (i + 1) {
   180  		return 0
   181  	}
   182  	return tmplData[i+1]
   183  }
   184  
   185  func skipUntilIfExists(tmplData []byte, i int, expects byte) (newI int, hasIt bool) {
   186  	j := i
   187  	for ; j < len(tmplData); j++ {
   188  		if tmplData[j] == expects {
   189  			return j, true
   190  		}
   191  	}
   192  	return j, false
   193  }
   194  
   195  func skipUntilIfExistsOrLine(tmplData []byte, i int, expects byte) (newI int, hasIt bool) {
   196  	j := i
   197  	for ; j < len(tmplData); j++ {
   198  		if tmplData[j] == 10 {
   199  			return j, false
   200  		} else if tmplData[j] == expects {
   201  			return j, true
   202  		}
   203  	}
   204  	return j, false
   205  }
   206  
   207  func skipUntilCharsExist(tmplData []byte, i int, expects []byte) (newI int, hasIt bool) {
   208  	j := i
   209  	expectIndex := 0
   210  	for ; j < len(tmplData) && expectIndex < len(expects); j++ {
   211  		//fmt.Println("tmplData[j]: ", string(tmplData[j]))
   212  		if tmplData[j] != expects[expectIndex] {
   213  			return j, false
   214  		}
   215  		//fmt.Printf("found %+v at %d\n", string(expects[expectIndex]), expectIndex)
   216  		expectIndex++
   217  	}
   218  	return j, true
   219  }
   220  
   221  func skipAllUntilCharsExist(tmplData []byte, i int, expects []byte) (newI int, hasIt bool) {
   222  	j := i
   223  	expectIndex := 0
   224  	for ; j < len(tmplData) && expectIndex < len(expects); j++ {
   225  		if tmplData[j] == expects[expectIndex] {
   226  			//fmt.Printf("expects[expectIndex]: %+v - %d\n", string(expects[expectIndex]), expectIndex)
   227  			expectIndex++
   228  			if len(expects) <= expectIndex {
   229  				break
   230  			}
   231  		} else {
   232  			/*if expectIndex != 0 {
   233  				fmt.Println("broke expectations")
   234  				fmt.Println("expected: ", string(expects[expectIndex]))
   235  				fmt.Println("got: ", string(tmplData[j]))
   236  				fmt.Println("next: ", string(peekNextChar(tmplData, j)))
   237  				fmt.Println("next: ", string(peekNextChar(tmplData, j+1)))
   238  				fmt.Println("next: ", string(peekNextChar(tmplData, j+2)))
   239  				fmt.Println("next: ", string(peekNextChar(tmplData, j+3)))
   240  			}*/
   241  			expectIndex = 0
   242  		}
   243  	}
   244  	return j, len(expects) == expectIndex
   245  }
   246  
   247  type menuRenderItem struct {
   248  	Type  int // 0: text, 1: variable
   249  	Index int
   250  }
   251  
   252  type MenuTmpl struct {
   253  	Name           string
   254  	TextBuffer     [][]byte
   255  	VariableBuffer [][]byte
   256  	RenderList     []menuRenderItem
   257  }
   258  
   259  func menuDumpSlice(outerSlice [][]byte) {
   260  	for sliceID, slice := range outerSlice {
   261  		fmt.Print(strconv.Itoa(sliceID) + ":[")
   262  		for _, ch := range slice {
   263  			fmt.Print(string(ch))
   264  		}
   265  		fmt.Print("] ")
   266  	}
   267  }
   268  
   269  func (h *MenuListHolder) Parse(name string, tmplData []byte) (menuTmpl MenuTmpl) {
   270  	var textBuffer, variableBuffer [][]byte
   271  	var renderList []menuRenderItem
   272  	var subBuffer []byte
   273  
   274  	// ? We only support simple properties on MenuItem right now
   275  	addVariable := func(name []byte) {
   276  		// TODO: Check if the subBuffer has any items or is empty
   277  		textBuffer = append(textBuffer, subBuffer)
   278  		subBuffer = nil
   279  
   280  		variableBuffer = append(variableBuffer, name)
   281  		renderList = append(renderList, menuRenderItem{0, len(textBuffer) - 1})
   282  		renderList = append(renderList, menuRenderItem{1, len(variableBuffer) - 1})
   283  	}
   284  
   285  	tmplData = bytes.Replace(tmplData, []byte("{{"), []byte("{"), -1)
   286  	tmplData = bytes.Replace(tmplData, []byte("}}"), []byte("}}"), -1)
   287  	for i := 0; i < len(tmplData); i++ {
   288  		char := tmplData[i]
   289  		if char == '{' {
   290  			dotIndex, hasDot := skipUntilIfExists(tmplData, i, '.')
   291  			if !hasDot {
   292  				// Template function style
   293  				langIndex, hasChars := skipUntilCharsExist(tmplData, i+1, []byte("lang"))
   294  				if hasChars {
   295  					startIndex, hasStart := skipUntilIfExists(tmplData, langIndex, '"')
   296  					endIndex, hasEnd := skipUntilIfExists(tmplData, startIndex+1, '"')
   297  					if hasStart && hasEnd {
   298  						fenceIndex, hasFence := skipUntilIfExists(tmplData, endIndex, '}')
   299  						if !hasFence || !nextCharIs(tmplData, fenceIndex, '}') {
   300  							break
   301  						}
   302  						//fmt.Println("tmplData[startIndex:endIndex]: ", tmplData[startIndex+1:endIndex])
   303  						prefix := []byte("lang.")
   304  						addVariable(append(prefix, tmplData[startIndex+1:endIndex]...))
   305  						i = fenceIndex + 1
   306  						continue
   307  					}
   308  				}
   309  				break
   310  			}
   311  			fenceIndex, hasFence := skipUntilIfExists(tmplData, dotIndex, '}')
   312  			if !hasFence {
   313  				break
   314  			}
   315  			addVariable(tmplData[dotIndex:fenceIndex])
   316  			i = fenceIndex + 1
   317  			continue
   318  		}
   319  		subBuffer = append(subBuffer, char)
   320  	}
   321  	if len(subBuffer) > 0 {
   322  		// TODO: Have a property in renderList which holds the byte slice since variableBuffers and textBuffers have the same underlying implementation?
   323  		textBuffer = append(textBuffer, subBuffer)
   324  		renderList = append(renderList, menuRenderItem{0, len(textBuffer) - 1})
   325  	}
   326  
   327  	return MenuTmpl{name, textBuffer, variableBuffer, renderList}
   328  }
   329  
   330  func (h *MenuListHolder) Scan(tmpls map[string]MenuTmpl, showItem func(i MenuItem) bool) (renderBuffer [][]byte, variableIndices []int, pathList []menuPath) {
   331  	for _, mitem := range h.List {
   332  		// Do we want this item in this variation of the menu?
   333  		if !showItem(mitem) {
   334  			continue
   335  		}
   336  		renderBuffer, variableIndices = h.ScanItem(tmpls, mitem, renderBuffer, variableIndices)
   337  		pathList = append(pathList, menuPath{mitem.Path, len(renderBuffer) - 1})
   338  	}
   339  
   340  	// TODO: Need more coalescing in the renderBuffer
   341  	return renderBuffer, variableIndices, pathList
   342  }
   343  
   344  // Note: This doesn't do a visibility check like hold.Scan() does
   345  func (h *MenuListHolder) ScanItem(tmpls map[string]MenuTmpl, mitem MenuItem, renderBuffer [][]byte, variableIndices []int) ([][]byte, []int) {
   346  	menuTmpl, ok := tmpls[mitem.TmplName]
   347  	if !ok {
   348  		menuTmpl = tmpls["menu_item"]
   349  	}
   350  
   351  	for _, renderItem := range menuTmpl.RenderList {
   352  		if renderItem.Type == 0 {
   353  			renderBuffer = append(renderBuffer, menuTmpl.TextBuffer[renderItem.Index])
   354  			continue
   355  		}
   356  
   357  		variable := menuTmpl.VariableBuffer[renderItem.Index]
   358  		dotAt, hasDot := skipUntilIfExists(variable, 0, '.')
   359  		if !hasDot {
   360  			continue
   361  		}
   362  
   363  		if bytes.Equal(variable[:dotAt], []byte("lang")) {
   364  			renderBuffer = append(renderBuffer, []byte(phrases.GetTmplPhrase(string(bytes.TrimPrefix(variable[dotAt:], []byte("."))))))
   365  			continue
   366  		}
   367  
   368  		var renderItem []byte
   369  		switch string(variable) {
   370  		case ".ID":
   371  			renderItem = []byte(strconv.Itoa(mitem.ID))
   372  		case ".Name":
   373  			renderItem = []byte(mitem.Name)
   374  		case ".HTMLID":
   375  			renderItem = []byte(mitem.HTMLID)
   376  		case ".CSSClass":
   377  			renderItem = []byte(mitem.CSSClass)
   378  		case ".Position":
   379  			renderItem = []byte(mitem.Position)
   380  		case ".Path":
   381  			renderItem = []byte(mitem.Path)
   382  		case ".Aria":
   383  			renderItem = []byte(mitem.Aria)
   384  		case ".Tooltip":
   385  			renderItem = []byte(mitem.Tooltip)
   386  		case ".CSSActive":
   387  			renderItem = []byte("{dyn.active}")
   388  		}
   389  
   390  		_, hasInnerVar := skipUntilIfExists(renderItem, 0, '{')
   391  		if hasInnerVar {
   392  			DebugLog("inner var: ", string(renderItem))
   393  			dotAt, hasDot := skipUntilIfExists(renderItem, 0, '.')
   394  			endFence, hasEndFence := skipUntilIfExists(renderItem, dotAt, '}')
   395  			if !hasDot || !hasEndFence || (endFence-dotAt) <= 1 {
   396  				renderBuffer = append(renderBuffer, renderItem)
   397  				variableIndices = append(variableIndices, len(renderBuffer)-1)
   398  				continue
   399  			}
   400  
   401  			if bytes.Equal(renderItem[1:dotAt], []byte("lang")) {
   402  				//fmt.Println("lang var: ", string(renderItem[dotAt+1:endFence]))
   403  				renderBuffer = append(renderBuffer, []byte(phrases.GetTmplPhrase(string(renderItem[dotAt+1:endFence]))))
   404  			} else {
   405  				fmt.Println("other var: ", string(variable[:dotAt]))
   406  				if len(renderItem) > 0 {
   407  					renderBuffer = append(renderBuffer, renderItem)
   408  					variableIndices = append(variableIndices, len(renderBuffer)-1)
   409  				}
   410  			}
   411  			continue
   412  		}
   413  		if len(renderItem) > 0 {
   414  			renderBuffer = append(renderBuffer, renderItem)
   415  		}
   416  	}
   417  	return renderBuffer, variableIndices
   418  }
   419  
   420  // TODO: Pre-render the lang stuff
   421  func (h *MenuListHolder) Build(w io.Writer, user *User, pathPrefix string) error {
   422  	var mTmpl menuTmpl
   423  	if !user.Loggedin {
   424  		mTmpl = h.Variations[0]
   425  	} else if user.IsAdmin {
   426  		mTmpl = h.Variations[3]
   427  	} else if user.IsSuperMod {
   428  		mTmpl = h.Variations[2]
   429  	} else {
   430  		mTmpl = h.Variations[1]
   431  	}
   432  	if pathPrefix == "" {
   433  		pathPrefix = Config.DefaultPath
   434  	}
   435  
   436  	if len(mTmpl.VariableIndices) == 0 {
   437  		for _, renderItem := range mTmpl.RenderBuffer {
   438  			w.Write(renderItem)
   439  		}
   440  		return nil
   441  	}
   442  
   443  	nearIndex := 0
   444  	for index, renderItem := range mTmpl.RenderBuffer {
   445  		if index != mTmpl.VariableIndices[nearIndex] {
   446  			w.Write(renderItem)
   447  			continue
   448  		}
   449  		variable := renderItem
   450  		// ? - I can probably remove this check now that I've kicked it upstream, or we could keep it here for safety's sake?
   451  		if len(variable) == 0 {
   452  			continue
   453  		}
   454  
   455  		prevIndex := 0
   456  		for i := 0; i < len(renderItem); i++ {
   457  			fenceStart, hasFence := skipUntilIfExists(variable, i, '{')
   458  			if !hasFence {
   459  				continue
   460  			}
   461  			i = fenceStart
   462  			fenceEnd, hasFence := skipUntilIfExists(variable, fenceStart, '}')
   463  			if !hasFence {
   464  				continue
   465  			}
   466  			i = fenceEnd
   467  			dotAt, hasDot := skipUntilIfExists(variable, fenceStart, '.')
   468  			if !hasDot {
   469  				continue
   470  			}
   471  
   472  			switch string(variable[fenceStart+1 : dotAt]) {
   473  			case "me":
   474  				w.Write(variable[prevIndex:fenceStart])
   475  				switch string(variable[dotAt+1 : fenceEnd]) {
   476  				case "Link":
   477  					w.Write([]byte(user.Link))
   478  				case "Session":
   479  					w.Write([]byte(user.Session))
   480  				}
   481  				prevIndex = fenceEnd
   482  			// TODO: Optimise this
   483  			case "dyn":
   484  				w.Write(variable[prevIndex:fenceStart])
   485  				var pmi int
   486  				for ii, pathItem := range mTmpl.PathMappings {
   487  					pmi = ii
   488  					if pathItem.Index > index {
   489  						break
   490  					}
   491  				}
   492  
   493  				if len(mTmpl.PathMappings) != 0 {
   494  					path := mTmpl.PathMappings[pmi].Path
   495  					if path == "" || path == "/" {
   496  						path = Config.DefaultPath
   497  					}
   498  					if strings.HasPrefix(path, pathPrefix) {
   499  						w.Write([]byte(" menu_active"))
   500  					}
   501  				}
   502  
   503  				prevIndex = fenceEnd
   504  			}
   505  		}
   506  
   507  		w.Write(variable[prevIndex : len(variable)-1])
   508  		if len(mTmpl.VariableIndices) > (nearIndex + 1) {
   509  			nearIndex++
   510  		}
   511  	}
   512  	return nil
   513  }