Files
zenfeed/pkg/schedule/rule/rule.go
2025-06-07 16:17:36 +08:00

169 lines
4.1 KiB
Go

// Copyright (C) 2025 wangyusong
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package rule
import (
"strings"
"time"
"github.com/pkg/errors"
"github.com/glidea/zenfeed/pkg/component"
"github.com/glidea/zenfeed/pkg/model"
"github.com/glidea/zenfeed/pkg/storage/feed"
"github.com/glidea/zenfeed/pkg/storage/feed/block"
)
// --- Interface code block ---
type Rule interface {
component.Component
Config() *Config
}
type Config struct {
Name string
Query string
Threshold float32
LabelFilters []string
Labels map[string]string
labels model.Labels
// Periodic type.
EveryDay string // e.g. "00:00~23:59", or "-22:00~7:00" (yesterday 22:00 to today 07:00)
start, end time.Time
crossDay bool
// Watch type.
WatchInterval time.Duration
}
var (
timeSep = "~"
timeYesterdayPrefix = "-"
timeFmt = "15:04"
)
func (c *Config) Validate() error { //nolint:cyclop,gocognit
if c.Name == "" {
return errors.New("name is required")
}
if c.Threshold == 0 {
c.Threshold = 0.5
}
if c.Threshold < 0 || c.Threshold > 1 {
return errors.New("threshold must be between 0 and 1")
}
if len(c.Labels) > 0 {
c.labels.FromMap(c.Labels)
}
if c.EveryDay != "" && c.WatchInterval != 0 {
return errors.New("every_day and watch_interval cannot both be set")
}
switch c.EveryDay {
case "":
if c.WatchInterval < 10*time.Minute {
c.WatchInterval = 10 * time.Minute
}
default:
times := strings.Split(c.EveryDay, timeSep)
if len(times) != 2 {
return errors.New("every_day must be in format 'start~end'")
}
start, end := strings.TrimSpace(times[0]), strings.TrimSpace(times[1])
isYesterday := strings.HasPrefix(start, timeYesterdayPrefix)
if isYesterday {
start = start[1:] // Remove the "-" prefix
c.crossDay = true
}
// Parse start time.
startTime, err := time.ParseInLocation(timeFmt, start, time.Local)
if err != nil {
return errors.Wrap(err, "parse start time")
}
// Parse end time.
endTime, err := time.ParseInLocation(timeFmt, end, time.Local)
if err != nil {
return errors.Wrap(err, "parse end time")
}
// For non-yesterday time range, end time must be after start time.
if !isYesterday && endTime.Before(startTime) {
return errors.New("end time must be after start time")
}
c.start, c.end = startTime, endTime
}
return nil
}
type Dependencies struct {
FeedStorage feed.Storage
Out chan<- *Result
}
type Result struct {
Rule string
Time time.Time
Feeds []*block.FeedVO
}
// --- Factory code block ---
type Factory component.Factory[Rule, Config, Dependencies]
func NewFactory(mockOn ...component.MockOption) Factory {
if len(mockOn) > 0 {
return component.FactoryFunc[Rule, Config, Dependencies](
func(instance string, config *Config, dependencies Dependencies) (Rule, error) {
m := &mockRule{}
component.MockOptions(mockOn).Apply(&m.Mock)
return m, nil
},
)
}
return component.FactoryFunc[Rule, Config, Dependencies](new)
}
func new(instance string, config *Config, dependencies Dependencies) (Rule, error) {
if err := config.Validate(); err != nil {
return nil, errors.Wrap(err, "validate config")
}
switch config.EveryDay {
case "":
return newWatch(instance, config, dependencies)
default:
return newPeriodic(instance, config, dependencies)
}
}
// --- Implementation code block ---
type mockRule struct {
component.Mock
}
func (m *mockRule) Config() *Config {
args := m.Called()
return args.Get(0).(*Config)
}