Separate interface typography from document text and enlarge controls across the workspace and public reader. Keep tree menus within the viewport and make constrained dialogs scrollable. Preserve nested image references, escape moved link paths, synchronize note creation and daily-note opening, and hydrate shared headings. Add regression coverage for settings and workspace behavior.
199 lines
4.8 KiB
Go
199 lines
4.8 KiB
Go
package api
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
)
|
|
|
|
var themeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
|
|
|
type themeManifest struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
//go:embed themes/white.css
|
|
var whiteThemeCSS string
|
|
|
|
//go:embed themes/black.css
|
|
var blackThemeCSS string
|
|
|
|
var builtinThemes = []struct {
|
|
themeManifest
|
|
css string
|
|
}{
|
|
{themeManifest{"white", "白色", "1"}, whiteThemeCSS},
|
|
{themeManifest{"black", "黑色", "1"}, blackThemeCSS},
|
|
}
|
|
|
|
func builtinTheme(id string) (themeManifest, string, bool) {
|
|
for _, theme := range builtinThemes {
|
|
if theme.ID == id {
|
|
return theme.themeManifest, theme.css, true
|
|
}
|
|
}
|
|
return themeManifest{}, "", false
|
|
}
|
|
|
|
func readThemeFile(root *os.Root, name string, limit int64) ([]byte, error) {
|
|
f, err := root.Open(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("主题文件必须为普通文件")
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(f, limit+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(data)) > limit {
|
|
return nil, fmt.Errorf("主题文件过大")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (s *Server) readTheme(id string) (themeManifest, string, error) {
|
|
if manifest, css, ok := builtinTheme(id); ok {
|
|
return manifest, css, nil
|
|
}
|
|
var manifest themeManifest
|
|
if !themeIDPattern.MatchString(id) || id == "default" || s.ThemesDir == "" {
|
|
return manifest, "", fs.ErrNotExist
|
|
}
|
|
root, err := os.OpenRoot(s.ThemesDir)
|
|
if err != nil {
|
|
return manifest, "", err
|
|
}
|
|
defer root.Close()
|
|
dir, err := root.OpenRoot(id)
|
|
if err != nil {
|
|
return manifest, "", err
|
|
}
|
|
defer dir.Close()
|
|
data, err := readThemeFile(dir, "manifest.json", 16<<10)
|
|
if err != nil {
|
|
return manifest, "", err
|
|
}
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
return manifest, "", fmt.Errorf("主题 %s 的 manifest.json 无法解析:%w", id, err)
|
|
}
|
|
if manifest.ID != id || manifest.Name == "" || manifest.Version == "" {
|
|
return manifest, "", fmt.Errorf("主题 %s 的 id、name 或 version 无效", id)
|
|
}
|
|
css, err := readThemeFile(dir, "theme.css", 256<<10)
|
|
return manifest, string(css), err
|
|
}
|
|
|
|
func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) {
|
|
themes := []themeManifest{{ID: "default", Name: "默认", Version: "1"}}
|
|
for _, theme := range builtinThemes {
|
|
themes = append(themes, theme.themeManifest)
|
|
}
|
|
if s.ThemesDir != "" {
|
|
entries, err := os.ReadDir(s.ThemesDir)
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
internalError(w, r, err)
|
|
return
|
|
}
|
|
for _, entry := range entries {
|
|
if _, _, ok := builtinTheme(entry.Name()); ok {
|
|
continue
|
|
}
|
|
if !entry.IsDir() || !themeIDPattern.MatchString(entry.Name()) || entry.Name() == "default" {
|
|
continue
|
|
}
|
|
manifest, _, err := s.readTheme(entry.Name())
|
|
if err != nil {
|
|
internalError(w, r, err)
|
|
return
|
|
}
|
|
themes = append(themes, manifest)
|
|
}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"themes": themes})
|
|
}
|
|
|
|
func (s *Server) getTheme(w http.ResponseWriter, r *http.Request) {
|
|
manifest, css, err := s.readTheme(r.PathValue("id"))
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
writeError(w, 404, "主题不存在或文件不完整")
|
|
return
|
|
}
|
|
if err != nil {
|
|
internalError(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, struct {
|
|
themeManifest
|
|
CSS string `json:"css"`
|
|
}{manifest, css})
|
|
}
|
|
|
|
// 只有有效分享的访客可读取外观;不返回账户、插件和其他应用设置。
|
|
func (s *Server) publicAppearance(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.openShare(w, r); !ok {
|
|
return
|
|
}
|
|
settings, err := s.readSettings()
|
|
if err != nil {
|
|
internalError(w, r, err)
|
|
return
|
|
}
|
|
out := map[string]any{}
|
|
for _, key := range []string{"colorMode", "uiFontSize", "fontSize", "contentWidth", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} {
|
|
if value, ok := settings[key]; ok {
|
|
out[key] = value
|
|
}
|
|
}
|
|
var snippets []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CSS string `json:"css"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if value, ok := settings["cssSnippets"]; ok {
|
|
if err := json.Unmarshal(value, &snippets); err != nil {
|
|
internalError(w, r, err)
|
|
return
|
|
}
|
|
}
|
|
active := snippets[:0]
|
|
for _, snippet := range snippets {
|
|
if snippet.Enabled {
|
|
active = append(active, snippet)
|
|
}
|
|
}
|
|
out["cssSnippets"] = active
|
|
var theme string
|
|
if value, ok := settings["theme"]; ok {
|
|
if err := json.Unmarshal(value, &theme); err != nil {
|
|
internalError(w, r, err)
|
|
return
|
|
}
|
|
}
|
|
if theme != "" && theme != "default" {
|
|
_, css, err := s.readTheme(theme)
|
|
if err != nil {
|
|
internalError(w, r, err)
|
|
return
|
|
}
|
|
out["themeCSS"] = css
|
|
}
|
|
out["theme"] = theme
|
|
writeJSON(w, 200, out)
|
|
}
|