241 lines
6.8 KiB
Go
241 lines
6.8 KiB
Go
package sharedassets
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ModulePromptFile maps a module-owned embedded prompt file into the
|
|
// Scriptorium-visible module prompt directory.
|
|
type ModulePromptFile struct {
|
|
Name string
|
|
Path string
|
|
}
|
|
|
|
// SharedPromptFile maps a caller-owned shared prompt file into a module's
|
|
// Scriptorium-visible sharedassets prompt subdirectory.
|
|
type SharedPromptFile struct {
|
|
Name string
|
|
FS fs.FS
|
|
Path string
|
|
}
|
|
|
|
// ModulePromptFS builds a prompt filesystem for a module directory from
|
|
// module-owned prompt files plus caller-provided shared prompt files under the
|
|
// module's sharedassets subdirectory.
|
|
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, sharedFiles ...SharedPromptFile) (fs.FS, error) {
|
|
cleanModuleDir, err := cleanPromptPath(moduleDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("module prompt directory: %w", err)
|
|
}
|
|
if moduleFS == nil {
|
|
return nil, fmt.Errorf("module prompt filesystem must not be nil")
|
|
}
|
|
assets := make(promptMapFS, len(files)+len(sharedFiles))
|
|
for _, file := range files {
|
|
name, err := cleanPromptPath(file.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("module prompt file name %q: %w", file.Name, err)
|
|
}
|
|
if strings.Contains(name, "/") {
|
|
return nil, fmt.Errorf("module prompt file name %q must not contain path separators", file.Name)
|
|
}
|
|
filePath, err := cleanPromptPath(file.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("module prompt file path %q: %w", file.Path, err)
|
|
}
|
|
data, err := fs.ReadFile(moduleFS, filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read module prompt asset %s: %w", filePath, err)
|
|
}
|
|
assets["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...)
|
|
}
|
|
|
|
for _, file := range sharedFiles {
|
|
name, err := cleanPromptPath(file.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("shared prompt file name %q: %w", file.Name, err)
|
|
}
|
|
if strings.Contains(name, "/") {
|
|
return nil, fmt.Errorf("shared prompt file name %q must not contain path separators", file.Name)
|
|
}
|
|
if file.FS == nil {
|
|
return nil, fmt.Errorf("shared prompt file %q filesystem must not be nil", file.Name)
|
|
}
|
|
filePath, err := cleanPromptPath(file.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("shared prompt file path %q: %w", file.Path, err)
|
|
}
|
|
data, err := fs.ReadFile(file.FS, filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read shared prompt asset %s: %w", filePath, err)
|
|
}
|
|
assets["assets/prompts/"+cleanModuleDir+"/sharedassets/"+name] = append([]byte(nil), data...)
|
|
}
|
|
return assets, nil
|
|
}
|
|
|
|
type promptMapFS map[string][]byte
|
|
|
|
func (m promptMapFS) Open(name string) (fs.File, error) {
|
|
cleaned, err := cleanPromptFSPath(name)
|
|
if err != nil {
|
|
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
|
|
}
|
|
if data, ok := m[cleaned]; ok {
|
|
return &promptFile{
|
|
reader: bytes.NewReader(data),
|
|
info: promptFileInfo{name: path.Base(cleaned), size: int64(len(data))},
|
|
}, nil
|
|
}
|
|
entries := m.dirEntries(cleaned)
|
|
if entries != nil {
|
|
return &promptDir{name: path.Base(cleaned), entries: entries}, nil
|
|
}
|
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
|
}
|
|
|
|
func (m promptMapFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
|
cleaned, err := cleanPromptFSPath(name)
|
|
if err != nil {
|
|
return nil, &fs.PathError{Op: "readdir", Path: name, Err: err}
|
|
}
|
|
entries := m.dirEntries(cleaned)
|
|
if entries == nil {
|
|
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (m promptMapFS) dirEntries(dir string) []fs.DirEntry {
|
|
children := map[string]promptDirEntry{}
|
|
prefix := ""
|
|
if dir != "." {
|
|
prefix = dir + "/"
|
|
}
|
|
for name, data := range m {
|
|
if !strings.HasPrefix(name, prefix) {
|
|
continue
|
|
}
|
|
rest := strings.TrimPrefix(name, prefix)
|
|
if rest == "" {
|
|
continue
|
|
}
|
|
childName, _, hasSlash := strings.Cut(rest, "/")
|
|
entry := promptDirEntry{name: childName, dir: hasSlash}
|
|
if !hasSlash {
|
|
entry.size = int64(len(data))
|
|
}
|
|
children[childName] = entry
|
|
}
|
|
if len(children) == 0 {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(children))
|
|
for name := range children {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
entries := make([]fs.DirEntry, 0, len(names))
|
|
for _, name := range names {
|
|
entries = append(entries, children[name])
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func cleanPromptPath(name string) (string, error) {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("path must not be empty")
|
|
}
|
|
cleaned := path.Clean(strings.TrimPrefix(trimmed, "/"))
|
|
if cleaned == "." || !fs.ValidPath(cleaned) {
|
|
return "", fmt.Errorf("invalid path %q", name)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
func cleanPromptFSPath(name string) (string, error) {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("path must not be empty")
|
|
}
|
|
cleaned := path.Clean(strings.TrimPrefix(trimmed, "/"))
|
|
if cleaned == "." {
|
|
return cleaned, nil
|
|
}
|
|
if !fs.ValidPath(cleaned) {
|
|
return "", fmt.Errorf("invalid path %q", name)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
type promptFile struct {
|
|
reader *bytes.Reader
|
|
info promptFileInfo
|
|
}
|
|
|
|
func (f *promptFile) Stat() (fs.FileInfo, error) { return f.info, nil }
|
|
func (f *promptFile) Read(p []byte) (int, error) { return f.reader.Read(p) }
|
|
func (f *promptFile) Close() error { return nil }
|
|
|
|
type promptDir struct {
|
|
name string
|
|
offset int
|
|
entries []fs.DirEntry
|
|
}
|
|
|
|
func (d *promptDir) Stat() (fs.FileInfo, error) { return promptFileInfo{name: d.name, dir: true}, nil }
|
|
func (d *promptDir) Read([]byte) (int, error) { return 0, io.EOF }
|
|
func (d *promptDir) Close() error { return nil }
|
|
func (d *promptDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
|
if d.offset >= len(d.entries) {
|
|
return nil, io.EOF
|
|
}
|
|
end := len(d.entries)
|
|
if n > 0 && d.offset+n < end {
|
|
end = d.offset + n
|
|
}
|
|
out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...)
|
|
d.offset = end
|
|
return out, nil
|
|
}
|
|
|
|
type promptDirEntry struct {
|
|
name string
|
|
dir bool
|
|
size int64
|
|
}
|
|
|
|
func (e promptDirEntry) Name() string { return e.name }
|
|
func (e promptDirEntry) IsDir() bool { return e.dir }
|
|
func (e promptDirEntry) Type() fs.FileMode { return e.fileInfoMode().Type() }
|
|
func (e promptDirEntry) Info() (fs.FileInfo, error) {
|
|
return promptFileInfo{name: e.name, dir: e.dir, size: e.size}, nil
|
|
}
|
|
func (e promptDirEntry) fileInfoMode() fs.FileMode {
|
|
if e.dir {
|
|
return fs.ModeDir | 0o555
|
|
}
|
|
return 0o444
|
|
}
|
|
|
|
type promptFileInfo struct {
|
|
name string
|
|
dir bool
|
|
size int64
|
|
}
|
|
|
|
func (i promptFileInfo) Name() string { return i.name }
|
|
func (i promptFileInfo) Size() int64 { return i.size }
|
|
func (i promptFileInfo) Mode() fs.FileMode { return promptDirEntry{dir: i.dir}.fileInfoMode() }
|
|
func (i promptFileInfo) ModTime() time.Time { return time.Time{} }
|
|
func (i promptFileInfo) IsDir() bool { return i.dir }
|
|
func (i promptFileInfo) Sys() any { return nil }
|