diff --git a/cmd/remembos/main.go b/cmd/remembos/main.go index 5e1eed2..05e981a 100644 --- a/cmd/remembos/main.go +++ b/cmd/remembos/main.go @@ -68,7 +68,7 @@ func main() { memorySvc := memory.NewService(selector, store, loc, logger) // Web handler - handler := web.NewHandler(memorySvc, logger) + handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, logger) // HTTP server srv := &http.Server{ diff --git a/config.dist.toml b/config.dist.toml index a79e396..d09d095 100644 --- a/config.dist.toml +++ b/config.dist.toml @@ -16,6 +16,12 @@ url = "" # Создаётся в Memos: Settings → Access Tokens. token = "" +# Публичный адрес Memos (для ссылок на оригинальные заметки). +# Если Memos и Remembos развёрнуты на одном сервере, внутренний адрес (url) +# может отличаться от публичного. Если не указан — используется url. +# Пример: "https://memos.example.com" +public_url = "" + # ============================================================================= # База данных (SQLite) # ============================================================================= diff --git a/internal/config/config.go b/internal/config/config.go index e91bdf6..48772b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,8 +17,9 @@ type Config struct { } type MemosConfig struct { - URL string `toml:"url"` - Token string `toml:"token"` + URL string `toml:"url"` + Token string `toml:"token"` + PublicURL string `toml:"public_url"` } type DatabaseConfig struct { diff --git a/internal/memos/types.go b/internal/memos/types.go index 89a133b..6fc7e59 100644 --- a/internal/memos/types.go +++ b/internal/memos/types.go @@ -5,17 +5,45 @@ import "time" // Memo represents a memo from the Memos API. // JSON field names follow protojson camelCase convention. type Memo struct { - Name string `json:"name"` - State string `json:"state"` - Creator string `json:"creator"` - CreateTime time.Time `json:"createTime"` - UpdateTime time.Time `json:"updateTime"` - DisplayTime time.Time `json:"displayTime"` - Content string `json:"content"` - Visibility string `json:"visibility"` - Tags []string `json:"tags"` - Pinned bool `json:"pinned"` - Snippet string `json:"snippet"` + Name string `json:"name"` + State string `json:"state"` + Creator string `json:"creator"` + CreateTime time.Time `json:"createTime"` + UpdateTime time.Time `json:"updateTime"` + DisplayTime time.Time `json:"displayTime"` + Content string `json:"content"` + Visibility string `json:"visibility"` + Tags []string `json:"tags"` + Pinned bool `json:"pinned"` + Snippet string `json:"snippet"` + Attachments []Attachment `json:"attachments"` +} + +// Attachment represents a file attached to a memo. +type Attachment struct { + Name string `json:"name"` // "attachments/{uid}" + Filename string `json:"filename"` + Type string `json:"type"` // MIME type + Size int64 `json:"size,string"` + ExternalLink string `json:"externalLink"` +} + +// IsImage returns true if the attachment is an image. +func (a Attachment) IsImage() bool { + switch a.Type { + case "image/png", "image/jpeg", "image/gif", "image/webp", "image/heic", "image/heif", "image/svg+xml": + return true + } + return false +} + +// UID extracts the uid part from "attachments/{uid}". +func (a Attachment) UID() string { + const prefix = "attachments/" + if len(a.Name) > len(prefix) { + return a.Name[len(prefix):] + } + return a.Name } // ListMemosResponse is the response from GET /api/v1/memos. diff --git a/internal/web/handler.go b/internal/web/handler.go index 960bbea..40dc235 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -6,6 +6,7 @@ import ( "html/template" "log/slog" "net/http" + "strings" "time" "git.vakhrushev.me/av/remembos/internal/memory" @@ -16,11 +17,18 @@ var templateFS embed.FS var templates = template.Must(template.ParseFS(templateFS, "templates/*.html")) +type imageData struct { + URL string + Alt string +} + type memoryData struct { DateFormatted string AgoText string Content string Tags []string + Images []imageData + MemoURL string Tier int ShowCount int } @@ -30,16 +38,24 @@ type errorData struct { } type Handler struct { - service *memory.Service - logger *slog.Logger - mux *http.ServeMux + service *memory.Service + logger *slog.Logger + mux *http.ServeMux + memosURL string // internal Memos URL (for attachment files) + publicURL string // public Memos URL (for memo links) } -func NewHandler(service *memory.Service, logger *slog.Logger) *Handler { +func NewHandler(service *memory.Service, memosURL, publicURL string, logger *slog.Logger) *Handler { + pub := publicURL + if pub == "" { + pub = memosURL + } h := &Handler{ - service: service, - logger: logger, - mux: http.NewServeMux(), + service: service, + memosURL: strings.TrimRight(memosURL, "/"), + publicURL: strings.TrimRight(pub, "/"), + logger: logger, + mux: http.NewServeMux(), } h.mux.HandleFunc("GET /", h.handleMemory) h.mux.HandleFunc("GET /health", h.handleHealth) @@ -74,11 +90,30 @@ func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) { return } + var images []imageData + for _, att := range mem.Memo.Attachments { + if !att.IsImage() { + continue + } + var imgURL string + if att.ExternalLink != "" { + imgURL = att.ExternalLink + } else { + imgURL = fmt.Sprintf("%s/file/%s/%s", h.memosURL, att.Name, att.Filename) + } + images = append(images, imageData{URL: imgURL, Alt: att.Filename}) + } + + // Link to original memo: {publicURL}/{memoName} + memoURL := fmt.Sprintf("%s/%s", h.publicURL, mem.Memo.Name) + data := memoryData{ DateFormatted: formatDate(mem.Date), AgoText: agoText(mem.Date), Content: mem.Memo.Content, Tags: mem.Memo.Tags, + Images: images, + MemoURL: memoURL, Tier: mem.Tier, ShowCount: mem.ShowCount, } diff --git a/internal/web/templates/memory.html b/internal/web/templates/memory.html index 840c3ff..9176d3c 100644 --- a/internal/web/templates/memory.html +++ b/internal/web/templates/memory.html @@ -42,6 +42,16 @@ font-size: 1rem; line-height: 1.6; } + .images { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + } + .images img { + max-width: 100%; + border-radius: 6px; + } .tags { margin-top: 1rem; display: flex; @@ -60,6 +70,13 @@ font-size: 0.8rem; color: #aaa; } + .meta a { + color: #aaa; + text-decoration: none; + } + .meta a:hover { + text-decoration: underline; + } @@ -70,13 +87,21 @@
{{.Content}}
+ {{if .Images}} +
+ {{range .Images}}{{.Alt}}{{end}} +
+ {{end}} {{if .Tags}}
{{range .Tags}}#{{.}}{{end}}
{{end}}
-
Tier {{.Tier}} · показов: {{.ShowCount}}
+
+ Tier {{.Tier}} · показов: {{.ShowCount}} + {{if .MemoURL}} · оригинал{{end}} +