package seo import ( "context" "errors" "fmt" "strings" "time" "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/sm-seo-plugin/classes/crawl" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // Commands registers seo:generate-entries and seo:crawl. func (p *Plugin) Commands() []bonfire.Command { return []bonfire.Command{p.crawlCommand(), { Name: "seo:generate-entries", Description: "Queue the job that creates missing SEO entries", Long: `Queues the entry generation job on the background queue. The job walks the published records of every content type registered in signpost (or of the one named by --type), creates the missing SEO entry of each locale with its current address and refreshes the address and the effective title and description of existing entries. Values set in the SEO tab are never changed. A worker (summer serve or queue:work) runs the job.`, Flags: []bonfire.Flag{ {Name: "type", Description: "Content type key (signpost menu item type) to limit the run to"}, }, Examples: []bonfire.Example{ {Command: "seo:generate-entries", Description: "Queue generation for every content type"}, {Command: "seo:generate-entries --type journal-post", Description: "Queue generation for one content type"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { typeKey, _ := in.Flag("type") typeKey = strings.TrimSpace(typeKey) svc := p.Service() if svc == nil { return classes.ErrNotBooted } if typeKey != "" { if reg := svc.Registry(); reg != nil { if _, ok := reg.ContentType(typeKey); !ok { return fmt.Errorf("Content type %q is not registered", typeKey) } } } db, done, err := p.commandDB(ctx) if err != nil { return err } defer done() id, err := svc.EnqueueGenerate(ctx, db, typeKey) if err != nil { return err } out.Success(fmt.Sprintf("Queued SEO entry generation (job #%d).", id)) return nil }, }} } // crawlCommand is seo:crawl [--scheduled]: it queues a crawl of the site // (D-14) for the worker; --scheduled is the nightly entry (P33-40), which // queues one only when the Site audit setting crawl_schedule is daily, or // weekly on a Monday in app.timezone, and skips while a crawl runs. func (p *Plugin) crawlCommand() bonfire.Command { return bonfire.Command{ Name: "seo:crawl", Description: "Queue a crawl of the site for the site audit", Long: `Queues a crawl job on the background queue. The crawler fetches the site's own pages over HTTP like a search engine (from the home page, the sitemap index and its files, and same-host links; two requests at a time, the Site audit settings' pause and page limit) and stores what it finds in SEO › Site audit. One crawl runs at a time. With --scheduled, as the nightly schedule runs it, the command queues a crawl only when the Site audit setting "Automatic crawl" is daily, or weekly and it is Monday in app.timezone, and does nothing while a crawl is queued or running. A worker (summer serve or queue:work) runs the job.`, Flags: []bonfire.Flag{ {Name: "scheduled", Description: "Run as the nightly schedule: follow the Automatic crawl setting", Bare: true}, }, Examples: []bonfire.Example{ {Command: "seo:crawl", Description: "Queue a crawl now"}, {Command: "seo:crawl --scheduled", Description: "What the nightly schedule runs"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { svc := p.Service() if svc == nil { return classes.ErrNotBooted } db, done, err := p.commandDB(ctx) if err != nil { return err } defer done() scheduled := bonfire.FlagBool(in, "scheduled") trigger, name := models.TriggerAdmin, "console" if scheduled { st, err := classes.LoadSettings(db) if err != nil { return err } if !crawl.ScheduledDue(st.CrawlSchedule, time.Now().In(appLocation(p.app))) { out.Info("No crawl is due (Automatic crawl: " + st.CrawlSchedule + ").") return nil } trigger, name = models.TriggerSchedule, "" } var row models.Crawl err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { var err error row, err = crawl.Start(ctx, svc, tx, trigger, name) return err }) var running *crawl.AlreadyRunningError switch { case errors.As(err, &running) && scheduled: out.Info(fmt.Sprintf("Crawl #%d is still running; no new crawl was queued.", running.Crawl.ID)) return nil case errors.As(err, &running): return fmt.Errorf("Crawl #%d is already running. Wait for it, or cancel it in SEO › Crawls.", running.Crawl.ID) case errors.Is(err, crawl.ErrNoSiteURL): return fmt.Errorf("The site address for crawls is not configured (golem15.seo.site_url).") case err != nil: return err } job := uint(0) if row.JobID != nil { job = *row.JobID } out.Success(fmt.Sprintf("Queued crawl #%d of %s (job #%d).", row.ID, row.Site, job)) return nil }, } } // appLocation is the app.timezone location, UTC when unset or unknown. func appLocation(app *backpack.App) *time.Location { if app == nil || app.Config == nil { return time.UTC } if name := strings.TrimSpace(app.Config.String("app.timezone")); name != "" { if loc, err := time.LoadLocation(name); err == nil { return loc } } return time.UTC } // commandDB returns the app database, opening database.dsn when the binary // published none for console commands. done closes a pool it opened. func (p *Plugin) commandDB(ctx context.Context) (*gorm.DB, func(), error) { app := p.app if app == nil { return nil, nil, classes.ErrNotBooted } if db, ok := app.Lookup[*gorm.DB](); ok && db != nil { return db, func() {}, nil } if lagoon.DSN(app.Config) == "" { return nil, nil, fmt.Errorf("golem15.seo: database is not configured (set SUMMER_DATABASE__DSN)") } sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app) if err != nil { return nil, nil, err } if err := lagoon.Publish(app, sqlDB, gdb); err != nil { _ = sqlDB.Close() return nil, nil, err } return gdb, func() { _ = sqlDB.Close() }, nil } var _ pact.HasCommands = (*Plugin)(nil)