mirror of
https://github.com/aljazceru/njump.git
synced 2025-12-17 22:34:25 +01:00
various meaningless speedups to render_image.
This commit is contained in:
4
data.go
4
data.go
@@ -38,9 +38,9 @@ type Data struct {
|
|||||||
Kind30818Metadata Kind30818Metadata
|
Kind30818Metadata Kind30818Metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func grabData(ctx context.Context, code string) (Data, error) {
|
func grabData(ctx context.Context, code string, withRelays bool) (Data, error) {
|
||||||
// code can be a nevent or naddr, in which case we try to fetch the associated event
|
// code can be a nevent or naddr, in which case we try to fetch the associated event
|
||||||
event, relays, err := getEvent(ctx, code, true)
|
event, relays, err := getEvent(ctx, code, withRelays)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Data{}, fmt.Errorf("error fetching event: %w", err)
|
return Data{}, fmt.Errorf("error fetching event: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"github.com/go-text/typesetting/language"
|
"github.com/go-text/typesetting/language"
|
||||||
"github.com/go-text/typesetting/opentype/api"
|
"github.com/go-text/typesetting/opentype/api"
|
||||||
"github.com/go-text/typesetting/shaping"
|
"github.com/go-text/typesetting/shaping"
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
"github.com/nbd-wtf/emoji"
|
"github.com/nbd-wtf/emoji"
|
||||||
"github.com/nfnt/resize"
|
"github.com/nfnt/resize"
|
||||||
"github.com/srwiley/rasterx"
|
"github.com/srwiley/rasterx"
|
||||||
@@ -72,6 +73,7 @@ var (
|
|||||||
scriptRanges []ScriptRange
|
scriptRanges []ScriptRange
|
||||||
fontMap [nSupportedScripts]font.Face
|
fontMap [nSupportedScripts]font.Face
|
||||||
emojiFace font.Face
|
emojiFace font.Face
|
||||||
|
dateFont *truetype.Font
|
||||||
|
|
||||||
defaultLanguageMap = [nSupportedScripts]language.Language{
|
defaultLanguageMap = [nSupportedScripts]language.Language{
|
||||||
"en-us",
|
"en-us",
|
||||||
@@ -163,6 +165,9 @@ func initializeImageDrawingStuff() error {
|
|||||||
fontMap[12] = loadFont("fonts/NotoSansKR.ttf")
|
fontMap[12] = loadFont("fonts/NotoSansKR.ttf")
|
||||||
emojiFace = loadFont("fonts/NotoEmoji.ttf")
|
emojiFace = loadFont("fonts/NotoEmoji.ttf")
|
||||||
|
|
||||||
|
fontData, _ := fonts.ReadFile("fonts/NotoSans.ttf")
|
||||||
|
dateFont, _ = truetype.Parse(fontData)
|
||||||
|
|
||||||
// shaper stuff
|
// shaper stuff
|
||||||
emojiFont = harfbuzz.NewFont(emojiFace)
|
emojiFont = harfbuzz.NewFont(emojiFace)
|
||||||
|
|
||||||
@@ -268,8 +273,11 @@ gotScriptIndex:
|
|||||||
return lng, script, direction, face
|
return lng, script, direction, face
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchImageFromURL(url string) (image.Image, error) {
|
func fetchImageFromURL(ctx context.Context, url string) (image.Image, error) {
|
||||||
response, err := http.Get(url)
|
ctx, cancel := context.WithTimeout(ctx, time.Millisecond*350)
|
||||||
|
defer cancel()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
response, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch image from %s: %w", url, err)
|
return nil, fmt.Errorf("failed to fetch image from %s: %w", url, err)
|
||||||
}
|
}
|
||||||
@@ -703,8 +711,8 @@ func drawShapedBlockAt(
|
|||||||
return charsWritten, int(math.Ceil(float64(x)))
|
return charsWritten, int(math.Ceil(float64(x)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func drawImageAt(img draw.Image, imageUrl string, startY int) int {
|
func drawImageAt(ctx context.Context, img draw.Image, imageUrl string, startY int) int {
|
||||||
srcImg, err := fetchImageFromURL(imageUrl)
|
srcImg, err := fetchImageFromURL(ctx, imageUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
@@ -740,7 +748,7 @@ func drawVideoAt(img draw.Image, videoUrl string, startY int) int {
|
|||||||
width := img.Bounds().Dx()
|
width := img.Bounds().Dx()
|
||||||
resizedFrame := resize.Resize(uint(width), 0, imgData, resize.Lanczos3)
|
resizedFrame := resize.Resize(uint(width), 0, imgData, resize.Lanczos3)
|
||||||
|
|
||||||
// Draw the play icon on the center of the frame
|
// draw the play icon on the center of the frame
|
||||||
videoFrame := image.NewRGBA(resizedFrame.Bounds())
|
videoFrame := image.NewRGBA(resizedFrame.Bounds())
|
||||||
draw.Draw(videoFrame, videoFrame.Bounds(), resizedFrame, image.Point{}, draw.Src)
|
draw.Draw(videoFrame, videoFrame.Bounds(), resizedFrame, image.Point{}, draw.Src)
|
||||||
iconFile, _ := static.ReadFile("static/play.png")
|
iconFile, _ := static.ReadFile("static/play.png")
|
||||||
@@ -754,16 +762,16 @@ func drawVideoAt(img draw.Image, videoUrl string, startY int) int {
|
|||||||
destRect := image.Rect(posX, posY, posX+iconWidth, posY+iconHeight)
|
destRect := image.Rect(posX, posY, posX+iconWidth, posY+iconHeight)
|
||||||
draw.Draw(videoFrame, destRect, stampImg, image.Point{}, draw.Over)
|
draw.Draw(videoFrame, destRect, stampImg, image.Point{}, draw.Over)
|
||||||
|
|
||||||
// Draw the modified video frame onto the main canvas
|
// draw the modified video frame onto the main canvas
|
||||||
destRect = image.Rect(0, startY, img.Bounds().Dx(), startY+videoFrame.Bounds().Dy())
|
destRect = image.Rect(0, startY, img.Bounds().Dx(), startY+videoFrame.Bounds().Dy())
|
||||||
draw.Draw(img, destRect, videoFrame, image.Point{}, draw.Src)
|
draw.Draw(img, destRect, videoFrame, image.Point{}, draw.Src)
|
||||||
|
|
||||||
return startY + videoFrame.Bounds().Dy()
|
return startY + videoFrame.Bounds().Dy()
|
||||||
}
|
}
|
||||||
|
|
||||||
func drawMediaAt(img draw.Image, mediaUrl string, startY int) int {
|
func drawMediaAt(ctx context.Context, img draw.Image, mediaUrl string, startY int) int {
|
||||||
if isImageURL(mediaUrl) {
|
if isImageURL(mediaUrl) {
|
||||||
return drawImageAt(img, mediaUrl, startY)
|
return drawImageAt(ctx, img, mediaUrl, startY)
|
||||||
} else if isVideoURL(mediaUrl) {
|
} else if isVideoURL(mediaUrl) {
|
||||||
return drawVideoAt(img, mediaUrl, startY)
|
return drawVideoAt(img, mediaUrl, startY)
|
||||||
} else {
|
} else {
|
||||||
@@ -772,17 +780,12 @@ func drawMediaAt(img draw.Image, mediaUrl string, startY int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isImageURL(input string) bool {
|
func isImageURL(input string) bool {
|
||||||
trimmedURL := strings.TrimSpace(input)
|
parsedURL, err := url.Parse(input)
|
||||||
if trimmedURL == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedURL, err := url.Parse(trimmedURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false // Unable to parse URL, consider it non-image URL
|
return false // unable to parse URL, consider it non-image URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the path (excluding query string and hash fragment)
|
// extract the path (excluding query string and hash fragment)
|
||||||
path := parsedURL.Path
|
path := parsedURL.Path
|
||||||
imageExtensions := []string{".jpg", ".jpeg", ".png", ".gif", ".bmp"}
|
imageExtensions := []string{".jpg", ".jpeg", ".png", ".gif", ".bmp"}
|
||||||
for _, ext := range imageExtensions {
|
for _, ext := range imageExtensions {
|
||||||
@@ -794,14 +797,9 @@ func isImageURL(input string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isVideoURL(input string) bool {
|
func isVideoURL(input string) bool {
|
||||||
trimmedURL := strings.TrimSpace(input)
|
parsedURL, err := url.Parse(input)
|
||||||
if trimmedURL == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedURL, err := url.Parse(trimmedURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false // Unable to parse URL, consider it non-image URL
|
return false // unable to parse URL, consider it non-video URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the path (excluding query string and hash fragment)
|
// Extract the path (excluding query string and hash fragment)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func renderOEmbed(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
host := r.Header.Get("X-Forwarded-Host")
|
host := r.Header.Get("X-Forwarded-Host")
|
||||||
|
|
||||||
data, err := grabData(ctx, code)
|
data, err := grabData(ctx, code, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.Header().Set("Cache-Control", "max-age=180")
|
w.Header().Set("Cache-Control", "max-age=180")
|
||||||
log.Warn().Err(err).Str("code", code).Msg("event not found on oembed")
|
log.Warn().Err(err).Str("code", code).Msg("event not found on oembed")
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func renderEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get data for this event
|
// get data for this event
|
||||||
data, err := grabData(ctx, code)
|
data, err := grabData(ctx, code, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.Header().Set("Cache-Control", "max-age=60")
|
w.Header().Set("Cache-Control", "max-age=60")
|
||||||
log.Warn().Err(err).Str("code", code).Msg("event not found on render_event")
|
log.Warn().Err(err).Str("code", code).Msg("event not found on render_event")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
@@ -45,13 +46,13 @@ func renderImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim fake extensions
|
// trim fake extensions
|
||||||
extensions := []string{".png", ".jpg", ".jpeg"}
|
extensions := []string{".png", ".jpg", ".jpeg"}
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
code = strings.TrimSuffix(code, ext)
|
code = strings.TrimSuffix(code, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := grabData(ctx, code)
|
data, err := grabData(ctx, code, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "error fetching event: "+err.Error(), http.StatusNotFound)
|
http.Error(w, "error fetching event: "+err.Error(), http.StatusNotFound)
|
||||||
log.Warn().Err(err).Str("code", code).Msg("event not found on render_image")
|
log.Warn().Err(err).Str("code", code).Msg("event not found on render_image")
|
||||||
@@ -64,6 +65,9 @@ func renderImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
content = strings.Replace(content, "\t", " ", -1)
|
content = strings.Replace(content, "\t", " ", -1)
|
||||||
content = strings.Replace(content, "\r", "", -1)
|
content = strings.Replace(content, "\r", "", -1)
|
||||||
content = shortenURLs(content, true)
|
content = shortenURLs(content, true)
|
||||||
|
if len(content) > 650 {
|
||||||
|
content = content[0:650]
|
||||||
|
}
|
||||||
|
|
||||||
// this turns the raw event.Content into a series of lines ready to drawn
|
// this turns the raw event.Content into a series of lines ready to drawn
|
||||||
paragraphs := replaceUserReferencesWithNames(ctx,
|
paragraphs := replaceUserReferencesWithNames(ctx,
|
||||||
@@ -73,7 +77,7 @@ func renderImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
string(INVISIBLE_SPACE),
|
string(INVISIBLE_SPACE),
|
||||||
)
|
)
|
||||||
|
|
||||||
img, err := drawImage(paragraphs, getPreviewStyle(r), data.event.author, data.createdAt)
|
img, err := drawImage(ctx, paragraphs, getPreviewStyle(r), data.event.author, data.event.CreatedAt.Time())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("failed to draw paragraphs as image")
|
log.Warn().Err(err).Msg("failed to draw paragraphs as image")
|
||||||
http.Error(w, "error writing image!", 500)
|
http.Error(w, "error writing image!", 500)
|
||||||
@@ -90,10 +94,11 @@ func renderImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func drawImage(
|
func drawImage(
|
||||||
|
ctx context.Context,
|
||||||
paragraphs []string,
|
paragraphs []string,
|
||||||
style Style,
|
style Style,
|
||||||
metadata sdk.ProfileMetadata,
|
metadata sdk.ProfileMetadata,
|
||||||
date string,
|
date time.Time,
|
||||||
) (image image.Image, err error) {
|
) (image image.Image, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
@@ -146,13 +151,12 @@ func drawImage(
|
|||||||
addedSize = int(200.0 / largeness * zoom)
|
addedSize = int(200.0 / largeness * zoom)
|
||||||
textFontSize = int(float64(fontSize + addedSize))
|
textFontSize = int(float64(fontSize + addedSize))
|
||||||
}
|
}
|
||||||
textImg, overflowingText := drawParagraphs(paragraphs, textFontSize, width-paddingLeft*2, height-20-barHeight)
|
textImg, overflowingText := drawParagraphs(ctx,
|
||||||
|
paragraphs, textFontSize, width-paddingLeft*2, height-20-barHeight)
|
||||||
img.DrawImage(textImg, paddingLeft, 20)
|
img.DrawImage(textImg, paddingLeft, 20)
|
||||||
|
|
||||||
// font for writing the date
|
// font for writing the date
|
||||||
fontData, _ := fonts.ReadFile("fonts/NotoSans.ttf")
|
img.SetFontFace(truetype.NewFace(dateFont, &truetype.Options{
|
||||||
ttf, _ := truetype.Parse(fontData)
|
|
||||||
img.SetFontFace(truetype.NewFace(ttf, &truetype.Options{
|
|
||||||
Size: (6 * barScale),
|
Size: (6 * barScale),
|
||||||
DPI: 260,
|
DPI: 260,
|
||||||
Hinting: xfont.HintingFull,
|
Hinting: xfont.HintingFull,
|
||||||
@@ -178,7 +182,7 @@ func drawImage(
|
|||||||
authorTextX := paddingLeft
|
authorTextX := paddingLeft
|
||||||
picHeight := barHeight - 20
|
picHeight := barHeight - 20
|
||||||
if metadata.Picture != "" {
|
if metadata.Picture != "" {
|
||||||
authorImage, err := fetchImageFromURL(metadata.Picture)
|
authorImage, err := fetchImageFromURL(ctx, metadata.Picture)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
resizedAuthorImage := resize.Resize(uint(barHeight-20), uint(picHeight), roundImage(cropToSquare(authorImage)), resize.Lanczos3)
|
resizedAuthorImage := resize.Resize(uint(barHeight-20), uint(picHeight), roundImage(cropToSquare(authorImage)), resize.Lanczos3)
|
||||||
img.DrawImage(resizedAuthorImage, paddingLeft, height-barHeight+10)
|
img.DrawImage(resizedAuthorImage, paddingLeft, height-barHeight+10)
|
||||||
@@ -195,7 +199,7 @@ func drawImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
img.SetColor(color.White)
|
img.SetColor(color.White)
|
||||||
textImg, _ = drawParagraphs([]string{metadata.ShortName()}, fontSize, width, barHeight)
|
textImg, _ = drawParagraphs(ctx, []string{metadata.ShortName()}, fontSize, width, barHeight)
|
||||||
img.DrawImage(textImg, authorTextX, authorTextY)
|
img.DrawImage(textImg, authorTextX, authorTextY)
|
||||||
|
|
||||||
// a gradient to cover too long names
|
// a gradient to cover too long names
|
||||||
@@ -221,17 +225,15 @@ func drawImage(
|
|||||||
stampY := height - barHeight + (barHeight-int(stampHeight))/2
|
stampY := height - barHeight + (barHeight-int(stampHeight))/2
|
||||||
img.DrawImage(resizedStampImg, stampX, stampY)
|
img.DrawImage(resizedStampImg, stampX, stampY)
|
||||||
|
|
||||||
// Draw event date
|
// draw event date
|
||||||
layout := "2006-01-02 15:04:05"
|
formattedDate := date.Format("Jan 02, 2006")
|
||||||
parsedTime, _ := time.Parse(layout, date)
|
|
||||||
formattedDate := parsedTime.Format("Jan 02, 2006")
|
|
||||||
img.SetColor(color.RGBA{160, 160, 160, 255})
|
img.SetColor(color.RGBA{160, 160, 160, 255})
|
||||||
img.DrawStringWrapped(formattedDate, float64(width-paddingLeft-int(stampWidth)-250), float64(height-barHeight+(barHeight-int(stampHeight))/2)+3, 0, 0, float64(240), 1.5, gg.AlignRight)
|
img.DrawStringWrapped(formattedDate, float64(width-paddingLeft-int(stampWidth)-250), float64(height-barHeight+(barHeight-int(stampHeight))/2)+3, 0, 0, float64(240), 1.5, gg.AlignRight)
|
||||||
|
|
||||||
return img.Image(), nil
|
return img.Image(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func drawParagraphs(paragraphs []string, fontSize int, width, height int) (image.Image, bool) {
|
func drawParagraphs(ctx context.Context, paragraphs []string, fontSize int, width, height int) (image.Image, bool) {
|
||||||
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||||
|
|
||||||
lineNumber := 1
|
lineNumber := 1
|
||||||
@@ -239,16 +241,23 @@ func drawParagraphs(paragraphs []string, fontSize int, width, height int) (image
|
|||||||
for i := 0; i < len(paragraphs); i++ {
|
for i := 0; i < len(paragraphs); i++ {
|
||||||
paragraph := paragraphs[i]
|
paragraph := paragraphs[i]
|
||||||
|
|
||||||
// Skip empty lines if the next element is an image
|
if paragraph == "" {
|
||||||
if paragraph == "" && len(paragraphs) > i+1 && isMediaURL(paragraphs[i+1]) {
|
// do not draw lines if the next element is an image
|
||||||
continue
|
if len(paragraphs) > i+1 && isMediaURL(paragraphs[i+1]) {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// just move us down a little then jump to the next line
|
||||||
|
lineNumber++
|
||||||
|
yPos = yPos + fontSize*12/10
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isMediaURL(paragraph) {
|
if isMediaURL(paragraph) {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
yPos = 0
|
yPos = 0
|
||||||
}
|
}
|
||||||
next := drawMediaAt(img, paragraph, yPos)
|
next := drawMediaAt(ctx, img, paragraph, yPos)
|
||||||
if next != -1 {
|
if next != -1 {
|
||||||
yPos = next
|
yPos = next
|
||||||
// this means the media picture was successfully drawn
|
// this means the media picture was successfully drawn
|
||||||
@@ -269,7 +278,7 @@ func drawParagraphs(paragraphs []string, fontSize int, width, height int) (image
|
|||||||
|
|
||||||
totalCharsWritten := 0
|
totalCharsWritten := 0
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
for _, out := range line {
|
for _, out := range line { // this iteration is useless because there is always just one line
|
||||||
charsWritten, _ := drawShapedBlockAt(
|
charsWritten, _ := drawShapedBlockAt(
|
||||||
img,
|
img,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
|||||||
22
utils.go
22
utils.go
@@ -264,20 +264,22 @@ func getNameFromNip19(ctx context.Context, nip19code string) (string, bool) {
|
|||||||
// replaces an npub/nprofile with the name of the author, if possible.
|
// replaces an npub/nprofile with the name of the author, if possible.
|
||||||
// meant to be used when plaintext is expected, not formatted HTML.
|
// meant to be used when plaintext is expected, not formatted HTML.
|
||||||
func replaceUserReferencesWithNames(ctx context.Context, input []string, prefix string) []string {
|
func replaceUserReferencesWithNames(ctx context.Context, input []string, prefix string) []string {
|
||||||
// Match and replace npup1 or nprofile1
|
// match and replace npup1 or nprofile1
|
||||||
ctx, cancel := context.WithTimeout(ctx, time.Second*3)
|
ctx, cancel := context.WithTimeout(ctx, time.Second*3)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
for i, line := range input {
|
for i, line := range input {
|
||||||
input[i] = nostrNpubNprofileMatcher.ReplaceAllStringFunc(line, func(match string) string {
|
input[i] = strings.TrimSpace(
|
||||||
submatch := nostrNpubNprofileMatcher.FindStringSubmatch(match)
|
nostrNpubNprofileMatcher.ReplaceAllStringFunc(line, func(match string) string {
|
||||||
nip19code := submatch[1]
|
submatch := nostrNpubNprofileMatcher.FindStringSubmatch(match)
|
||||||
name, ok := getNameFromNip19(ctx, nip19code)
|
nip19code := submatch[1]
|
||||||
if ok {
|
name, ok := getNameFromNip19(ctx, nip19code)
|
||||||
return prefix + strings.ReplaceAll(name, " ", string(THIN_SPACE))
|
if ok {
|
||||||
}
|
return prefix + strings.ReplaceAll(name, " ", string(THIN_SPACE))
|
||||||
return nip19code[0:10] + "…" + nip19code[len(nip19code)-5:]
|
}
|
||||||
})
|
return nip19code[0:10] + "…" + nip19code[len(nip19code)-5:]
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user