Merge branch 'feature/resources' into develop

This commit is contained in:
Maxim Lebedev 2023-11-10 09:10:04 +06:00
commit c4dd174c92
Signed by: toby3d
GPG Key ID: 1F14E25B7C119FC5
18 changed files with 366 additions and 167 deletions

View File

@ -1,52 +0,0 @@
package domain
import (
"mime"
"path/filepath"
"strings"
"time"
)
type File struct {
Path string
Updated time.Time
Content []byte
// Only for graphics
Width int
Height int
}
// LogicalName returns full file name without directory path.
func (f File) LogicalName() string {
return filepath.Base(f.Path)
}
// BaseFileName returns file name without extention and directory path.
func (f File) BaseFileName() string {
base := filepath.Base(f.Path)
return strings.TrimSuffix(base, filepath.Ext(base))
}
// Ext returns file extention.
func (f File) Ext() string {
if ext := filepath.Ext(f.Path); len(ext) > 1 {
return ext[1:]
}
return ""
}
// Dir returns file directory.
func (f File) Dir() string {
return filepath.Dir(f.Path)
}
func (f File) MediaType() string {
return mime.TypeByExtension(filepath.Ext(f.Path))
}
func (f File) GoString() string {
return "domain.File(" + f.Path + ")"
}

View File

@ -1,18 +0,0 @@
package domain
import "path"
// TODO(toby3d): search by type or name/id.
type Files []*File
func (f Files) GetMatch(pattern string) *File {
for i := range f {
if matched, err := path.Match(pattern, f[i].Path); err != nil || !matched {
continue
}
return f[i]
}
return nil
}

View File

@ -0,0 +1,52 @@
package domain
import (
"mime"
"strings"
)
type MediaType struct {
mainType string
subType string
mediaType string
}
// NewMediaType creates a new MediaType domain from raw string in
// the 'MainType/SubType' format.
func NewMediaType(raw string) MediaType {
parts := strings.Split(raw, "/")
if len(parts) < 2 {
return MediaType{}
}
return MediaType{
mainType: parts[0],
subType: parts[1],
mediaType: mime.FormatMediaType(strings.Join([]string{parts[0], parts[1]}, "/"), nil),
}
}
// Type returns main part of MediaType.
// For 'image/jpeg' it returns 'image'.
func (mt MediaType) MainType() string {
return mt.mainType
}
// Type returns sub part of MediaType.
// For 'image/jpeg' it returns 'jpeg'.
func (mt MediaType) SubType() string {
return mt.subType
}
// Type returns 'MainType/SubType' string.
func (mt MediaType) Type() string {
return mt.mediaType
}
func (mt MediaType) String() string {
return mt.mediaType
}
func (mt MediaType) GoString() string {
return "domain.MediaType(" + mt.mediaType + ")"
}

View File

@ -0,0 +1,32 @@
package domain_test
import (
"testing"
"source.toby3d.me/toby3d/home/internal/domain"
)
func TestMediaType_Type(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
input string
expect string
}{
"exists": {input: "image/jpeg", expect: "image/jpeg"},
"main": {input: "image/", expect: ""},
"sub": {input: "/jpeg", expect: ""},
"params": {input: "image/svg+xml", expect: "image/svg+xml"},
} {
tc, name := tc, name
t.Run(name, func(t *testing.T) {
t.Parallel()
actual := domain.NewMediaType(tc.input).Type()
if actual != tc.expect {
t.Errorf("got '%s', want '%s'", actual, tc.expect)
}
})
}
}

View File

@ -3,9 +3,9 @@ package domain
import "golang.org/x/text/language"
type Page struct {
Language language.Tag
Params map[string]any
Title string
Content []byte
Files Files
Language language.Tag
Params map[string]any
Title string
Content []byte
Resources Resources
}

110
internal/domain/resource.go Normal file
View File

