diff --git a/.gitignore b/.gitignore index 0c26160..107a891 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ cache/ memos-source/ /config.toml +/remembos +*.db diff --git a/cmd/remembos/main.go b/cmd/remembos/main.go new file mode 100644 index 0000000..5e1eed2 --- /dev/null +++ b/cmd/remembos/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.vakhrushev.me/av/remembos/internal/config" + "git.vakhrushev.me/av/remembos/internal/memory" + "git.vakhrushev.me/av/remembos/internal/memos" + "git.vakhrushev.me/av/remembos/internal/search" + "git.vakhrushev.me/av/remembos/internal/storage" + "git.vakhrushev.me/av/remembos/internal/web" +) + +func main() { + configPath := flag.String("config", "config.toml", "path to config file") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + slog.Error("failed to load config", "error", err) + os.Exit(1) + } + + // Logger + var logLevel slog.Level + switch cfg.General.LogLevel { + case "debug": + logLevel = slog.LevelDebug + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) + slog.SetDefault(logger) + + // Timezone + loc, err := time.LoadLocation(cfg.General.Timezone) + if err != nil { + logger.Error("invalid timezone", "timezone", cfg.General.Timezone, "error", err) + os.Exit(1) + } + + // Storage + store, err := storage.Open(cfg.Database.Path) + if err != nil { + logger.Error("failed to open storage", "error", err) + os.Exit(1) + } + defer store.Close() + + // Memos client + client := memos.NewClient(cfg.Memos.URL, cfg.Memos.Token) + + // Search selector + selector := search.NewSelector(client, store, cfg.Search, loc, logger) + + // Memory service + memorySvc := memory.NewService(selector, store, loc, logger) + + // Web handler + handler := web.NewHandler(memorySvc, logger) + + // HTTP server + srv := &http.Server{ + Addr: cfg.Web.Listen, + Handler: handler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Graceful shutdown + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + logger.Info("starting server", "addr", cfg.Web.Listen) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("server error", "error", err) + os.Exit(1) + } + }() + + <-ctx.Done() + logger.Info("shutting down") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("shutdown error", "error", err) + } + + logger.Info("stopped") +} diff --git a/go.mod b/go.mod index 05cf84a..fe6ee92 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,18 @@ module git.vakhrushev.me/av/remembos go 1.25.0 + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.37.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.45.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..974170d --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= +modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e91bdf6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,134 @@ +package config + +import ( + "fmt" + "os" + + "github.com/BurntSushi/toml" +) + +type Config struct { + Memos MemosConfig `toml:"memos"` + Database DatabaseConfig `toml:"database"` + Search SearchConfig `toml:"search"` + Telegram TelegramConfig `toml:"telegram"` + Web WebConfig `toml:"web"` + General GeneralConfig `toml:"general"` +} + +type MemosConfig struct { + URL string `toml:"url"` + Token string `toml:"token"` +} + +type DatabaseConfig struct { + Path string `toml:"path"` +} + +type SearchConfig struct { + CooldownDays int `toml:"cooldown_days"` + RelaxedCooldownDays int `toml:"relaxed_cooldown_days"` + PageSize int `toml:"page_size"` + MaxYearsBack int `toml:"max_years_back"` + PreferOlder bool `toml:"prefer_older"` + TierWeights TierWeights `toml:"tier_weights"` +} + +type TierWeights struct { + Tier1 int `toml:"tier1"` + Tier2 int `toml:"tier2"` + Tier3 int `toml:"tier3"` + Tier4 int `toml:"tier4"` + Tier5 int `toml:"tier5"` + Tier6 int `toml:"tier6"` + Tier7 int `toml:"tier7"` +} + +func (tw TierWeights) Sum() int { + return tw.Tier1 + tw.Tier2 + tw.Tier3 + tw.Tier4 + tw.Tier5 + tw.Tier6 + tw.Tier7 +} + +func (tw TierWeights) AsSlice() [7]int { + return [7]int{tw.Tier1, tw.Tier2, tw.Tier3, tw.Tier4, tw.Tier5, tw.Tier6, tw.Tier7} +} + +type TelegramConfig struct { + Token string `toml:"token"` + ChatID int64 `toml:"chat_id"` + SendAt string `toml:"send_at"` +} + +type WebConfig struct { + Listen string `toml:"listen"` +} + +type GeneralConfig struct { + Timezone string `toml:"timezone"` + LogLevel string `toml:"log_level"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + var cfg Config + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + setDefaults(&cfg) + + if err := validate(&cfg); err != nil { + return nil, fmt.Errorf("validate config: %w", err) + } + + return &cfg, nil +} + +func setDefaults(cfg *Config) { + if cfg.Database.Path == "" { + cfg.Database.Path = "remembos.db" + } + if cfg.Search.CooldownDays == 0 { + cfg.Search.CooldownDays = 90 + } + if cfg.Search.RelaxedCooldownDays == 0 { + cfg.Search.RelaxedCooldownDays = 30 + } + if cfg.Search.PageSize == 0 { + cfg.Search.PageSize = 50 + } + if cfg.Search.MaxYearsBack == 0 { + cfg.Search.MaxYearsBack = 10 + } + if cfg.Search.TierWeights.Sum() == 0 { + cfg.Search.TierWeights = TierWeights{ + Tier1: 35, Tier2: 15, Tier3: 15, + Tier4: 12, Tier5: 10, Tier6: 5, Tier7: 8, + } + } + if cfg.Web.Listen == "" { + cfg.Web.Listen = "127.0.0.1:8080" + } + if cfg.General.Timezone == "" { + cfg.General.Timezone = "Europe/Moscow" + } + if cfg.General.LogLevel == "" { + cfg.General.LogLevel = "info" + } +} + +func validate(cfg *Config) error { + if cfg.Memos.URL == "" { + return fmt.Errorf("memos.url is required") + } + if cfg.Memos.Token == "" { + return fmt.Errorf("memos.token is required") + } + if sum := cfg.Search.TierWeights.Sum(); sum != 100 { + return fmt.Errorf("search.tier_weights must sum to 100, got %d", sum) + } + return nil +} diff --git a/internal/memory/service.go b/internal/memory/service.go new file mode 100644 index 0000000..f151713 --- /dev/null +++ b/internal/memory/service.go @@ -0,0 +1,70 @@ +package memory + +import ( + "context" + "log/slog" + "sync" + "time" + + "git.vakhrushev.me/av/remembos/internal/search" + "git.vakhrushev.me/av/remembos/internal/storage" +) + +// Service provides "one memory per day" with in-memory caching. +type Service struct { + selector *search.Selector + store *storage.Storage + loc *time.Location + logger *slog.Logger + + mu sync.Mutex + cacheDay string // "2006-01-02" + cached *search.Memory +} + +func NewService(selector *search.Selector, store *storage.Storage, loc *time.Location, logger *slog.Logger) *Service { + return &Service{ + selector: selector, + store: store, + loc: loc, + logger: logger, + } +} + +// GetTodayMemory returns the memory for today, caching the result. +func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) { + today := time.Now().In(s.loc) + dayKey := today.Format("2006-01-02") + + s.mu.Lock() + if s.cacheDay == dayKey && s.cached != nil { + mem := s.cached + s.mu.Unlock() + return mem, nil + } + s.mu.Unlock() + + s.logger.Info("selecting new memory", "date", dayKey) + + mem, err := s.selector.Select(ctx, today) + if err != nil { + return nil, err + } + + if mem == nil { + s.logger.Warn("no memory found for today") + return nil, nil + } + + if err := s.store.RecordShow(ctx, mem.Memo.Name, mem.Tier); err != nil { + s.logger.Error("failed to record show", "error", err) + // Non-fatal: still return the memory + } + + s.mu.Lock() + s.cacheDay = dayKey + s.cached = mem + s.mu.Unlock() + + return mem, nil +} diff --git a/internal/memos/client.go b/internal/memos/client.go new file mode 100644 index 0000000..a1e4d64 --- /dev/null +++ b/internal/memos/client.go @@ -0,0 +1,80 @@ +package memos + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +func NewClient(baseURL, token string) *Client { + return &Client{ + baseURL: baseURL, + token: token, + httpClient: &http.Client{}, + } +} + +// ListMemos fetches memos from the API with the given CEL filter. +func (c *Client) ListMemos(ctx context.Context, filter string, pageSize int, pageToken string) (*ListMemosResponse, error) { + u, err := url.Parse(c.baseURL + "/api/v1/memos") + if err != nil { + return nil, fmt.Errorf("parse url: %w", err) + } + + q := u.Query() + if filter != "" { + q.Set("filter", filter) + } + if pageSize > 0 { + q.Set("pageSize", strconv.Itoa(pageSize)) + } + if pageToken != "" { + q.Set("pageToken", pageToken) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + 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 result ListMemosResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &result, nil +} + +// GetRandomMemo fetches a single memo without any filter (for full fallback). +func (c *Client) GetRandomMemo(ctx context.Context) (*Memo, error) { + resp, err := c.ListMemos(ctx, "", 1, "") + if err != nil { + return nil, err + } + if len(resp.Memos) == 0 { + return nil, nil + } + return resp.Memos[0], nil +} diff --git a/internal/memos/types.go b/internal/memos/types.go new file mode 100644 index 0000000..89a133b --- /dev/null +++ b/internal/memos/types.go @@ -0,0 +1,32 @@ +package memos + +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"` +} + +// ListMemosResponse is the response from GET /api/v1/memos. +type ListMemosResponse struct { + Memos []*Memo `json:"memos"` + NextPageToken string `json:"nextPageToken"` +} + +func (r *ListMemosResponse) GetMemos() []*Memo { + if r == nil { + return nil + } + return r.Memos +} diff --git a/internal/search/scoring.go b/internal/search/scoring.go new file mode 100644 index 0000000..440ec2b --- /dev/null +++ b/internal/search/scoring.go @@ -0,0 +1,67 @@ +package search + +import ( + "math/rand/v2" + "time" + + "git.vakhrushev.me/av/remembos/internal/memos" +) + +// Memory is the result of the search algorithm. +type Memory struct { + Memo *memos.Memo + Tier int + YearsAgo int + ShowCount int + Date time.Time +} + +// candidate is an intermediate struct during scoring. +type candidate struct { + memo *memos.Memo + yearsAgo int + showCount int +} + +// weightedSelect picks one candidate using weighted random selection. +// preferOlder gives higher weight to older memos. maxYearsBack normalizes recency. +func weightedSelect(candidates []candidate, preferOlder bool, maxYearsBack int) *candidate { + if len(candidates) == 0 { + return nil + } + if len(candidates) == 1 { + return &candidates[0] + } + + scores := make([]float64, len(candidates)) + var total float64 + + for i, c := range candidates { + var recencyFactor float64 + if preferOlder && maxYearsBack > 0 && c.yearsAgo > 0 { + recencyFactor = float64(c.yearsAgo) / float64(maxYearsBack) + } else { + recencyFactor = 1.0 + } + + showCountPenalty := 1.0 / (1.0 + float64(c.showCount)) + + score := recencyFactor * showCountPenalty + if score < 0.01 { + score = 0.01 // minimum weight + } + scores[i] = score + total += score + } + + r := rand.Float64() * total + var cumulative float64 + for i, s := range scores { + cumulative += s + if r <= cumulative { + return &candidates[i] + } + } + + return &candidates[len(candidates)-1] +} diff --git a/internal/search/selector.go b/internal/search/selector.go new file mode 100644 index 0000000..7995779 --- /dev/null +++ b/internal/search/selector.go @@ -0,0 +1,264 @@ +package search + +import ( + "context" + "fmt" + "log/slog" + "math/rand/v2" + "sync" + "time" + + "git.vakhrushev.me/av/remembos/internal/config" + "git.vakhrushev.me/av/remembos/internal/memos" + "git.vakhrushev.me/av/remembos/internal/storage" +) + +// Selector implements the memory search algorithm. +type Selector struct { + client *memos.Client + store *storage.Storage + cfg config.SearchConfig + loc *time.Location + logger *slog.Logger +} + +func NewSelector(client *memos.Client, store *storage.Storage, cfg config.SearchConfig, loc *time.Location, logger *slog.Logger) *Selector { + return &Selector{ + client: client, + store: store, + cfg: cfg, + loc: loc, + logger: logger, + } +} + +// tierFunc returns date ranges for a given tier. +type tierFunc func(today time.Time, maxYears int, loc *time.Location) []DateRange + +// Select runs the full search algorithm and returns one Memory for the given day. +func (s *Selector) Select(ctx context.Context, today time.Time) (*Memory, error) { + weights := s.cfg.TierWeights.AsSlice() + tierFuncs := [7]func(time.Time) []DateRange{ + func(t time.Time) []DateRange { return tier1Ranges(t, s.cfg.MaxYearsBack, s.loc) }, + func(t time.Time) []DateRange { return tier2Ranges(t, s.loc) }, + func(t time.Time) []DateRange { return tier3Ranges(t, s.cfg.MaxYearsBack, s.loc) }, + func(t time.Time) []DateRange { return tier4Ranges(t, s.cfg.MaxYearsBack, s.loc) }, + func(t time.Time) []DateRange { return tier5Ranges(t, s.cfg.MaxYearsBack, s.loc) }, + func(t time.Time) []DateRange { return tier6Ranges(t, s.cfg.MaxYearsBack, s.loc) }, + func(t time.Time) []DateRange { return tier7Ranges(t, s.loc) }, + } + + // Build a weighted order of tiers to try + order := weightedTierOrder(weights) + + // Try with normal cooldown + mem, err := s.tryTiers(ctx, today, order, tierFuncs, s.cfg.CooldownDays) + if err != nil { + return nil, err + } + if mem != nil { + return mem, nil + } + + s.logger.Info("no candidates with normal cooldown, trying relaxed") + + // Try with relaxed cooldown + mem, err = s.tryTiers(ctx, today, order, tierFuncs, s.cfg.RelaxedCooldownDays) + if err != nil { + return nil, err + } + if mem != nil { + return mem, nil + } + + s.logger.Info("no candidates with relaxed cooldown, full fallback") + + // Full fallback: any memo + return s.fullFallback(ctx) +} + +func (s *Selector) tryTiers( + ctx context.Context, + today time.Time, + order []int, + tierFuncs [7]func(time.Time) []DateRange, + cooldownDays int, +) (*Memory, error) { + cooldownSet, err := s.store.GetCooldownMemoNames(ctx, cooldownDays) + if err != nil { + return nil, fmt.Errorf("get cooldown set: %w", err) + } + + for _, tierIdx := range order { + tier := tierIdx + 1 + ranges := tierFuncs[tierIdx](today) + if len(ranges) == 0 { + continue + } + + allMemos, err := s.fetchRanges(ctx, ranges) + if err != nil { + return nil, fmt.Errorf("fetch tier %d: %w", tier, err) + } + + // Filter by cooldown + var filtered []*memos.Memo + for _, m := range allMemos { + if _, blocked := cooldownSet[m.Name]; !blocked { + filtered = append(filtered, m) + } + } + + if len(filtered) == 0 { + s.logger.Debug("tier empty after cooldown filter", "tier", tier, "total", len(allMemos)) + continue + } + + // Get show counts for scoring + names := make([]string, len(filtered)) + for i, m := range filtered { + names[i] = m.Name + } + showCounts, err := s.store.GetShowCounts(ctx, names) + if err != nil { + return nil, fmt.Errorf("get show counts: %w", err) + } + + // Build candidates + candidates := make([]candidate, len(filtered)) + for i, m := range filtered { + yearsAgo := today.Year() - m.DisplayTime.Year() + if yearsAgo < 0 { + yearsAgo = 0 + } + candidates[i] = candidate{ + memo: m, + yearsAgo: yearsAgo, + showCount: showCounts[m.Name], + } + } + + picked := weightedSelect(candidates, s.cfg.PreferOlder, s.cfg.MaxYearsBack) + if picked == nil { + continue + } + + s.logger.Info("selected memory", + "tier", tier, + "memo", picked.memo.Name, + "years_ago", picked.yearsAgo, + ) + + return &Memory{ + Memo: picked.memo, + Tier: tier, + YearsAgo: picked.yearsAgo, + ShowCount: picked.showCount, + Date: picked.memo.DisplayTime, + }, nil + } + + return nil, nil +} + +// fetchRanges queries Memos API for all date ranges concurrently. +func (s *Selector) fetchRanges(ctx context.Context, ranges []DateRange) ([]*memos.Memo, error) { + type result struct { + memos []*memos.Memo + err error + } + + results := make([]result, len(ranges)) + var wg sync.WaitGroup + + // Limit concurrency to avoid overwhelming the API + sem := make(chan struct{}, 5) + + for i, dr := range ranges { + wg.Add(1) + go func(idx int, dr DateRange) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + filter := BuildCELFilter(dr) + resp, err := s.client.ListMemos(ctx, filter, s.cfg.PageSize, "") + results[idx] = result{ + memos: resp.GetMemos(), + err: err, + } + }(i, dr) + } + + wg.Wait() + + var all []*memos.Memo + for _, r := range results { + if r.err != nil { + return nil, r.err + } + all = append(all, r.memos...) + } + + return all, nil +} + +func (s *Selector) fullFallback(ctx context.Context) (*Memory, error) { + memo, err := s.client.GetRandomMemo(ctx) + if err != nil { + return nil, fmt.Errorf("full fallback: %w", err) + } + if memo == nil { + return nil, nil + } + return &Memory{ + Memo: memo, + Tier: 0, + Date: memo.DisplayTime, + }, nil +} + +// weightedTierOrder returns tier indices (0-based) shuffled by weight. +// Higher weight tiers come first, with randomization within the ordering. +func weightedTierOrder(weights [7]int) []int { + type entry struct { + idx int + weight int + } + entries := make([]entry, 7) + for i, w := range weights { + entries[i] = entry{idx: i, weight: w} + } + + // Shuffle using weighted random selection without replacement + order := make([]int, 0, 7) + remaining := make([]entry, len(entries)) + copy(remaining, entries) + + for len(remaining) > 0 { + totalWeight := 0 + for _, e := range remaining { + totalWeight += e.weight + } + if totalWeight == 0 { + // All remaining have zero weight, just append them + for _, e := range remaining { + order = append(order, e.idx) + } + break + } + + r := rand.IntN(totalWeight) + cumulative := 0 + for i, e := range remaining { + cumulative += e.weight + if r < cumulative { + order = append(order, e.idx) + remaining = append(remaining[:i], remaining[i+1:]...) + break + } + } + } + + return order +} diff --git a/internal/search/tiers.go b/internal/search/tiers.go new file mode 100644 index 0000000..ef067de --- /dev/null +++ b/internal/search/tiers.go @@ -0,0 +1,226 @@ +package search + +import ( + "fmt" + "time" +) + +// DateRange represents a time range [Start, End) in a specific year. +type DateRange struct { + Start time.Time + End time.Time + Year int // the year this range belongs to (for scoring) +} + +// BuildCELFilter creates a CEL filter string for a date range using created_ts. +func BuildCELFilter(dr DateRange) string { + return fmt.Sprintf("created_ts >= %d && created_ts < %d", + dr.Start.Unix(), dr.End.Unix()) +} + +func isLeapYear(year int) bool { + return year%4 == 0 && (year%100 != 0 || year%400 == 0) +} + +func daysInMonth(year int, month time.Month) int { + return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + +// tier1Ranges returns date ranges for the exact same day in previous years. +// Special handling for Feb 29 / Feb 28. +func tier1Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange { + month := today.Month() + day := today.Day() + currentYear := today.Year() + + var ranges []DateRange + + for y := currentYear - maxYears; y < currentYear; y++ { + if month == time.February && day == 29 { + // Today is Feb 29 — only include leap years + if !isLeapYear(y) { + continue + } + } + + // Check that this day exists in that year + if day > daysInMonth(y, month) { + continue + } + + start := time.Date(y, month, day, 0, 0, 0, 0, loc) + end := start.AddDate(0, 0, 1) + ranges = append(ranges, DateRange{Start: start, End: end, Year: y}) + } + + // Special: if today is Feb 28 in a non-leap year, also check Feb 29 in past leap years + if month == time.February && day == 28 && !isLeapYear(currentYear) { + for y := currentYear - maxYears; y < currentYear; y++ { + if isLeapYear(y) { + start := time.Date(y, time.February, 29, 0, 0, 0, 0, loc) + end := start.AddDate(0, 0, 1) + ranges = append(ranges, DateRange{Start: start, End: end, Year: y}) + } + } + } + + return ranges +} + +// tier2Ranges returns date ranges for the same day-of-month in previous months (last 24 months). +// Excludes the current month (covered by Tier 1). +func tier2Ranges(today time.Time, loc *time.Location) []DateRange { + day := today.Day() + currentYear := today.Year() + currentMonth := today.Month() + + var ranges []DateRange + + for i := 1; i <= 24; i++ { + t := time.Date(currentYear, currentMonth, 1, 0, 0, 0, 0, loc).AddDate(0, -i, 0) + y := t.Year() + m := t.Month() + + // Skip if this day doesn't exist in that month + if day > daysInMonth(y, m) { + continue + } + + start := time.Date(y, m, day, 0, 0, 0, 0, loc) + end := start.AddDate(0, 0, 1) + ranges = append(ranges, DateRange{Start: start, End: end, Year: y}) + } + + return ranges +} + +// tier3Ranges returns ±3 day ranges around the exact date in previous years, +// excluding the exact day itself (which is Tier 1). +func tier3Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange { + month := today.Month() + day := today.Day() + currentYear := today.Year() + + var ranges []DateRange + + for y := currentYear - maxYears; y < currentYear; y++ { + // The center date in that year + if day > daysInMonth(y, month) { + continue + } + center := time.Date(y, month, day, 0, 0, 0, 0, loc) + weekStart := center.AddDate(0, 0, -3) + weekEnd := center.AddDate(0, 0, 4) // +3 days inclusive = +4 exclusive + + // We need to exclude the center day: split into [weekStart, center) and [center+1day, weekEnd) + centerEnd := center.AddDate(0, 0, 1) + + ranges = append(ranges, + DateRange{Start: weekStart, End: center, Year: y}, + DateRange{Start: centerEnd, End: weekEnd, Year: y}, + ) + } + + return ranges +} + +// tier4Ranges returns the same month in previous years, +// excluding the ±3 day window around the exact date (covered by Tier 1+3). +func tier4Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange { + month := today.Month() + day := today.Day() + currentYear := today.Year() + + var ranges []DateRange + + for y := currentYear - maxYears; y < currentYear; y++ { + monthStart := time.Date(y, month, 1, 0, 0, 0, 0, loc) + monthEnd := time.Date(y, month+1, 1, 0, 0, 0, 0, loc) + + d := min(day, daysInMonth(y, month)) + center := time.Date(y, month, d, 0, 0, 0, 0, loc) + excludeStart := center.AddDate(0, 0, -3) + excludeEnd := center.AddDate(0, 0, 4) + + // Before the exclude window + if excludeStart.After(monthStart) { + ranges = append(ranges, DateRange{Start: monthStart, End: excludeStart, Year: y}) + } + // After the exclude window + if excludeEnd.Before(monthEnd) { + ranges = append(ranges, DateRange{Start: excludeEnd, End: monthEnd, Year: y}) + } + } + + return ranges +} + +// tier5Ranges returns the same quarter in previous years, excluding the current month. +func tier5Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange { + month := today.Month() + currentYear := today.Year() + + qStart := (month-1)/3*3 + 1 // first month of quarter (1, 4, 7, 10) + var quarterMonths [3]time.Month + for i := range 3 { + quarterMonths[i] = time.Month(int(qStart) + i) + } + + var ranges []DateRange + + for y := currentYear - maxYears; y < currentYear; y++ { + for _, m := range quarterMonths { + if m == month { + continue // exclude current month (covered by Tier 4) + } + start := time.Date(y, m, 1, 0, 0, 0, 0, loc) + end := time.Date(y, m+1, 1, 0, 0, 0, 0, loc) + ranges = append(ranges, DateRange{Start: start, End: end, Year: y}) + } + } + + return ranges +} + +// tier6Ranges returns the same half-year in previous years, excluding the current quarter. +func tier6Ranges(today time.Time, maxYears int, loc *time.Location) []DateRange { + month := today.Month() + currentYear := today.Year() + + // Half-year: H1 = Jan-Jun, H2 = Jul-Dec + var halfStart time.Month + if month <= 6 { + halfStart = 1 + } else { + halfStart = 7 + } + + // Current quarter start + qStart := (month-1)/3*3 + 1 + + var ranges []DateRange + + for y := currentYear - maxYears; y < currentYear; y++ { + for m := halfStart; m < halfStart+6; m++ { + // Skip months in the current quarter + if m >= qStart && m < qStart+3 { + continue + } + start := time.Date(y, m, 1, 0, 0, 0, 0, loc) + end := time.Date(y, m+1, 1, 0, 0, 0, 0, loc) + ranges = append(ranges, DateRange{Start: start, End: end, Year: y}) + } + } + + return ranges +} + +// tier7Ranges returns 2 to 6 months ago (recent past). +func tier7Ranges(today time.Time, loc *time.Location) []DateRange { + end := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -2, 0) + start := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -6, 0) + + return []DateRange{ + {Start: start, End: end, Year: 0}, // Year=0 means "recent" + } +} diff --git a/internal/storage/history.go b/internal/storage/history.go new file mode 100644 index 0000000..f1b5e1d --- /dev/null +++ b/internal/storage/history.go @@ -0,0 +1,77 @@ +package storage + +import ( + "context" + "fmt" + "strings" + "time" +) + +// GetCooldownMemoNames returns memo names shown within the last cooldownDays. +func (s *Storage) GetCooldownMemoNames(ctx context.Context, cooldownDays int) (map[string]struct{}, error) { + cutoff := time.Now().Unix() - int64(cooldownDays)*86400 + + rows, err := s.db.QueryContext(ctx, + `SELECT DISTINCT memo_name FROM show_history WHERE shown_at > ?`, cutoff) + if err != nil { + return nil, fmt.Errorf("query cooldown memos: %w", err) + } + defer rows.Close() + + result := make(map[string]struct{}) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan memo_name: %w", err) + } + result[name] = struct{}{} + } + return result, rows.Err() +} + +// GetShowCounts returns show_count for each of the given memo names. +// Memos not present in history will be absent from the result (count = 0). +func (s *Storage) GetShowCounts(ctx context.Context, memoNames []string) (map[string]int, error) { + if len(memoNames) == 0 { + return nil, nil + } + + placeholders := make([]string, len(memoNames)) + args := make([]any, len(memoNames)) + for i, name := range memoNames { + placeholders[i] = "?" + args[i] = name + } + + query := fmt.Sprintf( + `SELECT memo_name, COUNT(*) FROM show_history WHERE memo_name IN (%s) GROUP BY memo_name`, + strings.Join(placeholders, ",")) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query show counts: %w", err) + } + defer rows.Close() + + result := make(map[string]int) + for rows.Next() { + var name string + var count int + if err := rows.Scan(&name, &count); err != nil { + return nil, fmt.Errorf("scan show count: %w", err) + } + result[name] = count + } + return result, rows.Err() +} + +// RecordShow records that a memo was shown. +func (s *Storage) RecordShow(ctx context.Context, memoName string, tier int) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO show_history (memo_name, shown_at, tier) VALUES (?, ?, ?)`, + memoName, time.Now().Unix(), tier) + if err != nil { + return fmt.Errorf("insert show history: %w", err) + } + return nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..b24f92b --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,52 @@ +package storage + +import ( + "context" + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +type Storage struct { + db *sql.DB +} + +func Open(path string) (*Storage, error) { + db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("ping sqlite: %w", err) + } + + s := &Storage{db: db} + if err := s.migrate(context.Background()); err != nil { + db.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return s, nil +} + +func (s *Storage) Close() error { + return s.db.Close() +} + +func (s *Storage) migrate(ctx context.Context) error { + const ddl = ` + CREATE TABLE IF NOT EXISTS show_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memo_name TEXT NOT NULL, + shown_at INTEGER NOT NULL, + tier INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_show_history_memo ON show_history(memo_name); + CREATE INDEX IF NOT EXISTS idx_show_history_shown_at ON show_history(shown_at); + ` + _, err := s.db.ExecContext(ctx, ddl) + return err +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..960bbea --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,154 @@ +package web + +import ( + "embed" + "fmt" + "html/template" + "log/slog" + "net/http" + "time" + + "git.vakhrushev.me/av/remembos/internal/memory" +) + +//go:embed templates/*.html +var templateFS embed.FS + +var templates = template.Must(template.ParseFS(templateFS, "templates/*.html")) + +type memoryData struct { + DateFormatted string + AgoText string + Content string + Tags []string + Tier int + ShowCount int +} + +type errorData struct { + Message string +} + +type Handler struct { + service *memory.Service + logger *slog.Logger + mux *http.ServeMux +} + +func NewHandler(service *memory.Service, logger *slog.Logger) *Handler { + h := &Handler{ + service: service, + logger: logger, + mux: http.NewServeMux(), + } + h.mux.HandleFunc("GET /", h.handleMemory) + h.mux.HandleFunc("GET /health", h.handleHealth) + return h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.mux.ServeHTTP(w, r) +} + +func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + mem, err := h.service.GetTodayMemory(r.Context()) + if err != nil { + h.logger.Error("failed to get memory", "error", err) + w.WriteHeader(http.StatusInternalServerError) + templates.ExecuteTemplate(w, "error.html", errorData{ + Message: "Не удалось загрузить воспоминание", + }) + return + } + + if mem == nil { + w.WriteHeader(http.StatusOK) + templates.ExecuteTemplate(w, "error.html", errorData{ + Message: "Нет заметок для воспоминания", + }) + return + } + + data := memoryData{ + DateFormatted: formatDate(mem.Date), + AgoText: agoText(mem.Date), + Content: mem.Memo.Content, + Tags: mem.Memo.Tags, + Tier: mem.Tier, + ShowCount: mem.ShowCount, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := templates.ExecuteTemplate(w, "memory.html", data); err != nil { + h.logger.Error("template render failed", "error", err) + } +} + +func (h *Handler) handleHealth(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") +} + +var months = [...]string{ + 1: "января", 2: "февраля", 3: "марта", + 4: "апреля", 5: "мая", 6: "июня", + 7: "июля", 8: "августа", 9: "сентября", + 10: "октября", 11: "ноября", 12: "декабря", +} + +func formatDate(t time.Time) string { + return fmt.Sprintf("%d %s %d", t.Day(), months[t.Month()], t.Year()) +} + +func agoText(t time.Time) string { + now := time.Now() + years := now.Year() - t.Year() + monthsDiff := int(now.Month()) - int(t.Month()) + if monthsDiff < 0 { + years-- + monthsDiff += 12 + } + + if years > 0 { + return fmt.Sprintf("%s назад", pluralYears(years)) + } + if monthsDiff > 0 { + return fmt.Sprintf("%s назад", pluralMonths(monthsDiff)) + } + return "" +} + +func pluralYears(n int) string { + mod10 := n % 10 + mod100 := n % 100 + switch { + case mod100 >= 11 && mod100 <= 14: + return fmt.Sprintf("%d лет", n) + case mod10 == 1: + return fmt.Sprintf("%d год", n) + case mod10 >= 2 && mod10 <= 4: + return fmt.Sprintf("%d года", n) + default: + return fmt.Sprintf("%d лет", n) + } +} + +func pluralMonths(n int) string { + mod10 := n % 10 + mod100 := n % 100 + switch { + case mod100 >= 11 && mod100 <= 14: + return fmt.Sprintf("%d месяцев", n) + case mod10 == 1: + return fmt.Sprintf("%d месяц", n) + case mod10 >= 2 && mod10 <= 4: + return fmt.Sprintf("%d месяца", n) + default: + return fmt.Sprintf("%d месяцев", n) + } +} diff --git a/internal/web/templates/error.html b/internal/web/templates/error.html new file mode 100644 index 0000000..b733313 --- /dev/null +++ b/internal/web/templates/error.html @@ -0,0 +1,29 @@ + + +
+ + +