1
0
Fork 0
timelinize/datasources/calendar/calendar.go
Matthew Holt f0697d2d6b
Refactor embedding jobs; enhance tooltips; upgrade gofakeit to v7
The gofakeit upgrade uses the new math/rand/v2 package, which uses uint64 more than int64, so we had to change a bunch of row IDs from int64 to uint64.
2025-04-24 16:33:41 -06:00

161 lines
3.7 KiB
Go

package calendar
import (
"context"
"io/fs"
"path"
"strings"
"time"
"github.com/apognu/gocal"
"github.com/timelinize/timelinize/timeline"
"go.uber.org/zap"
)
func init() {
err := timeline.RegisterDataSource(timeline.DataSource{
Name: "calendar",
Title: "Calendar",
Icon: "calendar.svg",
NewOptions: func() any { return new(Options) },
NewFileImporter: func() timeline.FileImporter { return new(FileImporter) },
})
if err != nil {
timeline.Log.Fatal("registering data source", zap.Error(err))
}
}
// FileImporter can import the data from a file.
type FileImporter struct{}
// Recognize returns whether the file is recognized for this data source.
func (fi FileImporter) Recognize(_ context.Context, dirEntry timeline.DirEntry, _ timeline.RecognizeParams) (timeline.Recognition, error) {
rec := timeline.Recognition{DirThreshold: 0.9}
// TODO: proper detection, not just filename
ext := strings.ToLower(path.Ext(dirEntry.Name()))
if ext == extIcs {
rec.Confidence = 1
}
return rec, nil
}
// Options configures the data source.
type Options struct {
// The ID of the owner entity. REQUIRED for linking
// entity in DB when calendar provides no/insufficient
// owner information.
OwnerEntityID uint64 `json:"owner_entity_id"`
}
// FileImport imports data from a file.
func (fi FileImporter) FileImport(ctx context.Context, dirEntry timeline.DirEntry, params timeline.ImportParams) error {
dsOpt := params.DataSourceOptions.(*Options)
// if no organizer/owner is specified, we default to the configured entity
defaultOwner := timeline.Entity{
ID: dsOpt.OwnerEntityID,
}
err := fs.WalkDir(dirEntry.FS, dirEntry.Filename, func(fpath string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if strings.HasPrefix(d.Name(), ".") {
// skip hidden files & folders
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if d.IsDir() {
return nil // traverse into subdirectories
}
// skip unsupported file types
ext := path.Ext(strings.ToLower(d.Name()))
if ext != extIcs {
return nil
}
file, err := dirEntry.FS.Open(fpath)
if err != nil {
return err
}
defer file.Close()
c := gocal.NewParser(file)
// import all items (with reasonable bounds still on recurring events)
// regardless of date range (set Start/End to configure date range)
c.SkipBounds = true
if err := c.Parse(); err != nil {
// go on to the next calendar file
params.Log.Error("parsing calendar file",
zap.String("filename", fpath),
zap.Error(err))
return nil
}
for _, e := range c.Events {
var start, end time.Time
if e.Start != nil {
start = *e.Start
}
if e.End != nil {
end = *e.End
}
var loc timeline.Location
if e.Geo != nil {
loc.Latitude = &e.Geo.Lat
loc.Longitude = &e.Geo.Long
}
var owner timeline.Entity
if e.Organizer == nil {
owner = defaultOwner
} else if e.Organizer != nil && e.Organizer.Cn != "" {
owner.Name = e.Organizer.Cn
}
content := strings.TrimSpace(e.Summary)
if e.Description != "" {
if content != "" {
content += "\n"
}
content += strings.TrimSpace(e.Description)
}
params.Pipeline <- &timeline.Graph{
Item: &timeline.Item{
ID: e.Uid,
Classification: timeline.ClassEvent,
Timestamp: start,
Timespan: end,
Location: loc,
Owner: owner,
Content: timeline.ItemData{
MediaType: "text/plain",
Data: timeline.StringData(content),
},
Metadata: timeline.Metadata{
"Location": e.Location,
"Class": e.Class,
},
},
}
}
return nil
})
return err
}
const extIcs = ".ics"