@ -0,0 +1,110 @@
package domain
import (
"bytes"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime"
"path"
"time"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/webp"
)
type Resource struct {
modTime time.Time
reader io.ReadSeeker
params map[string]any // TODO(toby3d): set from Page configuration
mediaType MediaType
key string
name string // TODO(toby3d): set from Page configuration
title string // TODO(toby3d): set from Page configuration
resourceType ResourceType
image image.Config
}
func NewResource(modTime time.Time, content []byte, key string) *Resource {
mediaType, _, _ := mime.ParseMediaType(mime.TypeByExtension(path.Ext(key)))
out := &Resource{
modTime: modTime,
key: key,
name: key, // TODO(toby3d): set from Page configuration
title: "", // TODO(toby3d): set from Page configuration
params: make(map[string]any), // TODO(toby3d): set from Page configuration
mediaType: NewMediaType(mediaType),
reader: bytes.NewReader(content),
}
switch path.Ext(key) {
default:
out.resourceType, _ = ParseResourceType(out.mediaType.mainType)
case ".md":
out.resourceType = ResourceTypePage
case ".webmanifest":
out.resourceType = ResourceTypeText
}
switch out.resourceType {
case ResourceTypeImage:
out.image, _, _ = image.DecodeConfig(out.reader)
}
return out
}
func (r Resource) Key() string {
return r.key
}
func (r Resource) Name() string {
return r.name
}
func (r Resource) MediaType() MediaType {
return r.mediaType
}
// Width returns width if current r is an image.
func (r Resource) Width() int {
return r.image.Width
}
// Height returns height if current r is an image.
func (r Resource) Height() int {
return r.image.Height
}
func (r Resource) ResourceType() ResourceType {
return r.resourceType
}
func (r Resource) Content() []byte {
content, _ := io.ReadAll(r.reader)
return content
}
func (r Resource) Read(p []byte) (int, error) {
return r.reader.Read(p)
}
func (r Resource) Seek(offset int64, whence int) (int64, error) {
return r.reader.Seek(offset, whence)
}
func (f Resource) GoString() string {
return "domain.Resource(" + f.key + ")"
}
// ResourceModTime used in http.ServeContent to get modtime of this resource.
func ResourceModTime(r *Resource) time.Time {
if r == nil {
return time.Time{}
}
return r.modTime
}

View File

@ -0,0 +1,50 @@
package domain
import (
"errors"
"fmt"
"strings"
)
type ResourceType struct {
resourceType string
}
var (
ResourceTypeUnd ResourceType = ResourceType{} // "und"
ResourceTypeAudio ResourceType = ResourceType{"audio"} // "audio"
ResourceTypeImage ResourceType = ResourceType{"image"} // "image"
ResourceTypePage ResourceType = ResourceType{"page"} // "page"
ResourceTypeText ResourceType = ResourceType{"text"} // "text"
ResourceTypeVideo ResourceType = ResourceType{"video"} // "video"
)
var ErrResourceType error = errors.New("unsupported ResourceType enum")
var stringsResourceTypes = map[string]ResourceType{
ResourceTypeAudio.resourceType: ResourceTypeAudio,
ResourceTypeImage.resourceType: ResourceTypeImage,
ResourceTypePage.resourceType: ResourceTypePage,
ResourceTypeText.resourceType: ResourceTypeText,
ResourceTypeVideo.resourceType: ResourceTypeVideo,
}
func ParseResourceType(raw string) (ResourceType, error) {
if rt, ok := stringsResourceTypes[strings.ToLower(raw)]; ok {
return rt, nil
}
return ResourceTypeUnd, fmt.Errorf("%w: got '%s', want %s", ErrResourceType, raw, stringsResourceTypes)
}
func (rt ResourceType) String() string {
if rt.resourceType != "" {
return rt.resourceType
}
return "und"
}
func (rt ResourceType) GoString() string {
return "domain.ResourceType(" + rt.String() + ")"
}

View File

@ -0,0 +1,64 @@
package domain
import (
"path"
)
type Resources []*Resource
func (r Resources) GetType(targetType string) Resources {
out := make(Resources, 0, len(r))
target, err := ParseResourceType(targetType)
if err != nil {
return out
}
for i := range r {
if r[i].resourceType != target {
continue
}
out = append(out, r[i])
}
return out
}
func (r Resources) Get(path string) *Resource {
for i := range r {
if r[i].key != path {
continue
}
return r[i]
}
return nil
}
func (r Resources) Match(pattern string) Resources {
out := make(Resources, 0, len(r))
for i := range r {
if matched, err := path.Match(pattern, r[i].key); err != nil || !matched {
continue
}
out = append(out, r[i])
}
return out
}
func (r Resources) GetMatch(pattern string) *Resource {
for i := range r {
if matched, err := path.Match(pattern, r[i].key); err != nil || !matched {
continue
}
return r[i]
}
return nil
}

