├── .gitignore ├── images ├── itsycal.png └── calendar.png ├── go.mod ├── icalendar.tmpl ├── Dockerfile ├── server.go ├── .github └── workflows │ └── package.yaml ├── scheduler.go ├── go.sum ├── README.md ├── calendar.go └── api.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /images/itsycal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Secbone/calendar/HEAD/images/itsycal.png -------------------------------------------------------------------------------- /images/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Secbone/calendar/HEAD/images/calendar.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | go 1.14 4 | 5 | require github.com/pionus/arry v0.0.0-20200721135907-bf6c6437dfdc 6 | -------------------------------------------------------------------------------- /icalendar.tmpl: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:pionus-go 4 | X-WR-CALNAME:{{.Name}} 5 | X-APPLE-CALENDAR-COLOR:{{.Color}} 6 | {{range .Holidays}} 7 | BEGIN:VEVENT 8 | SUMMARY:{{.Name}} 9 | DTSTAMP:{{.DTStamp}} 10 | DTSTART;VALUE=DATE:{{.StartDate}} 11 | DTEND;VALUE=DATE:{{.EndDate}} 12 | END:VEVENT 13 | {{end}} 14 | END:VCALENDAR 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build container 2 | FROM golang:latest as builder 3 | LABEL maintainer="Secbone " 4 | 5 | WORKDIR /app 6 | 7 | COPY go.mod go.sum ./ 8 | RUN go mod download 9 | COPY . . 10 | 11 | # build 12 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . 13 | 14 | # runtime container 15 | FROM alpine:latest 16 | 17 | WORKDIR /app 18 | 19 | RUN apk --no-cache add ca-certificates 20 | COPY --from=builder /app/main . 21 | COPY icalendar.tmpl . 22 | 23 | EXPOSE 80 24 | 25 | CMD ["./main"] -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "github.com/pionus/arry" 6 | "github.com/pionus/arry/middlewares" 7 | ) 8 | 9 | func main() { 10 | a := arry.New() 11 | a.Use(middlewares.Gzip) 12 | a.Use(middlewares.Logger()) 13 | a.Use(middlewares.Panic) 14 | 15 | api := NewAPI() 16 | 17 | router := a.Router() 18 | 19 | router.Get("/work", func(ctx arry.Context) { 20 | work := api.GetWorkCalendar() 21 | ctx.SetContentType("text/calendar;charset=UTF-8") 22 | ctx.Response().Code = 200 23 | work.Render(ctx.Response().Writer) 24 | }) 25 | 26 | router.Get("/off", func(ctx arry.Context) { 27 | off := api.GetOffCalendar() 28 | ctx.SetContentType("text/calendar;charset=UTF-8") 29 | ctx.Response().Code = 200 30 | off.Render(ctx.Response().Writer) 31 | }) 32 | 33 | log.Printf("Listening at :80") 34 | err := a.Start(":80") 35 | 36 | if err != nil { 37 | log.Fatalf("Could not start server: %s\n", err.Error()) 38 | } 39 | 40 | log.Printf("shutdown") 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/package.yaml: -------------------------------------------------------------------------------- 1 | name: build image to gihub packages 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-ghcr-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - uses: actions/checkout@master 21 | - name: Login to GitHub Container Registry 22 | uses: docker/login-action@master 23 | with: 24 | registry: ${{ env.REGISTRY }} 25 | username: ${{ github.actor }} 26 | password: ${{ secrets.GITHUB_TOKEN }} 27 | - name: Docker meta 28 | id: meta 29 | uses: docker/metadata-action@master 30 | with: 31 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 32 | - name: Build and push 33 | uses: docker/build-push-action@master 34 | with: 35 | context: . 36 | push: true 37 | tags: ${{ steps.meta.outputs.tags }} 38 | labels: ${{ steps.meta.outputs.labels }} 39 | -------------------------------------------------------------------------------- /scheduler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | "sync" 7 | "context" 8 | ) 9 | 10 | type Job func(ctx context.Context) 11 | 12 | type Scheduler struct { 13 | group *sync.WaitGroup 14 | cancellations []context.CancelFunc 15 | } 16 | 17 | func NewScheduler() *Scheduler { 18 | return &Scheduler{ 19 | group: new(sync.WaitGroup), 20 | cancellations: make([]context.CancelFunc, 0), 21 | } 22 | } 23 | 24 | func (s *Scheduler) Add(ctx context.Context, j Job, interval time.Duration) { 25 | ctx, cancel := context.WithCancel(ctx) 26 | s.cancellations = append(s.cancellations, cancel) 27 | 28 | s.group.Add(1) 29 | go s.process(ctx, j, interval) 30 | } 31 | 32 | func (s *Scheduler) Stop() { 33 | for _, cancel := range s.cancellations { 34 | cancel() 35 | } 36 | s.group.Wait() 37 | fmt.Println("stop called") 38 | } 39 | 40 | func (s *Scheduler) process(ctx context.Context, j Job, interval time.Duration) { 41 | ticker := time.NewTicker(interval) 42 | 43 | for { 44 | select { 45 | case <-ticker.C: 46 | j(ctx) 47 | case <-ctx.Done(): 48 | s.group.Done() 49 | return 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/pionus/arry v0.0.0-20200721135907-bf6c6437dfdc h1:knj0sX/2P3IXH+Go0bC5pyH/TM2dhg4pk055cn1xFXc= 2 | github.com/pionus/arry v0.0.0-20200721135907-bf6c6437dfdc/go.mod h1:95uEWvdzdPe1Snt3MzjYULoeTSMEUJLsD4evQJP96YA= 3 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 4 | golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg= 5 | golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 6 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= 7 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 8 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 9 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 10 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 11 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Calendar 2 | 法定节假日 iCalendar 订阅服务 3 | 4 | [![Docker build][build-image]][hub-url] 5 | [![Docker pulls][pulls-image]][hub-url] 6 | [![Docker stars][stars-image]][hub-url] 7 | [![Docker size][size-image]][size-url] 8 | 9 | #### calendar 10 | 11 | 12 | #### itsycal 13 | 14 | 15 | ## Subscription 16 | 17 | 本服务分为两个日历,`节假日` 和 `调休`,以便可以分别设置提醒和区分颜色 18 | 19 | MacOS 打开 `日历`,左上角 `文件` - `新建日历订阅`,添加如下地址订阅 20 | 21 | - 节假日: `https://calendar.2huo.us/off` 22 | - 调休: `https://calendar.2huo.us/work` 23 | 24 | ## Docker 25 | 26 | 自建订阅服务 27 | ```bash 28 | docker pull ghcr.io/secbone/calendar 29 | 30 | docker run -d -p [port]:80 ghcr.io/secbone/calendar 31 | ``` 32 | 33 | ## Thanks 34 | 35 | - [NateScarlet/holiday-cn](https://github.com/NateScarlet/holiday-cn) 36 | 37 | [pulls-image]: https://img.shields.io/docker/pulls/secbone/calendar.svg?style=flat-square 38 | [hub-url]: https://hub.docker.com/r/secbone/calendar/ 39 | [stars-image]: https://img.shields.io/docker/stars/secbone/calendar.svg?style=flat-square 40 | [size-image]: https://images.microbadger.com/badges/image/secbone/calendar.svg 41 | [size-url]: https://microbadger.com/images/secbone/calendar 42 | [build-image]: https://img.shields.io/docker/cloud/build/secbone/calendar.svg?style=flat-square 43 | -------------------------------------------------------------------------------- /calendar.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "time" 7 | "bytes" 8 | "text/template" 9 | ) 10 | 11 | type Holiday struct { 12 | Name string 13 | StartDate string 14 | EndDate string 15 | DTStamp string 16 | Rest bool 17 | } 18 | 19 | type Calendar struct { 20 | Name string 21 | Color string 22 | Holidays []Holiday 23 | t *template.Template 24 | prefix string 25 | suffix string 26 | } 27 | 28 | func NewCalendar(name string, color string) *Calendar { 29 | t := template.Must(template.New("iCalendar").ParseFiles("icalendar.tmpl")) 30 | 31 | return &Calendar{ 32 | Name: name, 33 | Color: color, 34 | t: t, 35 | } 36 | } 37 | 38 | func NewWorkCalendar() *Calendar { 39 | c := NewCalendar("调休", "#DD2222") 40 | c.SetSuffix("(班)") 41 | return c 42 | } 43 | 44 | func NewOffCalendar() *Calendar { 45 | c := NewCalendar("节假日", "#22DD22") 46 | c.SetSuffix("(休)") 47 | return c 48 | } 49 | 50 | 51 | func (c *Calendar) SetPrefix(prefix string) { 52 | c.prefix = prefix 53 | } 54 | 55 | func (c *Calendar) SetSuffix(suffix string) { 56 | c.suffix = suffix 57 | } 58 | 59 | func (c *Calendar) Render(writer io.Writer) { 60 | c.t.ExecuteTemplate(writer, "icalendar.tmpl", c) 61 | } 62 | 63 | func (c *Calendar) RenderFile(path string) { 64 | f, _ := os.Create(path) 65 | defer f.Close() 66 | 67 | c.Render(f) 68 | } 69 | 70 | func (c *Calendar) RenderString() string { 71 | var b bytes.Buffer 72 | c.Render(&b) 73 | return b.String() 74 | } 75 | 76 | func (c *Calendar) AddHoliday(name string, date time.Time, rest bool) { 77 | c.Holidays = append(c.Holidays, Holiday{ 78 | Name: c.prefix + name + c.suffix, 79 | StartDate: date.Format("20060102"), 80 | EndDate: date.AddDate(0, 0, 1).Format("20060102"), 81 | DTStamp: date.Format("20060102T150405Z"), 82 | Rest: rest, 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | // "io/ioutil" 5 | "net/http" 6 | "log" 7 | "time" 8 | "strconv" 9 | "encoding/json" 10 | ) 11 | 12 | 13 | type Day struct { 14 | Name string `json:"name"` 15 | Date string `json:"date"` 16 | OffDay bool `json:"isOffDay"` 17 | } 18 | 19 | type ResponseData struct { 20 | Papers []string `json:"papers"` 21 | Days []Day `json:"days"` 22 | Updated time.Time 23 | } 24 | 25 | type API struct { 26 | Data map[int]*ResponseData 27 | } 28 | 29 | 30 | func NewAPI() *API { 31 | return &API{ 32 | Data: make(map[int]*ResponseData), 33 | } 34 | } 35 | 36 | func (a *API) GetData(year int) *ResponseData { 37 | if data, ok := a.Data[year]; ok { 38 | // cache data in 7 days 39 | if time.Now().Sub(data.Updated).Hours() < 24 * 7 { 40 | return data 41 | } 42 | } 43 | 44 | a.Data[year] = a.FetchData(year) 45 | return a.Data[year] 46 | } 47 | 48 | func (a *API) FetchData(year int) *ResponseData { 49 | res, err := http.Get("https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/"+ strconv.Itoa(year) +".json") 50 | if err != nil{ 51 | log.Fatal(err) 52 | } 53 | 54 | defer res.Body.Close() 55 | 56 | var result ResponseData 57 | 58 | json.NewDecoder(res.Body).Decode(&result) 59 | result.Updated = time.Now() 60 | 61 | return &result 62 | } 63 | 64 | 65 | func (a *API) FillCalendar(c *Calendar, off bool) *Calendar { 66 | // get the next year 67 | year := time.Now().Year() + 1 68 | 69 | for y := year - 2; y <= year; y++ { 70 | data := a.GetData(y) 71 | 72 | for _, day := range data.Days { 73 | if day.OffDay != off { 74 | continue 75 | } 76 | 77 | t, _ := time.Parse("2006-01-02", day.Date) 78 | c.AddHoliday(day.Name, t, day.OffDay) 79 | } 80 | } 81 | 82 | return c 83 | } 84 | 85 | func (a *API) GetWorkCalendar() *Calendar { 86 | return a.FillCalendar(NewWorkCalendar(), false) 87 | } 88 | 89 | func (a *API) GetOffCalendar() *Calendar { 90 | return a.FillCalendar(NewOffCalendar(), true) 91 | } 92 | --------------------------------------------------------------------------------