From 9a68591255747c82fd4ce99351bca6d43349cafa Mon Sep 17 00:00:00 2001 From: tjpcc Date: Sun, 3 Sep 2023 19:58:18 -0600 Subject: implement gophernicus extensions for gophermaps --- gopher/gophermap/extended.go | 220 +++++++++++++++++++++ gopher/gophermap/extended_test.go | 57 ++++++ gopher/gophermap/listdir.go | 154 +++++++++++++++ gopher/gophermap/parse.go | 4 + gopher/gophermap/testdata/customlist.gophermap | 15 ++ .../gophermap/testdata/customlist_output.gophermap | 12 ++ gopher/gophermap/testdata/file1 | 0 gopher/gophermap/testdata/file2 | 0 gopher/gophermap/testdata/file3.executablething | 0 gopher/gophermap/testdata/file4 | 0 10 files changed, 462 insertions(+) create mode 100644 gopher/gophermap/extended.go create mode 100644 gopher/gophermap/extended_test.go create mode 100644 gopher/gophermap/listdir.go create mode 100644 gopher/gophermap/testdata/customlist.gophermap create mode 100644 gopher/gophermap/testdata/customlist_output.gophermap create mode 100644 gopher/gophermap/testdata/file1 create mode 100644 gopher/gophermap/testdata/file2 create mode 100644 gopher/gophermap/testdata/file3.executablething create mode 100644 gopher/gophermap/testdata/file4 (limited to 'gopher') diff --git a/gopher/gophermap/extended.go b/gopher/gophermap/extended.go new file mode 100644 index 0000000..d9fedd0 --- /dev/null +++ b/gopher/gophermap/extended.go @@ -0,0 +1,220 @@ +package gophermap + +import ( + "bufio" + "errors" + "io" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "tildegit.org/tjp/sliderule/gopher" + "tildegit.org/tjp/sliderule/internal/types" +) + +// ExtendedMapDocument is a gophermap doc with gophernicus's extensions +// +// These are documented at: gopher://gopher.gophernicus.org/0/docs/README.Gophermap +type ExtendedMapDocument struct { + Lines []gopher.MapItem + Location *url.URL +} + +// ParseExtended parses a gophermap document including gophernicus extensions. +func ParseExtended(input io.Reader, location *url.URL) (ExtendedMapDocument, error) { + rdr := bufio.NewReader(input) + doc := ExtendedMapDocument{Location: location} + +outer: + for num := 1; ; num += 1 { + line, err := rdr.ReadString('\n') + isEOF := errors.Is(err, io.EOF) + if err != nil && !isEOF { + return doc, err + } + line = strings.TrimRight(line, "\r\n") + + if len(line) > 0 { + switch line[0] { + + case '#': + doc.Lines = append(doc.Lines, gopher.MapItem{ + Type: CommentType, + Display: strings.TrimPrefix(line[1:], " "), + }) + continue outer + case '!': + doc.Lines = append(doc.Lines, gopher.MapItem{ + Type: TitleType, + Display: line[1:], + }) + continue outer + case '-': + doc.Lines = append(doc.Lines, gopher.MapItem{ + Type: HiddenType, + Selector: line[1:], + }) + continue outer + case ':': + doc.Lines = append(doc.Lines, gopher.MapItem{ + Type: ExtensionType, + Display: line[1:], + }) + continue outer + case '=': + doc.Lines = append(doc.Lines, gopher.MapItem{ + Type: InclusionType, + Selector: line[1:], + }) + continue outer + } + } + + switch line { + case "~": + doc.Lines = append(doc.Lines, gopher.MapItem{Type: UserListType}) + continue outer + case "%": + doc.Lines = append(doc.Lines, gopher.MapItem{Type: VHostListType}) + continue outer + case ".": + doc.Lines = append(doc.Lines, gopher.MapItem{Type: EndDocType}) + break outer + case "*": + doc.Lines = append(doc.Lines, gopher.MapItem{Type: DirListType}) + break outer + } + + if !strings.Contains(line, "\t") { + doc.Lines = append(doc.Lines, gopher.MapItem{ + Type: gopher.InfoMessageType, + Display: line, + Selector: location.Path, + Hostname: location.Hostname(), + Port: location.Port(), + }) + continue + } + + item := gopher.MapItem{Type: types.Status(line[0])} + + spl := strings.Split(line[1:], "\t") + if len(spl) != 4 { + return doc, InvalidLine(num) + } + item.Display = string(spl[0]) + item.Selector = string(spl[1]) + item.Hostname = string(spl[2]) + item.Port = string(spl[3]) + if _, err = strconv.Atoi(item.Port); err != nil { + return doc, InvalidLine(num) + } + doc.Lines = append(doc.Lines, item) + + if isEOF { + break + } + } + + return doc, nil +} + +// Extensions to gopher types from Gophernicus. +const ( + // CommentType is omitted from generated compatible gophermaps. + CommentType types.Status = '#' + + // TitleType defines the title of a gophermap document. + TitleType types.Status = '!' + + // HiddenType hides a link from the generated compatible gophermap. + HiddenType types.Status = '-' + + // ExtensionType defines the gopher type to use for files in the current directory with a given extension. + ExtensionType types.Status = ':' + + // UserListType generates a list of users with valid ~/public_gopher directories. + UserListType types.Status = '~' + + // VHostListType generates a listing of virtual hosts. + VHostListType types.Status = '%' + + // InclusionType causes another gophermap to be included at this location. + InclusionType types.Status = '=' + + // DirListType stops parsing the current file and ends the generated gophermap with a listing of the current directory. + DirListType types.Status = '*' + + // EndDocType ends the current gophermap file. + EndDocType types.Status = '.' +) + +// Compatible builds a standards-compliant gophermap from the current extended menu. +func (edoc ExtendedMapDocument) Compatible(cwd string) (gopher.MapDocument, string, error) { + doc := gopher.MapDocument{} + + title := "" + hidden := map[string]struct{}{} + extensions := map[string]types.Status{} + + for num, item := range edoc.Lines { + switch item.Type { + case CommentType: + case TitleType: + title = item.Display + case HiddenType: + hidden[item.Selector] = struct{}{} + case ExtensionType: + from, to, found := strings.Cut(item.Display, "=") + if !found { + return nil, "", InvalidLine(num) + } + extensions[from] = types.Status(to[0]) + case UserListType: //TODO + return nil, "", errors.New("User listings '~' are not supported") + case VHostListType: //TODO + return nil, "", errors.New("Virtual host listings '%' are not supported") + case InclusionType: + location := filepath.Join(cwd, item.Selector) + subEdoc, err := openExtended(location, edoc.Location) + if err != nil { + return nil, "", err + } + + lines, _, err := subEdoc.Compatible(filepath.Dir(location)) + if err != nil { + return nil, "", err + } + doc = append(doc, lines...) + case DirListType: + dirlist, err := listDir(cwd, edoc.Location, hidden, extensions) + if err != nil { + return nil, "", err + } + + doc = append(doc, dirlist...) + + break + case EndDocType: + break + default: + doc = append(doc, item) + } + } + + return doc, title, nil +} + +func openExtended(path string, location *url.URL) (ExtendedMapDocument, error) { + file, err := os.Open(path) + if err != nil { + return ExtendedMapDocument{}, err + } + defer func() { + _ = file.Close() + }() + + return ParseExtended(file, location) +} diff --git a/gopher/gophermap/extended_test.go b/gopher/gophermap/extended_test.go new file mode 100644 index 0000000..e956df1 --- /dev/null +++ b/gopher/gophermap/extended_test.go @@ -0,0 +1,57 @@ +package gophermap + +import ( + "bytes" + "fmt" + "io" + "net/url" + "os" + "testing" +) + +func TestExtendedDoc(t *testing.T) { + file, err := os.Open("testdata/customlist.gophermap") + if err != nil { + t.Fatal(err) + } + + source := &bytes.Buffer{} + _, err = io.Copy(source, file) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + + edoc, err := ParseExtended(source, &url.URL{ + Scheme: "gopher", + Host: "localhost.localdomain:70", + Path: "/customlist.gophermap", + }) + if err != nil { + t.Fatal(err) + } + + doc, _, err := edoc.Compatible("testdata") + if err != nil { + t.Fatal(err) + } + got := doc.String() + + file, err = os.Open("testdata/customlist_output.gophermap") + if err != nil { + t.Fatal(err) + } + + expected := &bytes.Buffer{} + _, err = io.Copy(expected, file) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + + if expected.String() != got { + fmt.Printf("expected:\n%s", expected.String()) + fmt.Printf("got:\n%s", got) + t.Fatal("output mismatch") + } +} diff --git a/gopher/gophermap/listdir.go b/gopher/gophermap/listdir.go new file mode 100644 index 0000000..8d66277 --- /dev/null +++ b/gopher/gophermap/listdir.go @@ -0,0 +1,154 @@ +package gophermap + +import ( + "bufio" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "tildegit.org/tjp/sliderule/gopher" + "tildegit.org/tjp/sliderule/internal/types" +) + +// ListDir builds a gopher menu representing the contents of a directory. +func ListDir(dir string, location *url.URL) (gopher.MapDocument, error) { + return listDir(dir, location, nil, nil) +} + +func listDir(dir string, location *url.URL, hidden map[string]struct{}, extensions map[string]types.Status) (gopher.MapDocument, error) { + contents, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + doc := gopher.MapDocument{} + + for _, entry := range contents { + name := entry.Name() + + if _, ok := hidden[name]; ok || name == "gophermap" { + continue + } + + var code types.Status + + ext := strings.TrimPrefix(filepath.Ext(name), ".") + if entry.IsDir() { + code = gopher.MenuType + } else if c, ok := extensions[ext]; ok && ext != "" { + code = c + } else { + switch ext { + case "gophermap": + code = gopher.MenuType + case "exe", "bin", "out": + code = gopher.BinaryFileType + case "gif": + code = gopher.GifFileType + case "jpg", "jpeg", "tif", "tiff": + code = gopher.ImageFileType + case "bmp": + code = gopher.BitmapType + case "mp4", "mov", "avi", "wmv", "webm": + code = gopher.MovieFileType + case "pcm", "aiff", "mp3", "aac", "ogg", "wma", "flac", "alac": + code = gopher.SoundFileType + case "doc", "docx", "odt", "fodt": + code = gopher.DocumentType + case "html": + code = gopher.HTMLType + case "png": + code = gopher.PngImageFileType + case "rtf": + code = gopher.RtfDocumentType + case "wav": + code = gopher.WavSoundFileType + case "pdf": + code = gopher.PdfDocumentType + case "xml", "atom": + code = gopher.XmlDocumentType + default: + code = gopher.TextFileType + } + } + + doc = append(doc, gopher.MapItem{ + Type: code, + Display: displayName(dir, entry), + Selector: path.Join(path.Dir(location.Path), name), + Hostname: location.Hostname(), + Port: location.Port(), + }) + } + + return doc, nil +} + +func displayName(dir string, entry os.DirEntry) string { + fname := entry.Name() + + // if is a gophermap, use !title or filename + if strings.HasSuffix(fname, ".gophermap") { + if title := gophermapTitle(dir, fname); title != "" { + return title + } + return fname + } + + if entry.IsDir() { + if tag := tagTitle(dir, fname); tag != "" { + return tag + } + + if title := gophermapTitle(dir, filepath.Join(fname, "gophermap")); title != "" { + return title + } + } + + return fname +} + +func gophermapTitle(dir, name string) string { + file, err := os.Open(filepath.Join(dir, name)) + if err != nil { + return "" + } + defer func() { + _ = file.Close() + }() + + rdr := bufio.NewReader(file) + line, err := rdr.ReadString('\n') + if err != nil { + return "" + } + + if !strings.HasPrefix(line, "!") { + return "" + } + return strings.TrimRight(line[1:], "\r\n") +} + +func tagTitle(parent, name string) string { + file, err := os.Open(filepath.Join(parent, name, "gophertag")) + if err != nil { + return "" + } + defer func() { + _ = file.Close() + }() + + stat, err := file.Stat() + if err != nil || stat.IsDir() { + return "" + } + + rdr := bufio.NewReader(file) + line, err := rdr.ReadString('\n') + if err != nil { + return "" + } + return strings.TrimRight(line, "\r\n") +} diff --git a/gopher/gophermap/parse.go b/gopher/gophermap/parse.go index 3317514..04286bd 100644 --- a/gopher/gophermap/parse.go +++ b/gopher/gophermap/parse.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "strconv" sr "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gopher" @@ -38,6 +39,9 @@ func Parse(input io.Reader) (gopher.MapDocument, error) { item.Selector = string(spl[1]) item.Hostname = string(spl[2]) item.Port = string(spl[3]) + if _, err = strconv.Atoi(item.Port); err != nil { + return nil, InvalidLine(num) + } doc = append(doc, item) } diff --git a/gopher/gophermap/testdata/customlist.gophermap b/gopher/gophermap/testdata/customlist.gophermap new file mode 100644 index 0000000..0d0c882 --- /dev/null +++ b/gopher/gophermap/testdata/customlist.gophermap @@ -0,0 +1,15 @@ +!This is the document title + +These should be info-message lines since they +have no tab characters anywhere. + +# This is a comment! + +-file1 +-file2 + +:executablething=9 + +* + +this is unreachable diff --git a/gopher/gophermap/testdata/customlist_output.gophermap b/gopher/gophermap/testdata/customlist_output.gophermap new file mode 100644 index 0000000..1a96644 --- /dev/null +++ b/gopher/gophermap/testdata/customlist_output.gophermap @@ -0,0 +1,12 @@ +i /customlist.gophermap localhost.localdomain 70 +iThese should be info-message lines since they /customlist.gophermap localhost.localdomain 70 +ihave no tab characters anywhere. /customlist.gophermap localhost.localdomain 70 +i /customlist.gophermap localhost.localdomain 70 +i /customlist.gophermap localhost.localdomain 70 +i /customlist.gophermap localhost.localdomain 70 +i /customlist.gophermap localhost.localdomain 70 +1This is the document title /customlist.gophermap localhost.localdomain 70 +1customlist_output.gophermap /customlist_output.gophermap localhost.localdomain 70 +9file3.executablething /file3.executablething localhost.localdomain 70 +0file4 /file4 localhost.localdomain 70 +. diff --git a/gopher/gophermap/testdata/file1 b/gopher/gophermap/testdata/file1 new file mode 100644 index 0000000..e69de29 diff --git a/gopher/gophermap/testdata/file2 b/gopher/gophermap/testdata/file2 new file mode 100644 index 0000000..e69de29 diff --git a/gopher/gophermap/testdata/file3.executablething b/gopher/gophermap/testdata/file3.executablething new file mode 100644 index 0000000..e69de29 diff --git a/gopher/gophermap/testdata/file4 b/gopher/gophermap/testdata/file4 new file mode 100644 index 0000000..e69de29 -- cgit v1.2.3