commit 92b0a902caa58a3e2d6037c9ec0fcbfcf4db672d Author: silva guimaraes Date: Thu Jul 3 17:12:05 2025 -0300 commit inicial diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..bf9080d --- /dev/null +++ b/.air.toml @@ -0,0 +1,51 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "css", "js"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94a4adc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +foo.html +tmp/* +indexStyle.css diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d71830d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM golang:1.23 as first + +COPY ./* . +RUN go build -o main -v . diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8360212 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ + + + +run: + go mod tidy + go run -v . + +dev: + air diff --git a/gallery/gallery.go b/gallery/gallery.go new file mode 100644 index 0000000..45366be --- /dev/null +++ b/gallery/gallery.go @@ -0,0 +1,258 @@ +package gallery + +import ( + "crypto/md5" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" +) + +type Gallery struct { + images []string + uuid string + title string + jpTitle string + tags []Tag + source Source + CTime time.Time + artists []string + groups []string + parodies []string +} + +func (g *Gallery) Name() string { return g.title } +func (g *Gallery) JpName() string { return g.jpTitle } +func (g *Gallery) Uuid() string { return g.uuid } +func (g *Gallery) Images() []string { return g.images } +func (g *Gallery) Tags() []Tag { return g.tags } +func (g *Gallery) Source() Source { return g.source } +func (g *Gallery) Artists() []string { return g.artists } +func (g *Gallery) Groups() []string { return g.groups } +func (g *Gallery) Parodies() []string { return g.parodies } + +func newGallery( + images []string, uuid string, title string, jpTitle string, tags []Tag, + source Source, CTime time.Time, artists []string, groups []string, parodies []string, +) Gallery { + return Gallery{ + images: images, + uuid: uuid, + title: title, + jpTitle: jpTitle, + tags: tags, + source: source, + CTime: CTime, + artists: artists, + groups: groups, + parodies: parodies, + } +} + +type Source string + +const ( + Nhentai Source = "nhentai" + Exhentai = "exhentai" + Hitomi = "hitomi" +) + +type Gender string + +const ( + Male Gender = "male" + Female Gender = "female" + Any Gender = "any" +) + +type Tag struct { + Name string + Sex Gender +} + +func newTag(name string, sex Gender) Tag { + return Tag{ + Name: strings.ToLower(name), + Sex: sex, + } +} + +func genUuid(title string) string { + return fmt.Sprintf("%x", md5.Sum([]byte(title))) +} + +func filterImages(root string, galleryDir []fs.DirEntry) []string { + var paths []string + for _, file := range galleryDir { + if file.IsDir() { + continue + } + name := file.Name() + + // fixme + isJpeg := strings.HasSuffix(name, ".jpeg") || strings.HasSuffix(name, ".jpg") + isPng := strings.HasSuffix(name, ".png") + isWebp := strings.HasSuffix(name, ".webp") + isJson := strings.HasSuffix(name, ".json") + + if isJson { + continue + } + if isJpeg || isPng || isWebp { + paths = append(paths, filepath.Join(root, name)) + } + } + return paths +} + +func NewNhentaiGallery(infoBinary []byte, root string, galleryDir []fs.DirEntry) (Gallery, error) { + var data struct { + Title string + Title_en string `json:"title_en"` + Title_ja string + // Gallery_id string `json:"gallery_id"` // troublesome + Media_id int + Date int64 + Scanlator string + Artist, Group, Parody, Characters, Tags []string + Type, Lang, Language, Category, Subcategory string + Count int + } + err := json.Unmarshal(infoBinary, &data) + if err != nil { + return Gallery{}, err + } + var tags []Tag + for _, t := range data.Tags { + tags = append(tags, newTag(t, Any)) + } + images := filterImages(root, galleryDir) + d, err := os.Stat(root) + if err != nil { + return Gallery{}, err + } + g := newGallery( + images, genUuid(data.Title), data.Title, data.Title_ja, tags, + Nhentai, d.ModTime(), data.Artist, data.Group, data.Parody, + ) + return g, nil +} + +func NewExhentaiGallery(infoBinary []byte, root string, galleryDir []fs.DirEntry) (Gallery, error) { + var data struct { + Gid int + Token string + Thumb string + Title string + Title_jpn string + Eh_category string + Uploader string + Date string + Parent string + Language string + Filecount string + Favorites string + Rating string + Torrentcount string + Lang string + Category string + Subcategory string + Expunged bool + Filesize int + Tags []string + } + err := json.Unmarshal(infoBinary, &data) + if err != nil { + return Gallery{}, err + } + var ( + tags []Tag + artists []string + groups []string + parodies []string + ) + for _, t := range data.Tags { + switch { + case strings.HasPrefix(t, "female:"): + tags = append(tags, newTag(t[7:], Female)) + case strings.HasPrefix(t, "male:"): + tags = append(tags, newTag(t[5:], Male)) + case strings.HasPrefix(t, "artist:"): + artists = append(artists, t[7:]) + case strings.HasPrefix(t, "group:"): + groups = append(groups, t[6:]) + case strings.HasPrefix(t, "parody:"): + parodies = append(parodies, t[7:]) + } + } + d, err := os.Stat(root) + if err != nil { + return Gallery{}, err + } + g := newGallery( + filterImages(root, galleryDir), genUuid(data.Title), data.Title, data.Title_jpn, + tags, Exhentai, d.ModTime(), artists, groups, parodies, + ) + return g, nil +} + +func NewHitomiGallery(infoBinary []byte, root string, galleryDir []fs.DirEntry) (Gallery, error) { + var data struct { + Gallery_id int + Title string + Title_jpn string + Type string + Language string + Lang string + Date string + Tags []string + Artist []string + Group []string + Parody []string + Characters []string + Count int + Category string + Subcategory string + } + err := json.Unmarshal(infoBinary, &data) + if err != nil { + return Gallery{}, err + } + var tags []Tag + for _, t := range data.Tags { + sex := Any + name := t + if strings.HasSuffix(t, "♀") { + sex = Female + name = t[:len(t)-3] + } else if strings.HasSuffix(t, "♂") { + sex = Male + name = t[:len(t)-3] + } + tags = append(tags, newTag(name, sex)) + } + d, err := os.Stat(root) + if err != nil { + return Gallery{}, err + } + g := newGallery( + filterImages(root, galleryDir), genUuid(data.Title), data.Title, data.Title_jpn, + tags, Hitomi, d.ModTime(), data.Artist, data.Group, data.Parody, + ) + return g, nil +} + +func NewGallery(source Source, infoBinary []byte, root string, galleryDir []fs.DirEntry) (Gallery, error) { + switch source { + case Nhentai: + return NewNhentaiGallery(infoBinary, root, galleryDir) + case Exhentai: + return NewExhentaiGallery(infoBinary, root, galleryDir) + case Hitomi: + return NewHitomiGallery(infoBinary, root, galleryDir) + } + panic(source) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5609b87 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module goreader + +go 1.23.0 + +require ( + github.com/lithammer/fuzzysearch v1.1.8 + github.com/silva-guimaraes/gtag v0.4.0 + golang.org/x/image v0.20.0 +) + +require golang.org/x/text v0.18.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6eb8668 --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/silva-guimaraes/gtag v0.4.0 h1:xFh95rhFU0y+b6EoW9OXs6JfaGogDpqiIY3gYiiulaA= +github.com/silva-guimaraes/gtag v0.4.0/go.mod h1:AqOpcUI+Lsu4mCKC8o0S4zr3wd6m8VHj6tKqmnyV/P0= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= +golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..55be14e --- /dev/null +++ b/logging.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "net/http" + "time" +) + +type wrappedWriter struct { + http.ResponseWriter + statusCode int +} + +func (w *wrappedWriter) WriteHeader(statusCode int) { + w.ResponseWriter.WriteHeader(statusCode) + w.statusCode = statusCode +} + +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + wrapped := &wrappedWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + } + + next.ServeHTTP(wrapped, r) + + log.Printf("%v %5v %12v %v", wrapped.statusCode, r.Method, time.Since(start), r.URL.Path) + }) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f0d14b6 --- /dev/null +++ b/main.go @@ -0,0 +1,369 @@ +package main + +import ( + "crypto/rand" + _ "embed" + "errors" + "fmt" + "io" + "io/fs" + "log" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + + "goreader/gallery" + "goreader/state" + view "goreader/views" + + "image" + _ "image/gif" + _ "image/jpeg" + "image/png" + + "golang.org/x/image/draw" + + "github.com/lithammer/fuzzysearch/fuzzy" +) + +var searchGalleriesError = fmt.Errorf("failure reading archives") + +func searchGalleryDl(state *state.State) error { + defer func() { + log.Println("done searching galleries") + state.Done = true + }() + sourceDirectories, err := os.ReadDir(state.Root) + if err != nil { + return errors.Join(searchGalleriesError, err) + } + for _, source := range sourceDirectories { + log.Println("attemping", source) + if !source.IsDir() { + continue + } + sourcePath := filepath.Join(state.Root, source.Name()) + sourceDir, err := os.ReadDir(sourcePath) + if err != nil { + return errors.Join(searchGalleriesError, err) + } + for _, galleryDir := range sourceDir { + + galleryPath := filepath.Join(sourcePath, galleryDir.Name()) + galleryFiles, err := os.ReadDir(galleryPath) + if err != nil { + log.Println(err) + continue + } + + infoIndex := slices.IndexFunc(galleryFiles, func(a fs.DirEntry) bool { + return a.Name() == "info.json" + }) + if infoIndex == -1 { + log.Println(fmt.Errorf("no index file: %s", galleryPath)) + continue + } + infoPath := filepath.Join(galleryPath, "info.json") + infoBinary, err := os.ReadFile(infoPath) + if err != nil { + log.Println(err) + continue + } + + var from gallery.Source + switch source.Name() { + case "nhentai": + from = gallery.Nhentai + case "exhentai": + from = gallery.Exhentai + case "hitomi": + from = gallery.Hitomi + default: + log.Printf("unrecognized source: \"%s\"\n", source.Name()) + continue + } + + info, err := gallery.NewGallery(from, infoBinary, galleryPath, galleryFiles) + if err != nil { + log.Println(err) + continue + } + + err = state.AddGallery(info) + if os.IsNotExist(err) { + log.Println(err) + } else if err != nil { + panic(err) + } + } + } + return nil +} + +func main() { + + state := &state.State{ + Port: ":2323", + Root: "/home/xi/seed/gallery-dl/", + Galleries: make([]gallery.Gallery, 0, 50), + UniqueTags: make(map[gallery.Tag]int), + UniqueArtists: make(map[string]int), + UniqueGroups: make(map[string]int), + UniqueParodies: make(map[string]int), + Filtered: make([]gallery.Tag, 0), + } + + fmt.Printf("live at http://localhost%s\n", state.Port) + + server := &http.Server{ + Addr: state.Port, + Handler: Logging(http.DefaultServeMux), + } + + ln, err := net.Listen("tcp", server.Addr) + if err != nil { + panic(err) + } + + go func() { + // defer func() { + // if err := recover(); err != nil { + // e, ok := err.(error) + // if !ok { + // log.Println(err) + // } else { + // log.Println(e.Error()) + // } + // } + // }() + err := searchGalleryDl(state) + if err != nil { + log.Println(err) + log.Println() + } + }() + + cacheDir, err := os.UserCacheDir() + if err != nil { + panic(err) + } + state.CacheDir = filepath.Join(cacheDir, "omakase") + err = os.MkdirAll(state.CacheDir, 0o755) + if err != nil { + panic(err) + } + + log.Println("cache directory path:", state.CacheDir) + + http.DefaultServeMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "index.html" { + log.Println("not found:", r.URL.Path) + http.NotFound(w, r) + return + } + page, err := strconv.Atoi(r.URL.Query().Get("page")) + if err != nil { + page = 1 + } + view.Index(state, page).Render(w) + }) + + // http.DefaultServeMux.HandleFunc("/static/styles.css", func(w http.ResponseWriter, r *http.Request) { + // file, err := os.Open("./views/static/styles.css") + // if err != nil { + // panic(err) + // } + // io.Copy(w, file) + // }) + + http.DefaultServeMux.HandleFunc("/read/{uuid}", func(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("uuid") + if uuid == "" { + panic(fmt.Errorf("could not get gallery UUID. empty string.")) + } + page := "1" + pageString := r.URL.Query().Get("page") + if pageString != "" { + page = pageString + } + found, err := state.FindTitle(uuid) + if err != nil { + panic(err) + } + _ = view.Reader(found, page).Render(w) + }) + + http.DefaultServeMux.HandleFunc("/random", func(w http.ResponseWriter, r *http.Request) { + f := state.FilterGalleries() + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(f)))) + if err != nil { + panic(err) + } + http.Redirect(w, r, fmt.Sprintf("/read/%s", f[n.Int64()].Uuid()), http.StatusFound) + }) + + http.DefaultServeMux.HandleFunc("/page/{uuid}/{page}", func(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("uuid") + pageString := r.PathValue("page") + if uuid == "" || pageString == "" { + panic(fmt.Errorf("uuid or page could not be found: %s", r.URL.Path)) + } + page, err := strconv.Atoi(pageString) + if err != nil { + panic(err) + } + found, err := state.FindTitle(uuid) + if err != nil { + panic(err) + } + image, err := os.Open(found.Images()[page-1]) + if err != nil { + panic(err) + } + _, _ = io.Copy(w, image) + }) + + http.DefaultServeMux.HandleFunc("/thumb/{uuid}", func(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("uuid") + if uuid == "" { + panic(fmt.Errorf("uuid could not be found: %s", r.URL.Path)) + } + found, err := state.FindTitle(uuid) + if err != nil { + panic(err) + } + if len(found.Images()) == 0 { + panic(fmt.Errorf("why does gallery contain no images? \"%s\"", uuid)) + } + imageCacheFilepath := filepath.Join(state.CacheDir, uuid) + imageCache, err := os.Open(imageCacheFilepath) + if err == nil { + _, _ = io.Copy(w, imageCache) + imageCache.Close() + return + } + if len(found.Images()) == 0 { + panic(fmt.Errorf("why does gallery contain no images? \"%s\"", uuid)) + } + coverImagePath := found.Images()[0] + imageStream, err := os.Open(coverImagePath) + if err != nil { + panic(err) + } + defer imageStream.Close() + sourceImage, _, err := image.Decode(imageStream) + if err != nil { + panic(err) + } + sourceSize := sourceImage.Bounds().Size() + aspectRatio := float64(sourceSize.X) / float64(sourceSize.Y) + destWidth := 148 + destHeight := int(float64(destWidth) / aspectRatio) + scaler := draw.BiLinear.NewScaler(destWidth, destHeight, sourceSize.X, sourceSize.Y) + destRect := image.Rect(0, 0, destWidth, destHeight) + destImage := image.NewRGBA(destRect) + scaler.Scale(destImage, destRect, sourceImage, sourceImage.Bounds(), draw.Src, nil) + imageCache, err = os.Create(imageCacheFilepath) + if err != nil { + panic(err) + } + defer imageCache.Close() + _ = png.Encode(io.MultiWriter(imageCache, w), destImage) + }) + + // http.DefaultServeMux.HandleFunc("POST /filter/{tag}/{sex}", func(w http.ResponseWriter, r *http.Request) { + // tagLookup := r.PathValue("tag") + // if tagLookup == "" { + // panic(fmt.Errorf("tag could not be found: %s", r.URL.Path)) + // } + // if tagLookup == "clear" { + // state.Filtered = []gallery.Tag{} + // view.GalleriesListing(state.Galleries).Render(w) + // return + // } + // tagSexLookup := r.PathValue("sex") + // if tagSexLookup == "" { + // panic(fmt.Errorf("tag sex could not be found: %s", r.URL.Path)) + // } + // sex := gallery.ToGender(tagSexLookup) + // i := slices.IndexFunc(state.TagKeys, func(a gallery.Tag) bool { + // return a.Name == tagLookup && a.Sex == sex + // }) + // if i < 0 { + // panic(fmt.Errorf("tag could not be found: %s", r.URL.Path)) + // } + // tag := state.TagKeys[i] + // i = slices.Index(state.Filtered, tag) + // if i < 0 { + // state.Filtered = append(state.Filtered, tag) + // } else { + // state.Filtered = slices.Delete(state.Filtered, i, i+1) + // } + // view.GalleriesListing(state.FilterGalleries()).Render(w) + // }) + + http.DefaultServeMux.HandleFunc("GET /stats", func(w http.ResponseWriter, r *http.Request) { + view.Stats(state).Render(w) + }) + + http.DefaultServeMux.HandleFunc("GET /details/{uuid}", func(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("uuid") + if uuid == "" { + panic(fmt.Errorf("uuid could not be found: %s", r.URL.Path)) + } + g, err := state.FindTitle(uuid) + if err != nil { + panic(err) + } + view.InspectorInfo(g, state).Render(w) + }) + + // FIXME + http.DefaultServeMux.HandleFunc("POST /search", func(w http.ResponseWriter, r *http.Request) { + query := r.FormValue("search") + log.Println("search form result:", query) + if query == "" { + view.SearchResults(state.Galleries, 0).Render(w) + return + } + // ranks := fuzzy.RankFindFold(search, state.GalleryNames) + var ranks fuzzy.Ranks + for i, g := range state.Galleries { + en := fuzzy.RankMatchFold(query, g.Name()) + rank := fuzzy.Rank{ + Source: query, + Target: g.JpName(), + Distance: en, + OriginalIndex: i, + } + if jp := fuzzy.RankMatchFold(query, g.JpName()); jp >= 0 && jp < en { + rank = fuzzy.Rank{ + Source: query, + Target: g.JpName(), + Distance: jp, + OriginalIndex: i, + } + } + if rank.Distance < 0 { + continue + } + ranks = append(ranks, rank) + } + sort.Sort(ranks) + var galleries []gallery.Gallery + for i, r := range ranks { + if i == 30 { + break + } + galleries = append(galleries, state.Galleries[r.OriginalIndex]) + } + view.SearchResults(galleries, 0).Render(w) + }) + + log.Fatal(server.Serve(ln)) +} diff --git a/state/state.go b/state/state.go new file mode 100644 index 0000000..d5f46c2 --- /dev/null +++ b/state/state.go @@ -0,0 +1,76 @@ +package state + +import ( + "fmt" + "goreader/gallery" + "maps" + "slices" + + _ "golang.org/x/image/webp" +) + +type State struct { + Port string + Root string + CacheDir string + Galleries []gallery.Gallery + GalleryNames []string + UniqueTags map[gallery.Tag]int + UniqueArtists map[string]int + UniqueGroups map[string]int + UniqueParodies map[string]int + TagKeys []gallery.Tag + Filtered []gallery.Tag + Done bool +} + +func (s *State) AddGallery(g gallery.Gallery) error { + s.Galleries = append(s.Galleries, g) + s.GalleryNames = append(s.GalleryNames, g.Name()) + slices.SortFunc(s.Galleries, func(a, b gallery.Gallery) int { + return a.CTime.Compare(b.CTime) + + }) + for _, tag := range g.Tags() { + s.UniqueTags[tag]++ + } + for _, artist := range g.Artists() { + s.UniqueArtists[artist]++ + } + for _, group := range g.Groups() { + s.UniqueGroups[group]++ + } + for _, parody := range g.Parodies() { + s.UniqueParodies[parody]++ + } + s.TagKeys = slices.Collect(maps.Keys(s.UniqueTags)) + return nil +} + +func (s *State) FilterGalleries() []gallery.Gallery { + var ret []gallery.Gallery + for _, g := range s.Galleries { + galleryTags := g.Tags() + count := 0 + for _, f := range s.Filtered { + if i := slices.Index(galleryTags, f); i < 0 { + break + } + count++ + } + if len(s.Filtered) == count { + ret = append(ret, g) + } + } + return ret +} + +func (s *State) FindTitle(uuid string) (gallery.Gallery, error) { + index := slices.IndexFunc(s.Galleries, func(g gallery.Gallery) bool { + return g.Uuid() == uuid + }) + if index == -1 { + return gallery.Gallery{}, fmt.Errorf("gallery not found") + } + return s.Galleries[index], nil +} diff --git a/views/index.go b/views/index.go new file mode 100644 index 0000000..eeee4af --- /dev/null +++ b/views/index.go @@ -0,0 +1,256 @@ +package view + +import ( + _ "embed" + "fmt" + "goreader/gallery" + "goreader/state" + "strconv" + + "github.com/silva-guimaraes/gtag" +) + +//go:embed static/styles.css +var styles string + +//go:embed static/reader.js +var readerJavascript string + +//go:embed static/index.js +var indexJavascript string + +//go:embed static/hotkeys.min.js +var hotkeys string + +//go:embed static/htmx.min.js +var htmx string + +func tag(t gallery.Tag, state *state.State) *gtag.Tag { + gender := "X" + backgroundClass := "any" + if t.Sex == gallery.Male { + gender = "M" + backgroundClass = "male" + } + if t.Sex == gallery.Female { + gender = "F" + backgroundClass = "female" + } + d := gtag.New("a"). + Href("javascript:void(0)"). + Class("tag"). + SetAttr("hx-post", fmt.Sprintf("/filter/%s/%s", t.Name, t.Sex)). + SetAttr("hx-target", "#listing"). + SetAttr("hx-swap", "outerHTML") + { + d.Tag("span").Text(gender).Class("name", backgroundClass) + d.Tag("span").Text(t.Name).Class("name") + d.Tag("span").Text(strconv.Itoa(state.UniqueTags[t])).Class("name", backgroundClass) + } + return d +} + +func thumbnail(src string) *gtag.Tag { + return gtag.NewVoid("img").SetAttr("src", src).SetAttr("loading", "lazy") +} + +func fullThumbnail(g gallery.Gallery) *gtag.Tag { + return thumbnail(fmt.Sprintf("/page/%s/1", g.Uuid())) +} + +func smallThumbnail(g gallery.Gallery) *gtag.Tag { + return thumbnail(fmt.Sprintf("/thumb/%s", g.Uuid())).Class("thumbnail") +} + +func InspectorInfo(g gallery.Gallery, state *state.State) *gtag.Tag { + d := gtag.Div().Class("float") + { + // d.Append(smallThumbnail(g)) + d.Append(fullThumbnail(g)) + // d.Tag("div").Id("inspector-image-placeholder") + d.Tag("a"). + SetAttr("href", fmt.Sprintf("/read/%s", g.Uuid())). + SetAttr("hx-boost", "false"). + SetAttr("style", "color: white"). + Tag("h1").Text(g.Name()) + d.Tag("h2").Text(g.JpName()) + tags := d.Div() + { + for _, t := range g.Tags() { + tags.Append(tag(t, state)) + } + } + } + return d +} + +func cover(g gallery.Gallery) *gtag.Tag { + a := gtag.New("a"). + Class("cover"). + Id(fmt.Sprintf("cover-%s", g.Uuid())). + SetAttr("href", fmt.Sprintf("/read/%s", g.Uuid())). + SetAttr("hx-get", fmt.Sprintf("/details/%s", g.Uuid())). + SetAttr("onmouseenter", "inspectSetTimeout(event)"). + SetAttr("onmouseleave", "inspectClearTimeout(event)"). + SetAttr("hx-trigger", "inspect"). + SetAttr("hx-target", "#inspector"). + SetAttr("hx-boost", "false") + + a.Append(smallThumbnail(g)) + + return a +} + +const galleriesPerPage = 50 + +func SearchResults(galleries []gallery.Gallery, page int) *gtag.Tag { + glm := max(len(galleries)-1, 0) + recent := galleries[min(page*galleriesPerPage, glm):min((page+1)*galleriesPerPage, glm)] + m := gtag.New("main") + // HTMX faz com que isso receba [InspectorInfo] quando usuário paira o + // mouse sobre algum cover + m.Tag("section").Id("inspector") + results := m.Tag("section").Id("results") + { + for _, g := range recent { + results.Append(cover(g)) + } + } + return m +} + +func Stats(state *state.State) *gtag.Tag { + s := gtag.New("div").Id("stats") + { + s.Tag("p").Text(fmt.Sprintf("Galleries loaded: %d", len(state.Galleries))) + s.Tag("p").Text(fmt.Sprintf("Unique tags: %d", len(state.UniqueTags))) + s.Tag("p").Text(fmt.Sprintf("Unique artists: %d", len(state.UniqueArtists))) + s.Tag("p").Text(fmt.Sprintf("Unique groups: %d", len(state.UniqueGroups))) + s.Tag("p").Text(fmt.Sprintf("Unique parodies: %d", len(state.UniqueParodies))) + } + if !state.Done { + s.SetAttr("hx-get", "/stats").SetAttr("hx-trigger", "every 0.01s").SetAttr("hx-swap", "outerHTML") + } + return s +} + +func Index(state *state.State, page int) *gtag.Tag { + html := gtag.Doc() + { + head := html.Head() + { + head.Tag("title").Text("index") + head.Tag("style").Asis(styles) + head.Asis( + ``, + ) + head.Asis("") + head.Tag("script").Asis(hotkeys) + head.Asis("") + head.Tag("script").Asis(htmx) + } + body := html.Body().Id("index").SetAttr("hx-boost", "true") + { + nav := body.Tag("nav").Class("container") + { + nav.Div().Text("omakase v1") + } + center := body.Div().Id("center") + { + top := center.Tag("div").Class("top") + { + top.Div().Id("search-bar").VoidTag("input"). + Class("ask-input"). + SetAttr("type", "search"). + SetAttr("name", "search"). + SetAttr("autocomplete", "false"). + SetAttr("placeholder", "Slash to search..."). + SetAttr("hx-post", "/search"). + SetAttr("hx-trigger", "input changed delay:50ms, search"). + SetAttr("hx-target", "#search-results") + header := top.Tag("header").Class("container") + { + header.Div().Id("omakase"). + Tag("a"). + SetAttr("href", "/random"). + Text("おまかせ"). + SetAttr("target", "_blank") + header.Tag("hr").Style("opacity: 0.2;") + header.Append(Stats(state)) + } + } + ret := center.Tag("main").Id("search-results") + { + controls := ret.Tag("section").AddClass("container").Id("controls") + { + paging := controls.Div() + { + maxPages := len(state.Galleries) / galleriesPerPage + previousPage := gtag.New("a"). + AddClass("page-control"). + Text("<") + if page > 1 { + previousPage.SetAttr("href", + fmt.Sprintf("?page=%d", page-1), + ) + } + paging.Append(previousPage) + p := page + for ; p < min(page+3, maxPages); p++ { + paging.Tag("a"). + AddClass("page-control"). + SetAttr("href", + fmt.Sprintf("?page=%d", p), + ).Text(fmt.Sprint(p)) + } + if len(state.Galleries) > 0 && p != maxPages { + paging.Tag("span").Text("...") + } + paging.Tag("a"). + AddClass("page-control"). + SetAttr("href", + fmt.Sprintf("?page=%d", maxPages), + ).Text(fmt.Sprint(maxPages)) + nextPage := gtag.New("a"). + AddClass("page-control"). + SetAttr("href", "?page=1"). + Text(">") + if page < maxPages { + nextPage.SetAttr("href", + fmt.Sprintf("?page=%d", page+1), + ) + } else if page == maxPages { + nextPage.SetAttr("disabled", "true") + } + paging.Append(nextPage) + } + } + ret.Append(SearchResults(state.Galleries, page)) + } + } + body.Tag("footer").P().Text(randomQuote()) + body.Tag("script").Asis(indexJavascript) + } + } + return html +} + +// content := body.Div().Id("content").Class("container"); { +// details := content.Tag("details"); { +// details.Tag("summary").Text("filter tags...") +// tags := details.Div().Id("tags"); { +// tags.Tag("a").Href("javascript:void(0)").Text("clear tags"). +// Class("name", "tag").Style("display: block;"). +// SetAttr("hx-post", "/filter/clear/all"). +// SetAttr("hx-target", "#listing"). +// SetAttr("hx-swap", "outerHTML") +// tags.Text("Tags:") +// tagsSpan := tags.Tag("span").SetAttr("hx-boost", "true"); { +// for k, v := range uniqueTags { +// tagsSpan.Append(tag(k, v)) +// } +// } +// } +// } +// content.Append(galleriesListing(galleries)) +// } diff --git a/views/quotes.go b/views/quotes.go new file mode 100644 index 0000000..a8c1933 --- /dev/null +++ b/views/quotes.go @@ -0,0 +1,72 @@ +package view + +import "math/rand" + +var quotes = []string{ + "The object of the superior man is truth.", + "Real knowledge is to know the extent of one's ignorance.", + "The will to win, the desire to succeed, the urge to reach your full potential... these are the keys that will unlock the door to personal excellence.", + "Better a diamond with a flaw than a pebble without.", + "It is easy to hate and it is difficult to love. This is how the whole scheme of things works. All good things are difficult to achieve; and bad things are very easy to get.", + "Humility is the solid foundation of all virtues.", + "What you do not want done to yourself, do not do to others.", + "Study the past, if you would divine the future.", + "Do not impose on others what you yourself do not desire.", + "The strength of a nation derives from the integrity of the home.", + "The more man meditates upon good thoughts, the better will be his world and the world at large.", + "A gentleman would be ashamed should his deeds not match his words.", + "We should feel sorrow, but not sink under its oppression.", + "The expectations of life depend upon diligence; the mechanic that would perfect his work must first sharpen his tools.", + "If we don't know life, how can we know death?", + "If you look into your own heart, and you find nothing wrong there, what is there to worry about? What is there to fear?", + "To see and listen to the wicked is already the beginning of wickedness.", + "Ability will never catch up with the demand for it.", + "Death and life have their determined appointments", + "riches and honors depend upon heaven.", + "There are three methods to gaining wisdom. The first is reflection, which is the highest. The second is limitation, which is the easiest. The third is experience, which is the bitterest.", + "The superior man thinks always of virtue; the common man thinks of comfort.", + "Life is really simple, but we insist on making it complicated.", + "Our greatest glory is not in never falling, but in rising every time we fall.", + "Wherever you go, go with all your heart.", + "When anger rises, think of the consequences.", + "Only the wisest and stupidest of men never change.", + "When it is obvious that the goals cannot be reached, don't adjust the goals, adjust the action steps.", + "Never give a sword to a man who can't dance.", + "If you think in terms of a year, plant a seed; if in terms of ten years, plant trees; if in terms of 100 years, teach the people.", + "Heaven means to be one with God.", + "Old age, believe me, is a good and pleasant thing. It is true you are gently shouldered off the stage, but then you are given such a comfortable front stall as spectator.", + "An oppressive government is more to be feared than a tiger.", + "The cautious seldom err.", + "To be wronged is nothing unless you continue to remember it.", + "Learning without thought is labor lost; thought without learning is perilous.", + "It is more shameful to distrust our friends than to be deceived by them.", + "To see what is right and not to do it is want of courage, or of principle.", + "If I am walking with two other men, each of them will serve as my teacher. I will pick out the good points of the one and imitate them, and the bad points of the other and correct them in myself.", + "The superior man acts before he speaks, and afterwards speaks according to his action.", + "Wisdom, compassion, and courage are the three universally recognized moral qualities of men.", + "I hear and I forget. I see and I remember. I do and I understand.", + "Everything has beauty, but not everyone sees it.", + "By three methods we may learn wisdom: First, by reflection, which is noblest; Second, by imitation, which is easiest; and third by experience, which is the bitterest.", + "To know what you know and what you do not know, that is true knowledge.", + "Success depends upon previous preparation, and without such preparation there is sure to be failure.", + "Silence is a true friend who never betrays.", + "He who learns but does not think, is lost! He who thinks but does not learn is in great danger.", + "Without feelings of respect, what is there to distinguish men from beasts?", + "I hear, I know. I see, I remember. I do, I understand.", + "The superior man understands what is right; the inferior man understands what will sell.", + "To practice five things under all circumstances constitutes perfect virtue; these five are gravity, generosity of soul, sincerity, earnestness, and kindness.", + "I want you to be everything that's you, deep at the center of your being.", + "You cannot open a book without learning something.", + "They must often change, who would be constant in happiness or wisdom.", + "Never contract friendship with a man that is not better than thyself.", + "The superior man is modest in his speech, but exceeds in his actions.", + "In a country well governed, poverty is something to be ashamed of. In a country badly governed, wealth is something to be ashamed of.", + "A superior man is modest in his speech, but exceeds in his actions.", + "To see the right and not to do it is cowardice.", +} + +var quotesLen = len(quotes) + +func randomQuote() string { + return quotes[rand.Intn(quotesLen)] +} diff --git a/views/reader.go b/views/reader.go new file mode 100644 index 0000000..ab7690b --- /dev/null +++ b/views/reader.go @@ -0,0 +1,60 @@ +package view + +import ( + _ "embed" + "fmt" + "goreader/gallery" + "strconv" + + "github.com/silva-guimaraes/gtag" +) + +//go:embed static/panzoom.min.js +var panzoom string + +func Reader(g gallery.Gallery, page string) *gtag.Tag { + images := g.Images() + html := gtag.Doc() + head := html.Head() + { + head.Tag("title").Text("leitor") + head.Tag("style").Asis(styles) + head.Asis("") + head.Tag("script").Asis(panzoom) + } + body := html.Body().Id("reader") + { + nav := body.Tag("nav") + { + controls := nav.Div().Id("controls") + { + controls.Tag("button").Id("start-button").Text("start") + controls.Tag("button").Id("previous-button").Text("previous") + controls.Tag("button").Id("next-button").Text("next") + controls.Tag("button").Id("end-button").Text("end") + } + nav.VoidTag("hr") + pageCounter := nav.P().Style("block: inline;") + { + pageCounter.Tag("span").Id("page-counter").Text(page) + pageCounter.Text("/") + pageCounter.Text(strconv.Itoa(len(images))) + } + } + cont := body.Div().Id("pages-container").SetAttr("data-page", page) + { + for i := range images { + img := gtag.NewVoid("img"). + Class("page hidden"). + SetAttr("loading", "lazy"). + SetAttr("src", fmt.Sprintf("/page/%s/%d", g.Uuid(), i+1)). + SetAttr("onclick", "pageNav(event);") + cont.Append(img) + } + } + body.Tag("a").Class("navigate").Id("left").SetAttr("href", "javascript:void(0)") + body.Tag("a").Class("navigate").Id("right").SetAttr("href", "javascript:void(0)") + body.Tag("script").Asis(readerJavascript) + } + return html +} diff --git a/views/static/hotkeys.js b/views/static/hotkeys.js new file mode 100644 index 0000000..22710d0 --- /dev/null +++ b/views/static/hotkeys.js @@ -0,0 +1,683 @@ +/**! + * hotkeys-js v3.13.7 + * A simple micro-library for defining and dispatching keyboard shortcuts. It has no dependencies. + * + * Copyright (c) 2024 kenny wong + * https://github.com/jaywcjlove/hotkeys-js.git + * + * @website: https://jaywcjlove.github.io/hotkeys-js + + * Licensed under the MIT license + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.hotkeys = factory()); +})(this, (function () { 'use strict'; + + const isff = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase().indexOf('firefox') > 0 : false; + + // 绑定事件 + function addEvent(object, event, method, useCapture) { + if (object.addEventListener) { + object.addEventListener(event, method, useCapture); + } else if (object.attachEvent) { + object.attachEvent("on".concat(event), method); + } + } + function removeEvent(object, event, method, useCapture) { + if (object.removeEventListener) { + object.removeEventListener(event, method, useCapture); + } else if (object.detachEvent) { + object.detachEvent("on".concat(event), method); + } + } + + // 修饰键转换成对应的键码 + function getMods(modifier, key) { + const mods = key.slice(0, key.length - 1); + for (let i = 0; i < mods.length; i++) mods[i] = modifier[mods[i].toLowerCase()]; + return mods; + } + + // 处理传的key字符串转换成数组 + function getKeys(key) { + if (typeof key !== 'string') key = ''; + key = key.replace(/\s/g, ''); // 匹配任何空白字符,包括空格、制表符、换页符等等 + const keys = key.split(','); // 同时设置多个快捷键,以','分割 + let index = keys.lastIndexOf(''); + + // 快捷键可能包含',',需特殊处理 + for (; index >= 0;) { + keys[index - 1] += ','; + keys.splice(index, 1); + index = keys.lastIndexOf(''); + } + return keys; + } + + // 比较修饰键的数组 + function compareArray(a1, a2) { + const arr1 = a1.length >= a2.length ? a1 : a2; + const arr2 = a1.length >= a2.length ? a2 : a1; + let isIndex = true; + for (let i = 0; i < arr1.length; i++) { + if (arr2.indexOf(arr1[i]) === -1) isIndex = false; + } + return isIndex; + } + + // Special Keys + const _keyMap = { + backspace: 8, + '⌫': 8, + tab: 9, + clear: 12, + enter: 13, + '↩': 13, + return: 13, + esc: 27, + escape: 27, + space: 32, + left: 37, + up: 38, + right: 39, + down: 40, + del: 46, + delete: 46, + ins: 45, + insert: 45, + home: 36, + end: 35, + pageup: 33, + pagedown: 34, + capslock: 20, + num_0: 96, + num_1: 97, + num_2: 98, + num_3: 99, + num_4: 100, + num_5: 101, + num_6: 102, + num_7: 103, + num_8: 104, + num_9: 105, + num_multiply: 106, + num_add: 107, + num_enter: 108, + num_subtract: 109, + num_decimal: 110, + num_divide: 111, + '⇪': 20, + ',': 188, + '.': 190, + '/': 191, + '`': 192, + '-': isff ? 173 : 189, + '=': isff ? 61 : 187, + ';': isff ? 59 : 186, + '\'': 222, + '[': 219, + ']': 221, + '\\': 220 + }; + + // Modifier Keys + const _modifier = { + // shiftKey + '⇧': 16, + shift: 16, + // altKey + '⌥': 18, + alt: 18, + option: 18, + // ctrlKey + '⌃': 17, + ctrl: 17, + control: 17, + // metaKey + '⌘': 91, + cmd: 91, + command: 91 + }; + const modifierMap = { + 16: 'shiftKey', + 18: 'altKey', + 17: 'ctrlKey', + 91: 'metaKey', + shiftKey: 16, + ctrlKey: 17, + altKey: 18, + metaKey: 91 + }; + const _mods = { + 16: false, + 18: false, + 17: false, + 91: false + }; + const _handlers = {}; + + // F1~F12 special key + for (let k = 1; k < 20; k++) { + _keyMap["f".concat(k)] = 111 + k; + } + + let _downKeys = []; // 记录摁下的绑定键 + let winListendFocus = null; // window是否已经监听了focus事件 + let _scope = 'all'; // 默认热键范围 + const elementEventMap = new Map(); // 已绑定事件的节点记录 + + // 返回键码 + const code = x => _keyMap[x.toLowerCase()] || _modifier[x.toLowerCase()] || x.toUpperCase().charCodeAt(0); + const getKey = x => Object.keys(_keyMap).find(k => _keyMap[k] === x); + const getModifier = x => Object.keys(_modifier).find(k => _modifier[k] === x); + + // 设置获取当前范围(默认为'所有') + function setScope(scope) { + _scope = scope || 'all'; + } + // 获取当前范围 + function getScope() { + return _scope || 'all'; + } + // 获取摁下绑定键的键值 + function getPressedKeyCodes() { + return _downKeys.slice(0); + } + function getPressedKeyString() { + return _downKeys.map(c => getKey(c) || getModifier(c) || String.fromCharCode(c)); + } + function getAllKeyCodes() { + const result = []; + Object.keys(_handlers).forEach(k => { + _handlers[k].forEach(_ref => { + let { + key, + scope, + mods, + shortcut + } = _ref; + result.push({ + scope, + shortcut, + mods, + keys: key.split('+').map(v => code(v)) + }); + }); + }); + return result; + } + + // 表单控件控件判断 返回 Boolean + // hotkey is effective only when filter return true + function filter(event) { + const target = event.target || event.srcElement; + const { + tagName + } = target; + let flag = true; + const isInput = tagName === 'INPUT' && !['checkbox', 'radio', 'range', 'button', 'file', 'reset', 'submit', 'color'].includes(target.type); + // ignore: isContentEditable === 'true', and