mirror of
https://codeberg.org/VnPower/PixivFE
synced 2024-12-06 19:16:23 +01:00
539 lines
15 KiB
Go
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,
|
|
}
|
|
}
|