View File

@ -8,10 +8,10 @@ import (
)
type Site struct {
Language language.Tag
BaseURL *url.URL
TimeZone *time.Location
Params map[string]any
Title string
Files Files
Language language.Tag
BaseURL *url.URL
TimeZone *time.Location
Params map[string]any
Title string
Resources Resources
}

View File

@ -57,10 +57,10 @@ func (repo *fileSystemPageRepository) Get(ctx context.Context, lang language.Tag
}
return &domain.Page{
Language: lang,
Title: data.Title,
Content: data.Content,
Params: data.Params,
Files: make([]*domain.File, 0),
Language: lang,
Title: data.Title,
Content: data.Content,
Params: data.Params,
Resources: make([]*domain.Resource, 0),
}, nil
}

View File

@ -33,23 +33,23 @@ func TestGet(t *testing.T) {
}{
"index": {
input: path.Join("index"),
expect: &domain.Page{Content: []byte("index.md"), Files: make([]*domain.File, 0)},
expect: &domain.Page{Content: []byte("index.md"), Resources: make([]*domain.Resource, 0)},
},
"file": {
input: path.Join("file"),
expect: &domain.Page{Content: []byte("file.md"), Files: make([]*domain.File, 0)},
expect: &domain.Page{Content: []byte("file.md"), Resources: make([]*domain.Resource, 0)},
},
"folder": {
input: path.Join("folder", "index"),
expect: &domain.Page{Content: []byte("folder/index.md"), Files: make([]*domain.File, 0)},
expect: &domain.Page{Content: []byte("folder/index.md"), Resources: make([]*domain.Resource, 0)},
},
"both-file": {
input: path.Join("both"),
expect: &domain.Page{Content: []byte("both.md"), Files: make([]*domain.File, 0)},
expect: &domain.Page{Content: []byte("both.md"), Resources: make([]*domain.Resource, 0)},
},
"both-folder": {
input: path.Join("both", "index"),
expect: &domain.Page{Content: []byte("both/index.md"), Files: make([]*domain.File, 0)},
expect: &domain.Page{Content: []byte("both/index.md"), Resources: make([]*domain.Resource, 0)},
},
} {
name, tc := name, tc

View File

@ -4,7 +4,6 @@ import (
"context"
"fmt"
"path"
"slices"
"golang.org/x/text/language"
@ -50,19 +49,7 @@ func (ucase *pageUseCase) Do(ctx context.Context, lang language.Tag, p string) (
continue
}
if out.Files, _, err = ucase.statics.Fetch(ctx, path.Dir(targets[i])); err != nil {
return out, nil
}
for j := 0; j < len(out.Files); j++ {
if ext := out.Files[j].Ext(); ext != "html" && ext != "md" {
continue
}
out.Files = slices.Delete(out.Files, j, j+1)
j--
}
out.Resources, _, _ = ucase.statics.Fetch(ctx, path.Dir(targets[i]))
return out, nil
}

View File

@ -3,7 +3,6 @@ package usecase
import (
"context"
"fmt"
"slices"
"golang.org/x/text/language"
@ -30,17 +29,7 @@ func (ucase *siteUseCase) Do(ctx context.Context, lang language.Tag) (*domain.Si
return nil, fmt.Errorf("cannot find base site data: %w", err)
}
if out.Files, _, err = ucase.statics.Fetch(ctx, "."); err == nil {
for i := 0; i < len(out.Files); i++ {
if ext := out.Files[i].Ext(); ext != "html" && ext != "md" {
continue
}
out.Files = slices.Delete(out.Files, i, i+1)
i--
}
}
out.Resources, _, _ = ucase.statics.Fetch(ctx, ".")
sub, err := ucase.sites.Get(ctx, lang)
if err != nil {

View File

@ -8,8 +8,11 @@ import (
type (
Repository interface {
Get(ctx context.Context, path string) (*domain.File, error)
Fetch(ctx context.Context, dir string) (domain.Files, int, error)
// Get returns Resource on path if exists
Get(ctx context.Context, path string) (*domain.Resource, error)
// Fetch returns all resources from dir recursevly.
Fetch(ctx context.Context, dir string) (domain.Resources, int, error)
}
dummyRepository struct{}
@ -19,8 +22,10 @@ func NewDummyRepository() dummyRepository {
return dummyRepository{}
}
func (dummyRepository) Get(ctx context.Context, path string) (*domain.File, error) { return nil, nil }
func (dummyRepository) Get(ctx context.Context, path string) (*domain.Resource, error) {
return nil, nil
}
func (dummyRepository) Fetch(ctx context.Context, dir string) (domain.Files, int, error) {
func (dummyRepository) Fetch(ctx context.Context, dir string) (domain.Resources, int, error) {
return nil, 0, nil
}

View File

@ -1,17 +1,13 @@
package fs
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"io/fs"
"path/filepath"
"strings"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/webp"
@ -30,10 +26,10 @@ func NewFileServerStaticRepository(root fs.FS) static.Repository {
}
}
func (repo *fileServerStaticRepository) Get(ctx context.Context, p string) (*domain.File, error) {
func (repo *fileServerStaticRepository) Get(ctx context.Context, p string) (*domain.Resource, error) {
info, err := fs.Stat(repo.root, p)
if err != nil {
return nil, fmt.Errorf("cannot read static info on path '%s': %w", p, err)
return nil, fmt.Errorf("cannot stat static on path '%s': %w", p, err)
}
f, err := repo.root.Open(p)
@ -47,46 +43,31 @@ func (repo *fileServerStaticRepository) Get(ctx context.Context, p string) (*dom
return nil, fmt.Errorf("cannot read static content on path '%s': %w", p, err)
}
out := &domain.File{
Path: p,
Updated: info.ModTime(),
Content: content,
}
parts := strings.Split(out.MediaType(), "/")
if len(parts) < 2 || parts[0] != "image" {
return out, nil
}
config, _, err := image.DecodeConfig(bytes.NewReader(content))
if err != nil {
return out, nil
}
out.Width, out.Height = config.Width, config.Height
return out, nil
return domain.NewResource(info.ModTime(), content, p), nil
}
func (repo *fileServerStaticRepository) Fetch(ctx context.Context, d string) (domain.Files, int, error) {
entries, err := fs.ReadDir(repo.root, d)
if err != nil {
return nil, 0, fmt.Errorf("cannot read directory on path '%s': %w", d, err)
func (repo *fileServerStaticRepository) Fetch(ctx context.Context, dir string) (domain.Resources, int, error) {
targets := make([]string, 0)
if err := fs.WalkDir(repo.root, dir, func(path string, de fs.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("received error while walking through '%s': %w", dir, err)
}
if de.IsDir() {
return nil
}
targets = append(targets, path)
return nil
}); err != nil {
return nil, 0, fmt.Errorf("cannot read directory on path '%s': %w", dir, err)
}
out := make(domain.Files, 0, len(entries))
out := make(domain.Resources, len(targets))
for _, entry := range entries {
if entry.IsDir() {
continue
}
f, err := repo.Get(ctx, filepath.Join(d, entry.Name()))
if err != nil {
continue
}
out = append(out, f)
for i := range targets {
out[i], _ = repo.Get(ctx, targets[i])
}
return out, len(out), nil

View File

@ -7,5 +7,5 @@ import (
)
type UseCase interface {
Do(ctx context.Context, path string) (*domain.File, error)
Do(ctx context.Context, path string) (*domain.Resource, error)
}

View File

@ -21,7 +21,7 @@ func NewStaticUseCase(statics static.Repository) static.UseCase {
}
}
func (ucase *staticUseCase) Do(ctx context.Context, p string) (*domain.File, error) {
func (ucase *staticUseCase) Do(ctx context.Context, p string) (*domain.Resource, error) {
p = strings.TrimPrefix(path.Clean(p), "/")
if ext := path.Ext(p); ext == ".html" || ext == ".md" {

View File

@ -5,7 +5,6 @@
package main
import (
"bytes"
"context"
"errors"
"flag"
@ -104,7 +103,7 @@ func NewApp(ctx context.Context, config *domain.Config) (*App, error) {
return
}
f, err := staticer.Do(r.Context(), r.URL.Path)
res, err := staticer.Do(r.Context(), r.URL.Path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
http.Error(w, err.Error(), http.StatusNotFound)
@ -117,7 +116,7 @@ func NewApp(ctx context.Context, config *domain.Config) (*App, error) {
return
}
http.ServeContent(w, r, f.LogicalName(), f.Updated, bytes.NewReader(f.Content))
http.ServeContent(w, r, res.Name(), domain.ResourceModTime(res), res)
return
}