Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2ca838a50f
|
|||
|
2c6e71bad5
|
|||
|
738dfda7a0
|
+1
-4
@@ -14,11 +14,8 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/remembos ./cmd/rememb
|
|||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
||||||
RUN apk add --no-cache imagemagick \
|
RUN apk add --no-cache imagemagick tzdata
|
||||||
&& adduser -D -H appuser
|
|
||||||
|
|
||||||
COPY --from=build /out/remembos /remembos
|
COPY --from=build /out/remembos /remembos
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
ENTRYPOINT ["/remembos"]
|
ENTRYPOINT ["/remembos"]
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func main() {
|
|||||||
selector := search.NewSelector(client, store, &cfg.Search, loc, logger)
|
selector := search.NewSelector(client, store, &cfg.Search, loc, logger)
|
||||||
|
|
||||||
// Memory service
|
// Memory service
|
||||||
memorySvc := memory.NewService(selector, store, loc, logger)
|
memorySvc := memory.NewService(selector, store, client, loc, logger)
|
||||||
|
|
||||||
// Web handler
|
// Web handler
|
||||||
handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger)
|
handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/remembos/internal/memos"
|
||||||
"git.vakhrushev.me/av/remembos/internal/search"
|
"git.vakhrushev.me/av/remembos/internal/search"
|
||||||
"git.vakhrushev.me/av/remembos/internal/storage"
|
"git.vakhrushev.me/av/remembos/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
selector *search.Selector
|
selector *search.Selector
|
||||||
store *storage.Storage
|
store *storage.Storage
|
||||||
|
client *memos.Client
|
||||||
loc *time.Location
|
loc *time.Location
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
@@ -22,16 +24,18 @@ type Service struct {
|
|||||||
cached *search.Memory
|
cached *search.Memory
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(selector *search.Selector, store *storage.Storage, loc *time.Location, logger *slog.Logger) *Service {
|
func NewService(selector *search.Selector, store *storage.Storage, client *memos.Client, loc *time.Location, logger *slog.Logger) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
selector: selector,
|
selector: selector,
|
||||||
store: store,
|
store: store,
|
||||||
|
client: client,
|
||||||
loc: loc,
|
loc: loc,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTodayMemory returns the memory for today, caching the result.
|
// GetTodayMemory returns the memory for today, caching the result.
|
||||||
|
// On cache miss (e.g. after restart), it checks the DB for a memo already shown today.
|
||||||
func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) {
|
func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) {
|
||||||
today := time.Now().In(s.loc)
|
today := time.Now().In(s.loc)
|
||||||
dayKey := today.Format("2006-01-02")
|
dayKey := today.Format("2006-01-02")
|
||||||
@@ -44,6 +48,38 @@ func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) {
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Try to restore from DB — check if a memo was already shown today
|
||||||
|
dayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.loc)
|
||||||
|
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
memoName, tier, err := s.store.GetLastShownToday(ctx, dayStart.Unix(), dayEnd.Unix())
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to check today's show history", "error", err)
|
||||||
|
// Fall through to select a new one
|
||||||
|
}
|
||||||
|
|
||||||
|
if memoName != "" {
|
||||||
|
s.logger.Info("restoring today's memory from history", "memo", memoName)
|
||||||
|
|
||||||
|
memo, err := s.client.GetMemo(ctx, memoName)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to fetch memo from history, selecting new", "memo", memoName, "error", err)
|
||||||
|
} else {
|
||||||
|
mem := &search.Memory{
|
||||||
|
Memo: memo,
|
||||||
|
Tier: tier,
|
||||||
|
Date: memo.DisplayTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cacheDay = dayKey
|
||||||
|
s.cached = mem
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return mem, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.logger.Info("selecting new memory", "date", dayKey)
|
s.logger.Info("selecting new memory", "date", dayKey)
|
||||||
|
|
||||||
mem, err := s.selector.Select(ctx, today)
|
mem, err := s.selector.Select(ctx, today)
|
||||||
@@ -58,7 +94,6 @@ func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) {
|
|||||||
|
|
||||||
if err := s.store.RecordShow(ctx, mem.Memo.Name, mem.Tier); err != nil {
|
if err := s.store.RecordShow(ctx, mem.Memo.Name, mem.Tier); err != nil {
|
||||||
s.logger.Error("failed to record show", "error", err)
|
s.logger.Error("failed to record show", "error", err)
|
||||||
// Non-fatal: still return the memory
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -67,6 +67,34 @@ func (c *Client) ListMemos(ctx context.Context, filter string, pageSize int, pag
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMemo fetches a single memo by its resource name (e.g. "memos/123").
|
||||||
|
func (c *Client) GetMemo(ctx context.Context, name string) (*Memo, error) {
|
||||||
|
reqURL := fmt.Sprintf("%s/api/v1/%s", c.baseURL, name)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("do request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("memos API returned %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var memo Memo
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&memo); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode memo: %w", err)
|
||||||
|
}
|
||||||
|
return &memo, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DownloadAttachment downloads the attachment data as bytes.
|
// DownloadAttachment downloads the attachment data as bytes.
|
||||||
func (c *Client) DownloadAttachment(ctx context.Context, att Attachment) ([]byte, error) {
|
func (c *Client) DownloadAttachment(ctx context.Context, att Attachment) ([]byte, error) {
|
||||||
var reqURL string
|
var reqURL string
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -65,6 +67,21 @@ func (s *Storage) GetShowCounts(ctx context.Context, memoNames []string) (map[st
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLastShownToday returns the memo_name and tier of the most recently shown memo
|
||||||
|
// within the given time range [from, to). Returns empty string if nothing was shown.
|
||||||
|
func (s *Storage) GetLastShownToday(ctx context.Context, from, to int64) (memoName string, tier int, err error) {
|
||||||
|
err = s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT memo_name, tier FROM show_history WHERE shown_at >= ? AND shown_at < ? ORDER BY shown_at DESC LIMIT 1`,
|
||||||
|
from, to).Scan(&memoName, &tier)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", 0, nil
|
||||||
|
}
|
||||||
|
return "", 0, fmt.Errorf("get last shown today: %w", err)
|
||||||
|
}
|
||||||
|
return memoName, tier, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RecordShow records that a memo was shown.
|
// RecordShow records that a memo was shown.
|
||||||
func (s *Storage) RecordShow(ctx context.Context, memoName string, tier int) error {
|
func (s *Storage) RecordShow(ctx context.Context, memoName string, tier int) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
|||||||
@@ -29,16 +29,6 @@ func formatMemory(mem *search.Memory, publicURL string) string {
|
|||||||
// Content
|
// Content
|
||||||
b.WriteString(escapeHTML(mem.Memo.Content))
|
b.WriteString(escapeHTML(mem.Memo.Content))
|
||||||
|
|
||||||
// Tags
|
|
||||||
if len(mem.Memo.Tags) > 0 {
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
tags := make([]string, len(mem.Memo.Tags))
|
|
||||||
for i, t := range mem.Memo.Tags {
|
|
||||||
tags[i] = "#" + t
|
|
||||||
}
|
|
||||||
b.WriteString(strings.Join(tags, " "))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link to original
|
// Link to original
|
||||||
memoURL := fmt.Sprintf("%s/%s", publicURL, mem.Memo.Name)
|
memoURL := fmt.Sprintf("%s/%s", publicURL, mem.Memo.Name)
|
||||||
b.WriteString("\n\n<a href=\"" + memoURL + "\">Оригинал</a>")
|
b.WriteString("\n\n<a href=\"" + memoURL + "\">Оригинал</a>")
|
||||||
|
|||||||
Reference in New Issue
Block a user