Compare commits

...

3 Commits

Author SHA1 Message Date
Seednode 3ea2227a9f Remove reference to supported files in readme, now available via HTTP endpoint 2023-09-12 23:41:36 -05:00
Seednode b5d08b5b6d Removed sample images 2023-09-12 23:35:29 -05:00
Seednode d7bc6e2451 Total restructure, just look at the diffs at this point 2023-09-12 23:35:17 -05:00
661 changed files with 361 additions and 242 deletions

View File

@ -8,8 +8,6 @@ A new file will be selected if you open `/` directly, or if you click on any dis
Browser history is preserved, so you can always go back to any previously displayed media.
Supported file types and extensions are listed in the corresponding source files in `formats/`.
Feature requests, code criticism, bug reports, general chit-chat, and unrelated angst accepted at `roulette@seedno.de`.
Static binary builds available [here](https://cdn.seedno.de/builds/roulette).
@ -78,13 +76,17 @@ The cache can be regenerated at any time by accessing the `/clear_cache` endpoin
If `--cache-file` is set, the cache will be loaded from the specified file on start, and written to the file whenever it is re-generated.
If the `-i|--index` flag is passed, four additional endpoints are registered.
## Info
If the `-i|--info` flag is passed, six additional endpoints are registered.
The first of these—`/html` and `/json`—return the contents of the index, in HTML and JSON formats respectively.
If `--page-length` is also set, these can be viewed in paginated form by appending `/n`, e.g. `/html/5` for the fifth page.
This can prove useful when confirming whether the index is generated successfully, or whether a given file is in the index.
The other two endpoints—`/extensions` and `/mime_types`—return the registered file types.
The remaining four endpoints—`/available_extensions`, `/enabled_extensions`, `/available_mime_types` and `/enabled_mime_types`—return information about the registered file types.
## Statistics
@ -92,6 +94,8 @@ If the `--stats` flag is passed, an additional endpoint, `/stats`, is registered
When accessed, this endpoint returns a JSON document listing every file served, along with the number of times it has been served, its filesize, and timestamps of when it was served.
If `--page-length` is also set, this can be viewed in paginated form by appending `/n`, e.g. `/stats/5` for the fifth page.
## Russian
If the `--russian` flag is passed, everything functions exactly as you would expect.
@ -111,7 +115,7 @@ Usage:
roulette <path> [path]... [flags]
Flags:
--all enable all supported file types
-a, --all enable all supported file types
--audio enable support for audio files
-b, --bind string address to bind to (default "0.0.0.0")
-c, --cache generate directory cache at startup
@ -120,7 +124,7 @@ Flags:
--flash enable support for shockwave flash files (via ruffle.rs)
-h, --help help for roulette
--images enable support for image files
-i, --index expose index endpoints
-i, --info expose informational endpoints
--maximum-files uint32 skip directories with file counts above this value (default 4294967295)
--minimum-files uint32 skip directories with file counts below this value (default 1)
--page-length uint32 pagination length for statistics and debug pages
@ -133,7 +137,7 @@ Flags:
--stats expose stats endpoint
--stats-file string path to optional persistent stats file
--text enable support for text files
-v, --verbose log accessed files to stdout
-v, --verbose log accessed files and other information to stdout
-V, --version display version and exit
--video enable support for video files
```

141
cmd/cache.go Normal file
View File

@ -0,0 +1,141 @@
/*
Copyright © 2023 Seednode <seednode@seedno.de>
*/
package cmd
import (
"encoding/gob"
"net/http"
"os"
"sync"
"github.com/julienschmidt/httprouter"
"github.com/klauspost/compress/zstd"
"seedno.de/seednode/roulette/types"
)
type FileIndex struct {
mutex sync.RWMutex
list []string
}
func (i *FileIndex) Index() []string {
i.mutex.RLock()
val := i.list
i.mutex.RUnlock()
return val
}
func (i *FileIndex) Remove(path string) {
i.mutex.RLock()
tempIndex := make([]string, len(i.list))
copy(tempIndex, i.list)
i.mutex.RUnlock()
var position int
for k, v := range tempIndex {
if path == v {
position = k
break
}
}
tempIndex[position] = tempIndex[len(tempIndex)-1]
i.mutex.Lock()
i.list = make([]string, len(tempIndex)-1)
copy(i.list, tempIndex[:len(tempIndex)-1])
i.mutex.Unlock()
}
func (i *FileIndex) setIndex(val []string) {
i.mutex.Lock()
i.list = val
i.mutex.Unlock()
}
func (i *FileIndex) generateCache(args []string, formats *types.Types) {
i.mutex.Lock()
i.list = []string{}
i.mutex.Unlock()
fileList(args, &Filters{}, "", i, formats)
if Cache && CacheFile != "" {
i.Export(CacheFile)
}
}
func (i *FileIndex) IsEmpty() bool {
i.mutex.RLock()
length := len(i.list)
i.mutex.RUnlock()
return length == 0
}
func (i *FileIndex) Export(path string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer file.Close()
z, err := zstd.NewWriter(file)
if err != nil {
return err
}
defer z.Close()
enc := gob.NewEncoder(z)
i.mutex.RLock()
enc.Encode(&i.list)
i.mutex.RUnlock()
return nil
}
func (i *FileIndex) Import(path string) error {
file, err := os.OpenFile(path, os.O_RDONLY, 0600)
if err != nil {
return err
}
defer file.Close()
z, err := zstd.NewReader(file)
if err != nil {
return err
}
defer z.Close()
dec := gob.NewDecoder(z)
i.mutex.Lock()
err = dec.Decode(&i.list)
i.mutex.Unlock()
if err != nil {
return err
}
return nil
}
func serveCacheClear(args []string, index *FileIndex, formats *types.Types) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
index.generateCache(args, formats)
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("Ok"))
}
}

View File

@ -5,150 +5,20 @@ Copyright © 2023 Seednode <seednode@seedno.de>
package cmd
import (
"encoding/gob"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/julienschmidt/httprouter"
"github.com/klauspost/compress/zstd"
"github.com/yosssi/gohtml"
"seedno.de/seednode/roulette/types"
)
type FileIndex struct {
mutex sync.RWMutex
list []string
}
func (i *FileIndex) Index() []string {
i.mutex.RLock()
val := i.list
i.mutex.RUnlock()
return val
}
func (i *FileIndex) Remove(path string) {
i.mutex.RLock()
tempIndex := make([]string, len(i.list))
copy(tempIndex, i.list)
i.mutex.RUnlock()
var position int
for k, v := range tempIndex {
if path == v {
position = k
break
}
}
tempIndex[position] = tempIndex[len(tempIndex)-1]
i.mutex.Lock()
i.list = make([]string, len(tempIndex)-1)
copy(i.list, tempIndex[:len(tempIndex)-1])
i.mutex.Unlock()
}
func (i *FileIndex) setIndex(val []string) {
i.mutex.Lock()
i.list = val
i.mutex.Unlock()
}
func (i *FileIndex) generateCache(args []string, formats *types.Types) {
i.mutex.Lock()
i.list = []string{}
i.mutex.Unlock()
fileList(args, &Filters{}, "", i, formats)
if Cache && CacheFile != "" {
i.Export(CacheFile)
}
}
func (i *FileIndex) IsEmpty() bool {
i.mutex.RLock()
length := len(i.list)
i.mutex.RUnlock()
return length == 0
}
func (i *FileIndex) Export(path string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer file.Close()
z, err := zstd.NewWriter(file)
if err != nil {
return err
}
defer z.Close()
enc := gob.NewEncoder(z)
i.mutex.RLock()
enc.Encode(&i.list)
i.mutex.RUnlock()
return nil
}
func (i *FileIndex) Import(path string) error {
file, err := os.OpenFile(path, os.O_RDONLY, 0600)
if err != nil {
return err
}
defer file.Close()
z, err := zstd.NewReader(file)
if err != nil {
return err
}
defer z.Close()
dec := gob.NewDecoder(z)
i.mutex.Lock()
err = dec.Decode(&i.list)
i.mutex.Unlock()
if err != nil {
return err
}
return nil
}
func serveCacheClear(args []string, index *FileIndex, formats *types.Types) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
index.generateCache(args, formats)
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("Ok"))
}
}
func serveIndexHtml(args []string, index *FileIndex, paginate bool) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
w.Header().Set("Content-Type", "text/html")
@ -318,30 +188,34 @@ func serveIndexJson(args []string, index *FileIndex) httprouter.Handle {
}
}
func serveExtensions(formats *types.Types) httprouter.Handle {
func serveAvailableExtensions() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
w.Header().Set("Content-Type", "text/plain")
startTime := time.Now()
var output strings.Builder
response := []byte(types.SupportedFormats.GetExtensions())
extensions := make([]string, len(formats.Extensions))
w.Write(response)
i := 0
for k := range formats.Extensions {
extensions[i] = k
i++
if Verbose {
fmt.Printf("%s | Served available extensions list (%s) to %s in %s\n",
startTime.Format(LogDate),
humanReadableSize(len(response)),
realIP(r),
time.Since(startTime).Round(time.Microsecond),
)
}
}
}
slices.Sort(extensions)
func serveEnabledExtensions(formats *types.Types) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
w.Header().Set("Content-Type", "text/plain")
for _, v := range extensions {
output.WriteString(v + "\n")
}
startTime := time.Now()
response := []byte(output.String())
response := []byte(formats.GetExtensions())
w.Write(response)
@ -356,30 +230,34 @@ func serveExtensions(formats *types.Types) httprouter.Handle {
}
}
func serveMimeTypes(formats *types.Types) httprouter.Handle {
func serveAvailableMimeTypes() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
w.Header().Set("Content-Type", "text/plain")
startTime := time.Now()
var output strings.Builder
response := []byte(types.SupportedFormats.GetMimeTypes())
mimeTypes := make([]string, len(formats.MimeTypes))
w.Write(response)
i := 0
for k := range formats.MimeTypes {
mimeTypes[i] = k
i++
if Verbose {
fmt.Printf("%s | Served available MIME types list (%s) to %s in %s\n",
startTime.Format(LogDate),
humanReadableSize(len(response)),
realIP(r),
time.Since(startTime).Round(time.Microsecond),
)
}
}
}
slices.Sort(mimeTypes)
func serveEnabledMimeTypes(formats *types.Types) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
w.Header().Set("Content-Type", "text/plain")
for _, v := range mimeTypes {
output.WriteString(v + "\n")
}
startTime := time.Now()
response := []byte(output.String())
response := []byte(formats.GetMimeTypes())
w.Write(response)

View File

@ -12,7 +12,7 @@ import (
)
const (
ReleaseVersion string = "0.75.0"
ReleaseVersion string = "0.76.0"
)
var (
@ -24,7 +24,7 @@ var (
Filtering bool
Flash bool
Images bool
Index bool
Info bool
MaximumFileCount uint32
MinimumFileCount uint32
PageLength uint32
@ -46,10 +46,6 @@ var (
Short: "Serves random media from the specified directories.",
Args: cobra.MinimumNArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
if Index {
cmd.MarkFlagRequired("cache")
}
if RefreshInterval != "" {
interval, err := time.ParseDuration(RefreshInterval)
if err != nil || interval < 500*time.Millisecond {
@ -78,7 +74,7 @@ func Execute() {
}
func init() {
rootCmd.Flags().BoolVar(&All, "all", false, "enable all supported file types")
rootCmd.Flags().BoolVarP(&All, "all", "a", false, "enable all supported file types")
rootCmd.Flags().BoolVar(&Audio, "audio", false, "enable support for audio files")
rootCmd.Flags().StringVarP(&Bind, "bind", "b", "0.0.0.0", "address to bind to")
rootCmd.Flags().BoolVarP(&Cache, "cache", "c", false, "generate directory cache at startup")
@ -86,7 +82,7 @@ func init() {
rootCmd.Flags().BoolVarP(&Filtering, "filter", "f", false, "enable filtering")
rootCmd.Flags().BoolVar(&Flash, "flash", false, "enable support for shockwave flash files (via ruffle.rs)")
rootCmd.Flags().BoolVar(&Images, "images", false, "enable support for image files")
rootCmd.Flags().BoolVarP(&Index, "index", "i", false, "expose index endpoints")
rootCmd.Flags().BoolVarP(&Info, "info", "i", false, "expose informational endpoints")
rootCmd.Flags().Uint32Var(&MaximumFileCount, "maximum-files", 1<<32-1, "skip directories with file counts above this value")
rootCmd.Flags().Uint32Var(&MinimumFileCount, "minimum-files", 1, "skip directories with file counts below this value")
rootCmd.Flags().Uint32Var(&PageLength, "page-length", 0, "pagination length for statistics and debug pages")
@ -99,7 +95,7 @@ func init() {
rootCmd.Flags().BoolVar(&Statistics, "stats", false, "expose stats endpoint")
rootCmd.Flags().StringVar(&StatisticsFile, "stats-file", "", "path to optional persistent stats file")
rootCmd.Flags().BoolVar(&Text, "text", false, "enable support for text files")
rootCmd.Flags().BoolVarP(&Verbose, "verbose", "v", false, "log accessed files to stdout")
rootCmd.Flags().BoolVarP(&Verbose, "verbose", "v", false, "log accessed files and other information to stdout")
rootCmd.Flags().BoolVarP(&Version, "version", "V", false, "display version and exit")
rootCmd.Flags().BoolVar(&Videos, "video", false, "enable support for video files")

View File

@ -27,6 +27,11 @@ import (
"github.com/julienschmidt/httprouter"
"github.com/yosssi/gohtml"
"seedno.de/seednode/roulette/types"
"seedno.de/seednode/roulette/types/audio"
"seedno.de/seednode/roulette/types/flash"
"seedno.de/seednode/roulette/types/images"
"seedno.de/seednode/roulette/types/text"
"seedno.de/seednode/roulette/types/video"
)
const (
@ -317,25 +322,25 @@ func ServePage(args []string) error {
}
if Audio || All {
formats.Add(types.Audio{})
formats.Add(audio.Format{})
}
if Flash || All {
formats.Add(types.Flash{})
formats.Add(flash.Format{})
}
if Text || All {
formats.Add(types.Text{})
formats.Add(text.Format{})
}
if Videos || All {
formats.Add(types.Video{})
formats.Add(video.Format{})
}
// enable image support if no other flags are passed, to retain backwards compatibility
// to be replaced with rootCmd.MarkFlagsOneRequired on next spf13/cobra update
if Images || All || len(formats.Extensions) == 0 {
formats.Add(types.Images{})
formats.Add(images.Format{})
}
paths, err := normalizePaths(args, formats)
@ -408,20 +413,23 @@ func ServePage(args []string) error {
mux.GET("/clear_cache", serveCacheClear(args, index, formats))
}
if Index {
mux.GET("/html/", serveIndexHtml(args, index, false))
if PageLength != 0 {
mux.GET("/html/:page", serveIndexHtml(args, index, true))
if Info {
if Cache {
mux.GET("/html/", serveIndexHtml(args, index, false))
if PageLength != 0 {
mux.GET("/html/:page", serveIndexHtml(args, index, true))
}
mux.GET("/json", serveIndexJson(args, index))
if PageLength != 0 {
mux.GET("/json/:page", serveIndexJson(args, index))
}
}
mux.GET("/json", serveIndexJson(args, index))
if PageLength != 0 {
mux.GET("/json/:page", serveIndexJson(args, index))
}
mux.GET("/extensions", serveExtensions(formats))
mux.GET("/mime_types", serveMimeTypes(formats))
mux.GET("/available_extensions", serveAvailableExtensions())
mux.GET("/enabled_extensions", serveEnabledExtensions(formats))
mux.GET("/available_mime_types", serveAvailableMimeTypes())
mux.GET("/enabled_mime_types", serveEnabledMimeTypes(formats))
}
if Profile {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Some files were not shown because too many files have changed in this diff Show More