Files
PixivFE/server/template/templateFunctions.go
T
2024-12-04 21:32:54 +11:00

539 lines
15 KiB
Go

package template
import (
"fmt"
"html"
"math"
"math/rand"
"net/url"
"regexp"
"strings"
"time"
"codeberg.org/vnpower/pixivfe/v2/core"
"codeberg.org/vnpower/pixivfe/v2/i18n"
)
// HTML is an alias for the HTML type from the core package
type HTML = core.HTML
// GetRandomColor returns a randomly selected color from a predefined list of color shades
func GetRandomColor() string {
// VnPower: Some color shade I stole
colors := []string{
// Green
"#C8847E",
"#C8A87E",
"#C8B87E",
"#C8C67E",
"#C7C87E",
"#C2C87E",
"#BDC87E",
"#82C87E",
"#82C87E",
"#7EC8AF",
"#7EAEC8",
"#7EA6C8",
"#7E99C8",
"#7E87C8",
"#897EC8",
"#967EC8",
"#AE7EC8",
"#B57EC8",
"#C87EA5",
}
// Randomly choose one color and return it
return colors[rand.Intn(len(colors))]
}
// Map of emoji shortcodes to their corresponding image IDs
var emojiList = map[string]string{
"normal": "101",
"surprise": "102",
"serious": "103",
"heaven": "104",
"happy": "105",
"excited": "106",
"sing": "107",
"cry": "108",
"normal2": "201",
"shame2": "202",
"love2": "203",
"interesting2": "204",
"blush2": "205",
"fire2": "206",
"angry2": "207",
"shine2": "208",
"panic2": "209",
"normal3": "301",
"satisfaction3": "302",
"surprise3": "303",
"smile3": "304",
"shock3": "305",
"gaze3": "306",
"wink3": "307",
"happy3": "308",
"excited3": "309",
"love3": "310",
"normal4": "401",
"surprise4": "402",
"serious4": "403",
"love4": "404",
"shine4": "405",
"sweat4": "406",
"shame4": "407",
"sleep4": "408",
"heart": "501",
"teardrop": "502",
"star": "503",
}
// ParseEmojis replaces emoji shortcodes in a string with corresponding image tags
func ParseEmojis(s string) HTML {
// Regular expression to match emoji shortcodes
regex := regexp.MustCompile(`\(([^)]+)\)`)
// Replace shortcodes with corresponding image tags
parsedString := regex.ReplaceAllStringFunc(s, func(s string) string {
s = s[1 : len(s)-1] // Get the string inside parentheses
id, found := emojiList[s] // Check if the string has a corresponding emoji ID
if !found {
return fmt.Sprintf("(%s)", s) // No replacement, return the original text
}
return fmt.Sprintf(`<img src="/proxy/s.pximg.net/common/images/emoji/%s.png" alt="(%s)" class="emoji" />`, id, s)
})
return HTML(parsedString)
}
// PageInfo represents information about a single page in pagination
type PageInfo struct {
Number int
URL string
}
// PaginationData contains all necessary information for rendering pagination controls
type PaginationData struct {
CurrentPage int
MaxPage int
Pages []PageInfo
HasPrevious bool
HasNext bool
PreviousURL string
NextURL string
FirstURL string
LastURL string
HasMaxPage bool
LastPage int
DropdownPages []PageInfo
}
// ParsePixivRedirect extracts and unescapes URLs from Pixiv's redirect links
func ParsePixivRedirect(s string) HTML {
// Regular expression to match Pixiv's redirect URLs
regex := regexp.MustCompile(`\/jump\.php\?(http[^"]+)`)
// Extract the actual URL from the redirect link
parsedString := regex.ReplaceAllStringFunc(s, func(s string) string {
s = s[10:]
return s
})
// Unescape the URL
escaped, err := url.QueryUnescape(parsedString)
if err != nil {
return HTML(s)
}
return HTML(escaped)
}
// EscapeString escapes a string for use in a URL query
func EscapeString(s string) string {
escaped := url.QueryEscape(s)
return escaped
}
// UnescapeHTMLString unescapes all HTML entities to literal characters
func UnescapeHTMLString(s string) string {
return html.UnescapeString(s)
}
// ParseTime formats a time.Time value as a string in the format "2006-01-02 15:04"
func ParseTime(date time.Time) string {
return date.Format("2006-01-02 15:04")
}
// ParseTimeCustomFormat formats a time.Time value as a string using a custom format
func ParseTimeCustomFormat(date time.Time, format string) string {
return date.Format(format)
}
// NaturalTime formats a time.Time value as a natural language string
// TODO: tailor this per locale
func NaturalTime(date time.Time) string {
return date.Format("Monday, 2 January 2006, at 3:04 PM")
}
// RelativeTime formats a time.Time value
// TODO: tailor this per locale
func RelativeTime(date time.Time) string {
now := time.Now()
duration := now.Sub(date)
// Handle future dates
if duration < 0 {
return date.Format("2 January 2006 3:04 PM")
}
// Within last minute
if duration < time.Minute {
return "Just now"
}
// Within last hour
if duration < time.Hour {
minutes := int(duration.Minutes())
if minutes == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", minutes)
}
// Within last 24 hours
if duration < 24*time.Hour {
hours := int(duration.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
}
// Check if yesterday
yesterday := now.AddDate(0, 0, -1)
if date.Year() == yesterday.Year() && date.Month() == yesterday.Month() && date.Day() == yesterday.Day() {
return fmt.Sprintf("Yesterday at %s", date.Format("3:04 PM"))
}
// Within last week
if duration < 7*24*time.Hour {
days := int(duration.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
// Older than a week
return date.Format("2 January 2006 3:04 PM")
}
// CreatePaginator generates pagination data based on the current page and maximum number of pages.
// It returns a PaginationData struct containing all necessary information for rendering pagination controls.
//
// Parameters:
// - base: The base part of the pagination URL
// - ending: The ending part of the pagination URL
// - current_page: The current page being displayed
// - max_page: Maximum number of pages (-1 if unknown)
// - page_margin: Number of pages to display before and after the current page
// - dropdown_offset: Number of pages to include in dropdown before and after current page
func CreatePaginator(base, ending string, current_page, max_page, page_margin, dropdown_offset int) (PaginationData, error) {
// Validate input parameters
if current_page < 1 {
return PaginationData{}, fmt.Errorf("current_page must be greater than or equal to 1, got %d", current_page)
}
if page_margin < 0 {
return PaginationData{}, fmt.Errorf("page_margin must be non-negative, got %d", page_margin)
}
if dropdown_offset < 0 {
return PaginationData{}, fmt.Errorf("dropdown_offset must be non-negative, got %d", dropdown_offset)
}
// Validation for users that don't have any artworks
// NOTE: the following breaks the current max_page implementation, commenting it out for now
// if max_page < 1 {
// max_page = 1
// }
// Validation for max_page in relation to current_page
// if max_page < current_page {
// return PaginationData{}, fmt.Errorf("max_page (%d) must be greater than or equal to current_page (%d) when specified", max_page, current_page)
// }
hasMaxPage := max_page != -1
pageUrl := func(page int) string {
return fmt.Sprintf(`%s%d%s`, base, page, ending)
}
// Helper function to generate a range of pages
generatePageRange := func(start, end int) []PageInfo {
if start > end {
return []PageInfo{}
}
pages := make([]PageInfo, 0, end-start+1)
for i := start; i <= end; i++ {
pages = append(pages, PageInfo{Number: i, URL: pageUrl(i)})
}
return pages
}
// Calculate the range of pages to display
start := max(1, current_page-page_margin)
end := current_page + page_margin
if hasMaxPage {
end = min(max_page, end)
}
// Generate page information for the range
pages := generatePageRange(start, end)
var lastPage int
if len(pages) > 0 {
lastPage = pages[len(pages)-1].Number
} else {
lastPage = current_page
}
// Generate dropdown pages
dropdownStart := max(1, current_page-dropdown_offset)
dropdownEnd := current_page + dropdown_offset
if hasMaxPage {
dropdownEnd = min(max_page, dropdownEnd)
}
dropdownPages := generatePageRange(dropdownStart, dropdownEnd)
// Calculate previous and next URLs
var previousURL, nextURL string
if current_page > 1 {
previousURL = pageUrl(current_page - 1)
}
if !hasMaxPage || current_page < max_page {
nextURL = pageUrl(current_page + 1)
}
// Create and return the PaginationData struct
return PaginationData{
CurrentPage: current_page,
MaxPage: max_page,
Pages: pages,
HasPrevious: current_page > 1,
HasNext: !hasMaxPage || current_page < max_page,
PreviousURL: previousURL,
NextURL: nextURL,
FirstURL: pageUrl(1),
LastURL: pageUrl(max_page),
HasMaxPage: hasMaxPage,
LastPage: lastPage,
DropdownPages: dropdownPages,
}, nil
}
var genreMap = map[string]string{
"1": "Romance",
"2": "Isekai fantasy",
"3": "Contemporary fantasy",
"4": "Mystery",
"5": "Horror",
"6": "Sci-fi",
"7": "Literature",
"8": "Drama",
"9": "Historical pieces",
"10": "BL (yaoi)",
"11": "Yuri",
"12": "For kids",
"13": "Poetry",
"14": "Essays/non-fiction",
"15": "Screenplays/scripts",
"16": "Reviews/opinion pieces",
"17": "Other",
}
// GetNovelGenre returns the genre name for a given genre ID
func GetNovelGenre(s string) string {
if genre, ok := genreMap[s]; ok {
return i18n.Tr(genre)
}
return i18n.Sprintf("(Unknown Genre: %s)", s)
}
// SwitchButtonAttributes generates HTML attributes for a switch button based on the current selection
func SwitchButtonAttributes(baseURL, selection, currentSelection string) string {
var cur string = "false"
if selection == currentSelection {
cur = "true"
}
return fmt.Sprintf(`href=%s%s class=switch-button selected=%s`, baseURL, selection, cur)
}
// IsFirstPathPart checks if the first part of the current path matches the given path
func IsFirstPathPart(currentPath, pathToCheck string) bool {
// Trim any trailing slashes from both paths
currentPath = strings.TrimRight(currentPath, "/")
pathToCheck = strings.TrimRight(pathToCheck, "/")
// Split the current path into parts
parts := strings.SplitN(currentPath, "/", 3)
// Check if we have at least two parts (empty string and the first path part)
if len(parts) < 2 {
return false
}
// Compare the first path part with the pathToCheck
return "/"+parts[1] == pathToCheck
}
// IsLastPathPart checks if the last part of the current path matches the given path
func IsLastPathPart(currentPath, pathToCheck string) bool {
// Parse the currentPath to remove query parameters
u, err := url.Parse(currentPath)
if err == nil {
currentPath = u.Path
}
// Trim any trailing slashes from both paths
currentPath = strings.TrimRight(currentPath, "/")
pathToCheck = strings.TrimRight(pathToCheck, "/")
// Split the current path into parts
parts := strings.Split(currentPath, "/")
// Check if there is at least one part
if len(parts) < 1 {
return false
}
// Get the last part
lastPart := parts[len(parts)-1]
// Compare the last path part with the pathToCheck
return "/"+lastPart == pathToCheck
}
var (
furiganaPattern = regexp.MustCompile(`\[\[rb:\s*(.+?)\s*>\s*(.+?)\s*\]\]`)
chapterPattern = regexp.MustCompile(`\[chapter:\s*(.+?)\s*\]`)
jumpUriPattern = regexp.MustCompile(`\[\[jumpuri:\s*(.+?)\s*>\s*(.+?)\s*\]\]`)
jumpPagePattern = regexp.MustCompile(`\[jump:\s*(\d+?)\s*\]`)
newPagePattern = regexp.MustCompile(`\s*\[newpage\]\s*`)
)
// GetTemplateFunctions returns a map of custom template functions for use in HTML templates
func GetTemplateFunctions() map[string]any {
return map[string]any{
"parseEmojis": func(s string) HTML {
return ParseEmojis(s)
},
"parsePixivRedirect": func(s string) HTML {
return ParsePixivRedirect(s)
},
"escapeString": func(s string) string {
return EscapeString(s)
},
"unescapeHTMLString": func(s string) string {
return UnescapeHTMLString(s)
},
"randomColor": func() string {
return GetRandomColor()
},
"isEmpty": func(s string) bool {
return len(s) < 1
},
"isEmphasize": func(s string) bool {
switch s {
case "R-18", "R-18G":
return true
}
return false
},
"reformatDate": func(s string) string {
if len(s) != 8 {
return s
}
return fmt.Sprintf("%s-%s-%s", s[4:], s[2:4], s[:2])
},
"parseTime": func(date time.Time) string {
return ParseTime(date)
},
"parseTimeCustomFormat": func(date time.Time, format string) string {
return ParseTimeCustomFormat(date, format)
},
"naturalTime": func(date time.Time) string {
return NaturalTime(date)
},
"relativeTime": func(date time.Time) string {
return RelativeTime(date)
},
"createPaginator": func(base, ending string, current_page, max_page, page_margin, dropdown_offset int) PaginationData {
paginationData, err := CreatePaginator(base, ending, current_page, max_page, page_margin, dropdown_offset)
if err != nil {
fmt.Printf("Error creating paginator: %v", err)
// Return an empty PaginationData in case of error
return PaginationData{}
}
return paginationData
},
"joinArtworkIds": func(artworks []core.ArtworkBrief) string {
ids := []string{}
for _, art := range artworks {
ids = append(ids, art.ID)
}
return strings.Join(ids, ",")
},
"stripEmbed": func(s string) string {
// Remove the last 6 characters from the string (assumes "_embed" suffix)
return s[:len(s)-6]
},
"renderNovel": func(s string) HTML {
// Replace furigana markup with HTML ruby tags
furiganaTemplate := `<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>`
s = furiganaPattern.ReplaceAllString(s, furiganaTemplate)
// Replace chapter markup with HTML h2 tags
chapterTemplate := `<h2>$1</h2>`
s = chapterPattern.ReplaceAllString(s, chapterTemplate)
// Replace jump URI markup with HTML anchor tags
jumpUriTemplate := `<a href="$2" target="_blank">$1</a>`
s = jumpUriPattern.ReplaceAllString(s, jumpUriTemplate)
// Replace jump page markup with HTML anchor tags
jumpPageTemplate := `<a href="#$1">To page $1</a>`
s = jumpPagePattern.ReplaceAllString(s, jumpPageTemplate)
// Handle newpage markup
if strings.Contains(s, "[newpage]") {
// Prepend <hr id="1"/> to the page if [newpage] is present
s = `<hr id="1"/>` + s
pageIdx := 1
// Should run before replace `\n` -> `<br />`
s = newPagePattern.ReplaceAllStringFunc(s, func(_ string) string {
pageIdx += 1
return fmt.Sprintf(`<br /><hr id="%d"/>`, pageIdx)
})
}
// Replace newlines with HTML line breaks
s = strings.ReplaceAll(s, "\n", "<br />")
return HTML(s)
},
"novelGenre": GetNovelGenre,
"floor": func(i float64) int {
return int(math.Floor(i))
},
"unfinishedQuery": UnfinishedQuery,
"replaceQuery": ReplaceQuery,
"isFirstPathPart": IsFirstPathPart,
"isLastPathPart": IsLastPathPart,
// TODO: what is AttrGen for
// "AttrGen": SwitchButtonAttributes,
}
}