github.com/quantumghost/awgo@v0.15.0/workflow.go (about)

     1  //
     2  // Copyright (c) 2016 Dean Jackson <deanishe@deanishe.net>
     3  //
     4  // MIT Licence. See http://opensource.org/licenses/MIT
     5  //
     6  
     7  package aw
     8  
     9  import (
    10  	"fmt"
    11  	"io"
    12  	"log"
    13  	"os"
    14  	"path/filepath"
    15  	"runtime/debug"
    16  	"sync"
    17  	"time"
    18  
    19  	"github.com/deanishe/awgo/fuzzy"
    20  	"github.com/deanishe/awgo/util"
    21  )
    22  
    23  // AwGoVersion is the semantic version number of this library.
    24  const AwGoVersion = "0.14.0"
    25  
    26  // Default Workflow settings. Can be changed with the corresponding Options.
    27  //
    28  // See the Options and Workflow documentation for more information.
    29  const (
    30  	DefaultLogPrefix   = "\U0001F37A"    // Beer mug
    31  	DefaultMaxLogSize  = 1048576         // 1 MiB
    32  	DefaultMaxResults  = 0               // No limit, i.e. send all results to Alfred
    33  	DefaultSessionName = "AW_SESSION_ID" // Workflow variable session ID is stored in
    34  	DefaultMagicPrefix = "workflow:"     // Prefix to call "magic" actions
    35  )
    36  
    37  var (
    38  	startTime time.Time // Time execution started
    39  
    40  	// The workflow object operated on by top-level functions.
    41  	// wf *Workflow
    42  
    43  	// Flag, as we only want to set up logging once
    44  	// TODO: Better, more pluggable logging
    45  	logInitialized bool
    46  )
    47  
    48  // init creates the default Workflow.
    49  func init() {
    50  	startTime = time.Now()
    51  }
    52  
    53  // Workflow provides a consolidated API for building Script Filters.
    54  //
    55  // As a rule, you should create a Workflow in init or main and call your main
    56  // entry-point via Workflow.Run(), which catches panics, and logs & shows the
    57  // error in Alfred.
    58  //
    59  // Script Filter
    60  //
    61  // To generate feedback for a Script Filter, use Workflow.NewItem() to create
    62  // new Items and Workflow.SendFeedback() to send the results to Alfred.
    63  //
    64  // Run Script
    65  //
    66  // Use the TextErrors option, so any rescued panics are printed as text,
    67  // not as JSON.
    68  //
    69  // Use ArgVars to set workflow variables, not Workflow/Feedback.
    70  //
    71  // See the _examples/ subdirectory for some full examples of workflows.
    72  type Workflow struct {
    73  	sync.WaitGroup
    74  	// Interface to workflow's settings.
    75  	// Reads workflow variables by type and saves new values to info.plist.
    76  	Config *Config
    77  
    78  	// Call Alfred's AppleScript functions.
    79  	Alfred *Alfred
    80  
    81  	// Cache is a Cache pointing to the workflow's cache directory.
    82  	Cache *Cache
    83  	// Data is a Cache pointing to the workflow's data directory.
    84  	Data *Cache
    85  	// Session is a cache that stores session-scoped data. These data
    86  	// persist until the user closes Alfred or runs a different workflow.
    87  	Session *Session
    88  
    89  	// The response that will be sent to Alfred. Workflow provides
    90  	// convenience wrapper methods, so you don't normally have to
    91  	// interact with this directly.
    92  	Feedback *Feedback
    93  
    94  	// Updater fetches updates for the workflow.
    95  	Updater Updater
    96  
    97  	// MagicActions contains the magic actions registered for this workflow.
    98  	// It is set to DefaultMagicActions by default.
    99  	MagicActions *MagicActions
   100  
   101  	logPrefix   string         // Written to debugger to force a newline
   102  	maxLogSize  int            // Maximum size of log file in bytes
   103  	magicPrefix string         // Overrides DefaultMagicPrefix for magic actions.
   104  	maxResults  int            // max. results to send to Alfred. 0 means send all.
   105  	sortOptions []fuzzy.Option // Options for fuzzy filtering
   106  	textErrors  bool           // Show errors as plaintext, not Alfred JSON
   107  	helpURL     string         // URL to help page (shown if there's an error)
   108  	dir         string         // Directory workflow is in
   109  	cacheDir    string         // Workflow's cache directory
   110  	dataDir     string         // Workflow's data directory
   111  	sessionName string         // Name of the variable sessionID is stored in
   112  	sessionID   string         // Random session ID
   113  }
   114  
   115  // New creates and initialises a new Workflow, passing any Options to
   116  // Workflow.Configure().
   117  //
   118  // For available options, see the documentation for the Option type and the
   119  // following functions.
   120  //
   121  // IMPORTANT: In order to be able to initialise the Workflow correctly,
   122  // New must be run within a valid Alfred environment; specifically
   123  // *at least* the following environment variables must be set:
   124  //
   125  //     alfred_workflow_bundleid
   126  //     alfred_workflow_cache
   127  //     alfred_workflow_data
   128  //
   129  // If you aren't running from Alfred, or would like to specify a
   130  // custom environment, use NewFromEnv().
   131  func New(opts ...Option) *Workflow { return NewFromEnv(nil, opts...) }
   132  
   133  // NewFromEnv creates a new Workflows from a custom Env, instead of
   134  // reading its configuration from Alfred's environment variables.
   135  func NewFromEnv(env Env, opts ...Option) *Workflow {
   136  
   137  	if env == nil {
   138  		env = sysEnv{}
   139  	}
   140  
   141  	if err := validateEnv(env); err != nil {
   142  		panic(err)
   143  	}
   144  
   145  	wf := &Workflow{
   146  		Config:      NewConfig(env),
   147  		Alfred:      NewAlfred(env),
   148  		Feedback:    &Feedback{},
   149  		logPrefix:   DefaultLogPrefix,
   150  		maxLogSize:  DefaultMaxLogSize,
   151  		maxResults:  DefaultMaxResults,
   152  		sessionName: DefaultSessionName,
   153  		sortOptions: []fuzzy.Option{},
   154  	}
   155  
   156  	wf.MagicActions = defaultMagicActions(wf)
   157  
   158  	wf.Configure(opts...)
   159  
   160  	wf.Cache = NewCache(wf.CacheDir())
   161  	wf.Data = NewCache(wf.DataDir())
   162  	wf.Session = NewSession(wf.CacheDir(), wf.SessionID())
   163  	wf.initializeLogging()
   164  	return wf
   165  }
   166  
   167  // --------------------------------------------------------------------
   168  // Initialisation methods
   169  
   170  // Configure applies one or more Options to Workflow. The returned Option reverts
   171  // all Options passed to Configure.
   172  func (wf *Workflow) Configure(opts ...Option) (previous Option) {
   173  	prev := make(options, len(opts))
   174  	for i, opt := range opts {
   175  		prev[i] = opt(wf)
   176  	}
   177  	return prev.apply
   178  }
   179  
   180  // initializeLogging ensures future log messages are written to
   181  // workflow's log file.
   182  func (wf *Workflow) initializeLogging() {
   183  
   184  	if logInitialized { // All Workflows use the same global logger
   185  		return
   186  	}
   187  
   188  	// Rotate log file if larger than MaxLogSize
   189  	fi, err := os.Stat(wf.LogFile())
   190  	if err == nil {
   191  
   192  		if fi.Size() >= int64(wf.maxLogSize) {
   193  
   194  			new := wf.LogFile() + ".1"
   195  			if err := os.Rename(wf.LogFile(), new); err != nil {
   196  				fmt.Fprintf(os.Stderr, "Error rotating log: %v\n", err)
   197  			}
   198  
   199  			fmt.Fprintln(os.Stderr, "Rotated log")
   200  		}
   201  	}
   202  
   203  	// Open log file
   204  	file, err := os.OpenFile(wf.LogFile(),
   205  		os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
   206  
   207  	if err != nil {
   208  		wf.Fatal(fmt.Sprintf("Couldn't open log file %s : %v",
   209  			wf.LogFile(), err))
   210  	}
   211  
   212  	// Attach logger to file
   213  	multi := io.MultiWriter(file, os.Stderr)
   214  	log.SetOutput(multi)
   215  
   216  	// Show filenames and line numbers if Alfred's debugger is open
   217  	if wf.Debug() {
   218  		log.SetFlags(log.Ltime | log.Lshortfile)
   219  	} else {
   220  		log.SetFlags(log.Ltime)
   221  	}
   222  
   223  	logInitialized = true
   224  }
   225  
   226  // --------------------------------------------------------------------
   227  // API methods
   228  
   229  // BundleID returns the workflow's bundle ID. This library will not
   230  // work without a bundle ID, which is set in the workflow's main
   231  // setup sheet in Alfred Preferences.
   232  func (wf *Workflow) BundleID() string {
   233  
   234  	s := wf.Config.Get(EnvVarBundleID)
   235  	if s == "" {
   236  		wf.Fatal("No bundle ID set. You *must* set a bundle ID to use AwGo.")
   237  	}
   238  	return s
   239  }
   240  
   241  // Name returns the workflow's name as specified in the workflow's main
   242  // setup sheet in Alfred Preferences.
   243  func (wf *Workflow) Name() string { return wf.Config.Get(EnvVarName) }
   244  
   245  // Version returns the workflow's version set in the workflow's configuration
   246  // sheet in Alfred Preferences.
   247  func (wf *Workflow) Version() string { return wf.Config.Get(EnvVarVersion) }
   248  
   249  // SessionID returns the session ID for this run of the workflow.
   250  // This is used internally for session-scoped caching.
   251  //
   252  // The session ID is persisted as a workflow variable. It and the session
   253  // persist as long as the user is using the workflow in Alfred. That
   254  // means that the session expires as soon as Alfred closes or the user
   255  // runs a different workflow.
   256  func (wf *Workflow) SessionID() string {
   257  
   258  	if wf.sessionID == "" {
   259  
   260  		ev := os.Getenv(wf.sessionName)
   261  
   262  		if ev != "" {
   263  			wf.sessionID = ev
   264  		} else {
   265  			wf.sessionID = NewSessionID()
   266  		}
   267  	}
   268  
   269  	return wf.sessionID
   270  }
   271  
   272  // Debug returns true if Alfred's debugger is open.
   273  func (wf *Workflow) Debug() bool { return wf.Config.GetBool(EnvVarDebug) }
   274  
   275  // Args returns command-line arguments passed to the program.
   276  // It intercepts "magic args" and runs the corresponding actions, terminating
   277  // the workflow. See MagicAction for full documentation.
   278  func (wf *Workflow) Args() []string {
   279  	prefix := DefaultMagicPrefix
   280  	if wf.magicPrefix != "" {
   281  		prefix = wf.magicPrefix
   282  	}
   283  	return wf.MagicActions.Args(os.Args[1:], prefix)
   284  }
   285  
   286  // Run runs your workflow function, catching any errors.
   287  // If the workflow panics, Run rescues and displays an error message in Alfred.
   288  func (wf *Workflow) Run(fn func()) {
   289  
   290  	vstr := wf.Name()
   291  
   292  	if wf.Version() != "" {
   293  		vstr += "/" + wf.Version()
   294  	}
   295  
   296  	vstr = fmt.Sprintf(" %s (AwGo/%v) ", vstr, AwGoVersion)
   297  
   298  	// Print right after Alfred's introductory blurb in the debugger.
   299  	// Alfred strips whitespace.
   300  	if wf.logPrefix != "" {
   301  		fmt.Fprintln(os.Stderr, wf.logPrefix)
   302  	}
   303  
   304  	log.Println(util.Pad(vstr, "-", 50))
   305  
   306  	// Clear expired session data
   307  	wf.Add(1)
   308  	go func() {
   309  		defer wf.Done()
   310  		wf.Session.Clear(false)
   311  	}()
   312  
   313  	// Catch any `panic` and display an error in Alfred.
   314  	// Fatal(msg) will terminate the process (via log.Fatal).
   315  	defer func() {
   316  
   317  		if r := recover(); r != nil {
   318  
   319  			log.Println(util.Pad(" FATAL ERROR ", "-", 50))
   320  			log.Printf("%s : %s", r, debug.Stack())
   321  			log.Println(util.Pad(" END STACK TRACE ", "-", 50))
   322  
   323  			// log.Printf("Recovered : %x", r)
   324  			err, ok := r.(error)
   325  			if ok {
   326  				wf.outputErrorMsg(err.Error())
   327  			}
   328  
   329  			wf.outputErrorMsg(fmt.Sprintf("%v", r))
   330  		}
   331  	}()
   332  
   333  	// Call the workflow's main function.
   334  	fn()
   335  
   336  	wf.Wait()
   337  	finishLog(false)
   338  }
   339  
   340  // --------------------------------------------------------------------
   341  // Helper methods
   342  
   343  // outputErrorMsg prints and logs error, then exits process.
   344  func (wf *Workflow) outputErrorMsg(msg string) {
   345  	if wf.textErrors {
   346  		fmt.Print(msg)
   347  	} else {
   348  		wf.Feedback.Clear()
   349  		wf.NewItem(msg).Icon(IconError)
   350  		wf.SendFeedback()
   351  	}
   352  	log.Printf("[ERROR] %s", msg)
   353  	// Show help URL or website URL
   354  	if wf.helpURL != "" {
   355  		log.Printf("Get help at %s", wf.helpURL)
   356  	}
   357  	finishLog(true)
   358  }
   359  
   360  // awDataDir is the directory for AwGo's own data.
   361  func (wf *Workflow) awDataDir() string {
   362  	return util.MustExist(filepath.Join(wf.DataDir(), "_aw"))
   363  }
   364  
   365  // awCacheDir is the directory for AwGo's own cache.
   366  func (wf *Workflow) awCacheDir() string {
   367  	return util.MustExist(filepath.Join(wf.CacheDir(), "_aw"))
   368  }
   369  
   370  // --------------------------------------------------------------------
   371  // Package-level only
   372  
   373  // finishLog outputs the workflow duration
   374  func finishLog(fatal bool) {
   375  
   376  	elapsed := time.Now().Sub(startTime)
   377  	s := util.Pad(fmt.Sprintf(" %v ", elapsed), "-", 50)
   378  
   379  	if fatal {
   380  		log.Fatalln(s)
   381  	} else {
   382  		log.Println(s)
   383  	}
   384  }