add images from memos
This commit is contained in:
@@ -68,7 +68,7 @@ func main() {
|
|||||||
memorySvc := memory.NewService(selector, store, loc, logger)
|
memorySvc := memory.NewService(selector, store, loc, logger)
|
||||||
|
|
||||||
// Web handler
|
// Web handler
|
||||||
handler := web.NewHandler(memorySvc, logger)
|
handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, logger)
|
||||||
|
|
||||||
// HTTP server
|
// HTTP server
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ url = ""
|
|||||||
# Создаётся в Memos: Settings → Access Tokens.
|
# Создаётся в Memos: Settings → Access Tokens.
|
||||||
token = ""
|
token = ""
|
||||||
|
|
||||||
|
# Публичный адрес Memos (для ссылок на оригинальные заметки).
|
||||||
|
# Если Memos и Remembos развёрнуты на одном сервере, внутренний адрес (url)
|
||||||
|
# может отличаться от публичного. Если не указан — используется url.
|
||||||
|
# Пример: "https://memos.example.com"
|
||||||
|
public_url = ""
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# База данных (SQLite)
|
# База данных (SQLite)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MemosConfig struct {
|
type MemosConfig struct {
|
||||||
URL string `toml:"url"`
|
URL string `toml:"url"`
|
||||||
Token string `toml:"token"`
|
Token string `toml:"token"`
|
||||||
|
PublicURL string `toml:"public_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
|
|||||||
+39
-11
@@ -5,17 +5,45 @@ import "time"
|
|||||||
// Memo represents a memo from the Memos API.
|
// Memo represents a memo from the Memos API.
|
||||||
// JSON field names follow protojson camelCase convention.
|
// JSON field names follow protojson camelCase convention.
|
||||||
type Memo struct {
|
type Memo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Creator string `json:"creator"`
|
Creator string `json:"creator"`
|
||||||
CreateTime time.Time `json:"createTime"`
|
CreateTime time.Time `json:"createTime"`
|
||||||
UpdateTime time.Time `json:"updateTime"`
|
UpdateTime time.Time `json:"updateTime"`
|
||||||
DisplayTime time.Time `json:"displayTime"`
|
DisplayTime time.Time `json:"displayTime"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Visibility string `json:"visibility"`
|
Visibility string `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
Snippet string `json:"snippet"`
|
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.
|
// ListMemosResponse is the response from GET /api/v1/memos.
|
||||||
|
|||||||
+42
-7
@@ -6,6 +6,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/remembos/internal/memory"
|
"git.vakhrushev.me/av/remembos/internal/memory"
|
||||||
@@ -16,11 +17,18 @@ var templateFS embed.FS
|
|||||||
|
|
||||||
var templates = template.Must(template.ParseFS(templateFS, "templates/*.html"))
|
var templates = template.Must(template.ParseFS(templateFS, "templates/*.html"))
|
||||||
|
|
||||||
|
type imageData struct {
|
||||||
|
URL string
|
||||||
|
Alt string
|
||||||
|
}
|
||||||
|
|
||||||
type memoryData struct {
|
type memoryData struct {
|
||||||
DateFormatted string
|
DateFormatted string
|
||||||
AgoText string
|
AgoText string
|
||||||
Content string
|
Content string
|
||||||
Tags []string
|
Tags []string
|
||||||
|
Images []imageData
|
||||||
|
MemoURL string
|
||||||
Tier int
|
Tier int
|
||||||
ShowCount int
|
ShowCount int
|
||||||
}
|
}
|
||||||
@@ -30,16 +38,24 @@ type errorData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *memory.Service
|
service *memory.Service
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
mux *http.ServeMux
|
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{
|
h := &Handler{
|
||||||
service: service,
|
service: service,
|
||||||
logger: logger,
|
memosURL: strings.TrimRight(memosURL, "/"),
|
||||||
mux: http.NewServeMux(),
|
publicURL: strings.TrimRight(pub, "/"),
|
||||||
|
logger: logger,
|
||||||
|
mux: http.NewServeMux(),
|
||||||
}
|
}
|
||||||
h.mux.HandleFunc("GET /", h.handleMemory)
|
h.mux.HandleFunc("GET /", h.handleMemory)
|
||||||
h.mux.HandleFunc("GET /health", h.handleHealth)
|
h.mux.HandleFunc("GET /health", h.handleHealth)
|
||||||
@@ -74,11 +90,30 @@ func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
data := memoryData{
|
||||||
DateFormatted: formatDate(mem.Date),
|
DateFormatted: formatDate(mem.Date),
|
||||||
AgoText: agoText(mem.Date),
|
AgoText: agoText(mem.Date),
|
||||||
Content: mem.Memo.Content,
|
Content: mem.Memo.Content,
|
||||||
Tags: mem.Memo.Tags,
|
Tags: mem.Memo.Tags,
|
||||||
|
Images: images,
|
||||||
|
MemoURL: memoURL,
|
||||||
Tier: mem.Tier,
|
Tier: mem.Tier,
|
||||||
ShowCount: mem.ShowCount,
|
ShowCount: mem.ShowCount,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,16 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.6;
|
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 {
|
.tags {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -60,6 +70,13 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
.meta a {
|
||||||
|
color: #aaa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.meta a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -70,13 +87,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="content">{{.Content}}</div>
|
<div class="content">{{.Content}}</div>
|
||||||
|
{{if .Images}}
|
||||||
|
<div class="images">
|
||||||
|
{{range .Images}}<img src="{{.URL}}" alt="{{.Alt}}" loading="lazy">{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{if .Tags}}
|
{{if .Tags}}
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
|
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">Tier {{.Tier}} · показов: {{.ShowCount}}</div>
|
<div class="meta">
|
||||||
|
Tier {{.Tier}} · показов: {{.ShowCount}}
|
||||||
|
{{if .MemoURL}} · <a href="{{.MemoURL}}" target="_blank" rel="noopener">оригинал</a>{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user