add web service

This commit is contained in:
2026-02-12 11:10:40 +03:00
parent b6f922897c
commit ed04c11eff
16 changed files with 1414 additions and 0 deletions
+2
View File
@@ -3,3 +3,5 @@ cache/
memos-source/
/config.toml
/remembos
*.db
+105
View File
@@ -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")
}
+15
View File
@@ -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
)
+25
View File
@@ -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=
+134
View File
@@ -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
}
+70
View File
@@ -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
}
+80
View File
@@ -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
}
+32
View File
@@ -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
}
+67
View File
@@ -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]
}
+264
View File
@@ -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
}
+226
View File
@@ -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"
}
}
+77
View File
@@ -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
}
+52
View File
@@ -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
}
+154
View File
@@ -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)
}
}
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Remembos</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
padding: 2rem 1rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.message {
text-align: center;
color: #999;
font-size: 1.1rem;
}
</style>
</head>
<body>
<div class="message">{{.Message}}</div>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Воспоминание</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
padding: 2rem 1rem;
display: flex;
justify-content: center;
}
.container {
max-width: 640px;
width: 100%;
}
.header {
margin-bottom: 1.5rem;
}
.date {
font-size: 1.1rem;
color: #666;
}
.ago {
font-size: 0.9rem;
color: #999;
margin-top: 0.25rem;
}
.card {
background: #fff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.content {
white-space: pre-wrap;
word-wrap: break-word;
font-size: 1rem;
line-height: 1.6;
}
.tags {
margin-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
background: #e8f0fe;
color: #1a73e8;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.85rem;
}
.meta {
margin-top: 1rem;
font-size: 0.8rem;
color: #aaa;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="date">{{.DateFormatted}}</div>
{{if .AgoText}}<div class="ago">{{.AgoText}}</div>{{end}}
</div>
<div class="card">
<div class="content">{{.Content}}</div>
{{if .Tags}}
<div class="tags">
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
</div>
{{end}}
</div>
<div class="meta">Tier {{.Tier}} · показов: {{.ShowCount}}</div>
</div>
</body>
</html>