diff --git a/.autoenv.zsh b/.autoenv.zsh new file mode 100644 index 0000000..51faafe --- /dev/null +++ b/.autoenv.zsh @@ -0,0 +1,6 @@ + +test -f alfredenv.sh && { + source alfredenv.sh +} || { + echo ./alfredenv.sh not found >&2 +} diff --git a/.autoenv_leave.zsh b/.autoenv_leave.zsh new file mode 100644 index 0000000..6735908 --- /dev/null +++ b/.autoenv_leave.zsh @@ -0,0 +1,11 @@ + +unset alfred_workflow_bundleid +unset alfred_workflow_version +unset alfred_workflow_name +unset alfred_workflow_data +unset alfred_workflow_cache + +unset INTERVAL_FIND +unset INTERVAL_MDFIND +unset INTERVAL_LOCATE + diff --git a/.gitignore b/.gitignore index 7902246..8d4e04e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,98 +1,16 @@ -# Created by https://www.gitignore.io/api/python,sublimetext,vim - -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -*.dist-info/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache +# build and dist directories +/build +/dist -# Scrapy stuff: -.scrapy +# compiled binary +/alfsubl -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# IPython Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# dotenv -.env - -# virtualenv -venv/ -ENV/ - -# Spyder project settings -.spyderproject - -# Rope project settings -.ropeproject +# vendor stuff +/vendor +Gopkg.lock +# Created by https://www.gitignore.io/api/python,sublimetext,vim ### SublimeText ### # cache files for sublime text diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..9f8dabc --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,43 @@ + +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + + +[[constraint]] + name = "github.com/BurntSushi/toml" + version = "0.3.0" + +[[constraint]] + name = "github.com/deanishe/awgo" + version = "0.13.2" + +[[constraint]] + name = "github.com/docopt/docopt.go" + # version = "0.6.2" + revision = "ee0de3bc6815ee19d4a46c7eb90f829db0e014b1" + +[[constraint]] + name = "github.com/gobwas/glob" + version = "0.2.2" + +# [[override]] +# name = "github.com/docopt/docopt-go" +# revision = "ee0de3bc6815ee19d4a46c7eb90f829db0e014b1" diff --git a/LICENCE b/LICENCE.txt similarity index 94% rename from LICENCE rename to LICENCE.txt index 9c013a4..c7301f7 100644 --- a/LICENCE +++ b/LICENCE.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014 Dean Jackson +Copyright (c) 2014–2018 Dean Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/alfredenv.sh b/alfredenv.sh new file mode 100644 index 0000000..9f24b11 --- /dev/null +++ b/alfredenv.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# When sourced, creates an Alfred-like environment needed by modd +# and ./bin/build (which sources the file itself) + +# getvar | Read a value from info.plist +getvar() { + local v="$1" + /usr/libexec/PlistBuddy -c "Print :$v" info.plist +} + +# stuff in info.plist +export alfred_workflow_bundleid=$( getvar "bundleid" ) +export alfred_workflow_version=$( getvar "version" ) +export alfred_workflow_name=$( getvar "name" ) + +export INTERVAL_FIND=$( getvar "variables:INTERVAL_FIND" ) +export INTERVAL_MDFIND=$( getvar "variables:INTERVAL_MDFIND" ) +export INTERVAL_LOCATE=$( getvar "variables:INTERVAL_LOCATE" ) + +# workflow data and cache directories +export alfred_workflow_data="${HOME}/Library/Application Support/Alfred 3/Workflow Data/${alfred_workflow_bundleid}" +export alfred_workflow_cache="${HOME}/Library/Caches/com.runningwithcrayons.Alfred-3/Workflow Data/${alfred_workflow_bundleid}" + diff --git a/bin/build b/bin/build new file mode 100755 index 0000000..1398e1e --- /dev/null +++ b/bin/build @@ -0,0 +1,177 @@ +#!/usr/bin/env zsh + +# Path to this script's directory (i.e. workflow root) +here="$( cd "$( dirname "$0" )"; pwd )" +root="$( cd "$here/../"; pwd )" +builddir="${root}/build" +distdir="${root}/dist" + +source "${root}/alfredenv.sh" + +verbose=false +devmode=true +runtests=false +force=false + +# log ... | Echo arguments to STDERR +log() { + echo "$@" >&2 +} + +# info .. | Write args to STDERR if VERBOSE is true +info() { + $verbose && log $(print -P "%F{blue}.. %f") "$@" + return 0 +} + +# success .. | Write green "ok" and args to STDERR if VERBOSE is true +success() { + $verbose && log $(print -P "%F{green}ok %f") "$@" + return 0 +} + +# error .. | Write red "error" and args to STDERR +error() { + log $(print -P '%F{red}err%f') "$@" +} + +# fail .. | Write red "error" and args to STDERR, then exit with status 1 +fail() { + error "$@" + exit 1 +} + +# cleanup | Delete build files +cleanup() { + info "cleaning up ..." + test -d "$builddir" && rm -rf $verbose "${builddir}/"* +} + +# usage | Show usage message +usage() { + cat < /dev/null +# ------------------------------------------------------- +# Run unit tests +$runtests && { + info "running unit tests ..." + go test $v . || exit 1 + success "unit tests" +} + +# ------------------------------------------------------- +# Build +test -d "${builddir}" && { + info "cleaning build directory ..." + cleanup + success "cleaned build" +} + +info "building executable(s) ..." + +go build $v -o ./alfsubl . + +# $devmode && { sym="-s" } + +info "linking assets to build directory ..." +# mkdir -vp "$builddir" +# mkdir -p $v "${builddir}/scripts/"{tab,url} +mkdir -p $v "${builddir}/icons" + +pushd "$builddir" &> /dev/null + +ln $v ../*.html . +ln $v ../info.plist . +ln $v ../icons/icon.png . +ln $v ../alfsubl . +ln $v ../README.md . +ln $v ../LICENCE.txt . +ln $v ../icons/*.png ./icons/ +# ln $v scripts/tab/* "${builddir}/scripts/tab/" +# ln $v scripts/url/* "${builddir}/scripts/url/" +popd &> /dev/null + +# ------------------------------------------------------- +# Build .alfredworkflow file +$devmode || { + test -f "${outpath}" && { + $force && { + rm $v "${outpath}" + } || { + fatal "destination file already exists (use --force to overwrite)" + } + } + + test -d "$distdir" || mkdir -p "$distdir" + + info "building .alfredworkflow file ..." + + zipname="Google-Calendar-View-${alfred_workflow_version}.alfredworkflow" + outpath="${distdir}/${zipname}" + + pushd "$builddir" &> /dev/null + + zip -9 -r "${outpath}" ./* + ST_ZIP=$? + test "$ST_ZIP" -ne 0 && { + error "zip failed (${ST_ZIP}) while creating .alfredworkflow file." + popd &> /dev/null + popd &> /dev/null + exit $ST_ZIP + } + + popd &> /dev/null + success "wrote '${zipname}' file in '${distdir}'" +} + +popd &> /dev/null diff --git a/bin/icons b/bin/icons new file mode 100755 index 0000000..dfe3cb9 --- /dev/null +++ b/bin/icons @@ -0,0 +1,137 @@ +#!/usr/bin/env zsh + +set -e + +# URL of icon generator +api="http://icons.deanishe.net/icon" +here="$( cd "$( dirname "$0" )"; pwd )" +root="$( cd "$here/../"; pwd )" +# where workflow icons belong +icondir="${root}/icons" +# icon config file +iconfile="${root}/icons/icons.txt" + +prog="$( basename "$0" )" + +force=false +verbose=false +vopt= +icons=() + +# log ... | Echo arguments to STDERR +log() { + echo "$@" >&2 +} + +# info .. | Write args to STDERR if VERBOSE is true +info() { + $verbose && log $(print -P "%F{blue}.. %f") "$@" + return 0 +} + +# success .. | Write green "ok" and args to STDERR if VERBOSE is true +success() { + $verbose && log $(print -P "%F{green}ok %f") "$@" + return 0 +} + +# error .. | Write red "error" and args to STDERR +error() { + log $(print -P '%F{red}err%f') "$@" +} + +# fail .. | Write red "error" and args to STDERR, then exit with status 1 +fail() { + error "$@" + exit 1 +} + +# load | Read configuration file from STDIN +load() { + typeset -g icons + while read line; do + + # ignore lines that start with # or are empty + [[ $line =~ "#" ]] || test -z "$line" && continue + + read fname font colour name <<< "$line" + icons+=($fname $font $colour $name) + + done +} + +usage() { +cat < +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-26 +// + +package main + +import "log" + +func runConfig() { + log.Printf(`filtering config "%s" ...`, query) +} diff --git a/cmd_scan.go b/cmd_scan.go new file mode 100644 index 0000000..de34cd3 --- /dev/null +++ b/cmd_scan.go @@ -0,0 +1,57 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-26 +// + +package main + +import ( + "log" + "time" +) + +var ( + totals = map[string]int{} + accepted = map[string][]string{} +) + +func runScan() { + wf.TextErrors = true + + var res []ScanResult + + if force { + if conf.FindInterval != 0 { + conf.FindInterval = time.Nanosecond * 1 + } + if conf.MDFindInterval != 0 { + conf.MDFindInterval = time.Nanosecond * 1 + } + if conf.LocateInterval != 0 { + conf.LocateInterval = time.Nanosecond * 1 + } + } + + for r := range scan() { + accepted[r.Scanner] = append(accepted[r.Scanner], r.Path) + res = append(res, r) + // log.Println(p) + } + + log.Printf("%d project(s)", len(res)) + for n := range totals { + log.Printf(`[filter] %d/%d accepted in "%s"`, len(accepted[n]), totals[n], n) + if n == "locate" { + for _, p := range accepted[n] { + log.Printf(" %s", p) + } + } + } + + if err := wf.Cache.StoreJSON("projects.json", res); err != nil { + wf.FatalError(err) + } +} diff --git a/cmd_search.go b/cmd_search.go new file mode 100644 index 0000000..29bd4c5 --- /dev/null +++ b/cmd_search.go @@ -0,0 +1,60 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-26 +// + +package main + +import ( + "log" + "os" + "os/exec" + + aw "github.com/deanishe/awgo" + "github.com/deanishe/awgo/util" +) + +func runSearch() { + + var res []ScanResult + + log.Printf(`searching for "%s" ...`, query) + + // Run "alfsubl rescan" in background if need be + if scanDue() && !aw.IsRunning("rescan") { + log.Println("recanning for projects ...") + cmd := exec.Command(os.Args[0], "rescan") + if err := aw.RunInBackground("rescan", cmd); err != nil { + log.Printf(`error running "%s rescan": %v`, os.Args[0], err) + wf.Fatal("Error scanning for repos. See log file.") + } + } + + // Load data + if wf.Cache.Exists(cacheKey) { + if err := wf.Cache.LoadJSON(cacheKey, &res); err != nil { + wf.FatalError(err) + } + } + + for _, r := range res { + wf.NewItem(r.Name()). + Subtitle(util.PrettyPath(r.Path)). + Valid(true). + Arg(r.Path). + IsFile(true) + } + + if query != "" { + res := wf.Filter(query) + for _, r := range res { + log.Printf("[search] %0.2f %#v", r.Score, r.SortKey) + } + } + + wf.WarnEmpty("No Projects Found", "Try a different query?") + wf.SendFeedback() +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..5dc8a88 --- /dev/null +++ b/config.go @@ -0,0 +1,168 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-26 +// + +package main + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "time" + + "github.com/BurntSushi/toml" +) + +const ( + // DefaultDepth is how deep to search directories by default. + // 1 means the immediate children of the specified path, 2 means + // its grandchildren, etc. + DefaultDepth = 2 + + // DefaultFindInterval is how often to run find + DefaultFindInterval = time.Duration(5) * time.Minute + + // DefaultMDFindInterval is how often to run mdfind + DefaultMDFindInterval = time.Duration(5) * time.Minute + + // DefaultLocateInterval is how often to run locate + DefaultLocateInterval = time.Duration(24) * time.Hour + + envFindInterval = "INTERVAL_FIND" + envMDFindInterval = "INTERVAL_MDFIND" + envLocateInterval = "INTERVAL_LOCATE" +) + +// environment variable +type envVar string + +const defaultConfig = ` +# How many directories deep to search by default. +# 0 = the path itself +# 1 = immediate children of the path +# 2 = grandchildren of the path +# etc. +# default: 2 +# +# depth = 2 + +# How long to cache the list of projects for. +# default: 5m +# +# cache-age = "5m" + +# Each search path is specified by a [[paths]] header and +# requires a path value. +# E.g.: +# +# [[paths]] +# path = "~/Dropbox" +# +# You can override the default depth: +# +# +# [[paths]] +# path = "~/Code" +# depth = 3 + +` + +type config struct { + FindInterval time.Duration `toml:"-"` + MDFindInterval time.Duration `toml:"-"` + LocateInterval time.Duration `toml:"-"` + Excludes []string `toml:"excludes"` + Depth int `toml:"depth"` + SearchPaths []*searchPath `toml:"paths"` +} + +func (c *config) String() string { + return fmt.Sprintf(` +INTERVAL_FIND=%s +INTERVAL_MDFIND=%s +INTERVAL_LOCATE=%s +depth=%d`, c.FindInterval, c.MDFindInterval, + c.LocateInterval, c.Depth) +} + +type searchPath struct { + Path string `toml:"path"` + Excludes []string `toml:"excludes"` + Depth int `toml:"depth"` +} + +func timed(start time.Time, title string) { + log.Printf("%s \U000029D7 %s", time.Now().Sub(start), title) +} + +// Copy default settings file to data directory if there is no +// existing settings file. +func initConfig() error { + if _, err := os.Stat(configFile); os.IsNotExist(err) { + if err := ioutil.WriteFile(configFile, []byte(defaultConfig), os.ModePerm); err != nil { + return err + } + } + return nil +} + +// Load configuration file. +func loadConfig(path string) (*config, error) { + + defer timed(time.Now(), "load config") + + data, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + if err := toml.Unmarshal(data, &conf); err != nil { + return nil, err + } + + if conf == nil { // config file was empty + conf = &config{ + Depth: DefaultDepth, + SearchPaths: []*searchPath{}, + } + } + + // Environment variables + conf.FindInterval = getEnvDuration(envFindInterval, 0) + conf.MDFindInterval = getEnvDuration(envMDFindInterval, 0) + conf.LocateInterval = getEnvDuration(envLocateInterval, 0) + + // Update depths + if conf.Depth == 0 { + conf.Depth = DefaultDepth + } + for _, sp := range conf.SearchPaths { + if sp.Depth == 0 { + sp.Depth = conf.Depth + } + sp.Path = expandPath(sp.Path) + } + + return conf, nil +} + +func getEnvDuration(key string, fallback time.Duration) time.Duration { + s := os.Getenv(key) + + log.Printf("[env] %s=%s", key, s) + + if s == "" { + return fallback + } + d, err := time.ParseDuration(s) + if err != nil { + log.Printf(`[env] invalid duration (%s) for "%s": %v`, s, key, err) + return fallback + } + return d +} diff --git a/icon.png b/icon.png index 114badf..9db683c 120000 --- a/icon.png +++ b/icon.png @@ -1 +1 @@ -src/icon.png \ No newline at end of file +icons/icon.png \ No newline at end of file diff --git a/icons/Sublime Text.acorn b/icons/Sublime Text.acorn new file mode 100644 index 0000000..d2fd604 Binary files /dev/null and b/icons/Sublime Text.acorn differ diff --git a/icons/docs.png b/icons/docs.png new file mode 100644 index 0000000..9300385 Binary files /dev/null and b/icons/docs.png differ diff --git a/icons/help.png b/icons/help.png new file mode 100644 index 0000000..59ac8c6 Binary files /dev/null and b/icons/help.png differ diff --git a/icons/icon.acorn b/icons/icon.acorn new file mode 100644 index 0000000..7883ab4 Binary files /dev/null and b/icons/icon.acorn differ diff --git a/icons/icon.png b/icons/icon.png new file mode 100644 index 0000000..65d9dd9 Binary files /dev/null and b/icons/icon.png differ diff --git a/icons/icons.txt b/icons/icons.txt new file mode 100644 index 0000000..02a3a7d --- /dev/null +++ b/icons/icons.txt @@ -0,0 +1,32 @@ +# Icons from webfonts via http://icons.deanishe.net +# +# Script ../bin/icons reads this file and downloads the +# specified icons to this directory. +# +# Colours +# blue: 5485F3 +# yellow: F8AC30 +# red: B00000 +# green: 03AE03 +# + +# filename font name colour icon name + +# icon material 5485F3 calendar +# calendar-today material 03AE03 calendar +# calendar-on material 03AE03 calendar-check +# calendar-off material B00000 calendar-close +# day weathericons F8AC30 sunrise +# previous fontawesome 5484F3 arrow-circle-left +# next fontawesome 5484F3 arrow-circle-right +# map elusive 03AE03 map-marker +on fontawesome 03AE03 dot-circle-o +off fontawesome B00000 circle-o +reload fontawesome F8AC30 refresh +trash fontawesome B00000 trash-o +update-available material F8AC30 cloud-download +update-ok material 03AE03 cloud-done +url fontawesome 5485F3 globe +help material 03AE03 help +docs material 5485F3 help +issue fontawesome F8AC30 bug diff --git a/icons/issue.png b/icons/issue.png new file mode 100644 index 0000000..2a0e3fc Binary files /dev/null and b/icons/issue.png differ diff --git a/icons/off.png b/icons/off.png new file mode 100644 index 0000000..9502bee Binary files /dev/null and b/icons/off.png differ diff --git a/icons/on.png b/icons/on.png new file mode 100644 index 0000000..2e96bec Binary files /dev/null and b/icons/on.png differ diff --git a/icons/reload.png b/icons/reload.png new file mode 100644 index 0000000..c0db1f5 Binary files /dev/null and b/icons/reload.png differ diff --git a/icons/trash.png b/icons/trash.png new file mode 100644 index 0000000..f498c59 Binary files /dev/null and b/icons/trash.png differ diff --git a/icons/update-available.png b/icons/update-available.png new file mode 100644 index 0000000..21e3a15 Binary files /dev/null and b/icons/update-available.png differ diff --git a/icons/update-ok.png b/icons/update-ok.png new file mode 100644 index 0000000..10caee5 Binary files /dev/null and b/icons/update-ok.png differ diff --git a/icons/url.png b/icons/url.png new file mode 100644 index 0000000..8ffce40 Binary files /dev/null and b/icons/url.png differ diff --git a/info.plist b/info.plist new file mode 100644 index 0000000..fddd42d --- /dev/null +++ b/info.plist @@ -0,0 +1,455 @@ + + + + + bundleid + net.deanishe.alfred.sublime-text-projects + connections + + 0D6DB001-6C1A-4973-BD3C-0CD4706096CB + + + destinationuid + 506077C9-6BF8-401D-B34D-ACAEAA975F30 + modifiers + 0 + modifiersubtext + + vitoclose + + + + destinationuid + E934D964-B0B8-4A32-8DF3-4692A8F26E97 + modifiers + 1048576 + modifiersubtext + Reveal in Finder + vitoclose + + + + 1958F9DC-5B69-4F0C-B4DC-FCBAE0AB1EF8 + + + destinationuid + 0F77272E-39A5-44AD-BB24-462F199B5590 + modifiers + 0 + modifiersubtext + + vitoclose + + + + 36EE8FBB-FCCE-483E-AFCE-AC73906117C3 + + + destinationuid + 31AB2913-1B83-4976-BE10-2526F543F5E3 + modifiers + 0 + modifiersubtext + + vitoclose + + + + 506077C9-6BF8-401D-B34D-ACAEAA975F30 + + + destinationuid + 7D52E522-509F-4E58-8CB7-B705389C78CB + modifiers + 0 + modifiersubtext + + vitoclose + + + + 9981F708-6C83-44CD-BC06-B6C10A2B00F6 + + + destinationuid + 1958F9DC-5B69-4F0C-B4DC-FCBAE0AB1EF8 + modifiers + 0 + modifiersubtext + + vitoclose + + + + + createdby + Dean Jackson + description + Find and open Sublime Text projects + disabled + + name + Sublime Text Projects + objects + + + config + + alfredfiltersresults + + alfredfiltersresultsmatchmode + 0 + argumenttrimmode + 0 + argumenttype + 1 + escaping + 68 + keyword + .st + queuedelaycustom + 1 + queuedelayimmediatelyinitially + + queuedelaymode + 0 + queuemode + 1 + runningsubtext + Finding projects… + script + ./alfsubl search "$1" + scriptargtype + 1 + scriptfile + + subtext + Search and Open Sublime Text Projects + title + Sublime Text Projects + type + 0 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 0D6DB001-6C1A-4973-BD3C-0CD4706096CB + version + 2 + + + config + + lastpathcomponent + + onlyshowifquerypopulated + + removeextension + + text + {query} + title + Error + + type + alfred.workflow.output.notification + uid + 7D52E522-509F-4E58-8CB7-B705389C78CB + version + 1 + + + config + + concurrently + + escaping + 102 + script + # Path to .sublime-project file +proj="$1" + +# log <arg>... | echo args to STDERR +log() { + echo "$@" >&2 +} + +# error <arg>... | echo args to STDOUT +error() { + echo "$@" >&1 +} + +# Open with `subl` command by preference, +# as it works much better than the app. +# +candidates=('/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl') +candidates+=('/usr/local/bin/subl') + + +for prog in $candidates; do + + test -x "$prog" || continue + + log "opening \"$proj\" with \"$prog\" ..." + + "$prog" "$proj" + st=$? + [[ $st -eq 0 ]] || { + log "[error] '$prog' exited with $st" + error "call to subl command failed" + } + exit $st + +done + +# Failed to find `subl` command, so launch with app +/usr/bin/open -a "Sublime Text" "$proj" +st=$? +[[ $st -eq 0 ]] || { + log "[error] 'open' exited with $st" + error "call to Sublime Text.app failed" +} +exit $st + scriptargtype + 1 + scriptfile + + type + 5 + + type + alfred.workflow.action.script + uid + 506077C9-6BF8-401D-B34D-ACAEAA975F30 + version + 2 + + + type + alfred.workflow.action.revealfile + uid + E934D964-B0B8-4A32-8DF3-4692A8F26E97 + version + 1 + + + config + + lastpathcomponent + + onlyshowifquerypopulated + + removeextension + + text + {query} + title + Sublime Projects + + type + alfred.workflow.output.notification + uid + 0F77272E-39A5-44AD-BB24-462F199B5590 + version + 1 + + + config + + alfredfiltersresults + + alfredfiltersresultsmatchmode + 0 + argumenttrimmode + 0 + argumenttype + 2 + escaping + 127 + keyword + .stconfig + queuedelaycustom + 1 + queuedelayimmediatelyinitially + + queuedelaymode + 0 + queuemode + 1 + runningsubtext + Reading settings… + script + ./alfsubl config "$1" + scriptargtype + 1 + scriptfile + + subtext + View and edit workflow settings + title + Settings for Sublime Text Projects + type + 0 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 9981F708-6C83-44CD-BC06-B6C10A2B00F6 + version + 2 + + + config + + concurrently + + escaping + 102 + script + /usr/bin/python sublime.py --action "{query}" + scriptargtype + 0 + scriptfile + + type + 0 + + type + alfred.workflow.action.script + uid + 1958F9DC-5B69-4F0C-B4DC-FCBAE0AB1EF8 + version + 2 + + + config + + concurrently + + escaping + 127 + script + open ./Help.html + scriptargtype + 0 + scriptfile + + type + 0 + + type + alfred.workflow.action.script + uid + 31AB2913-1B83-4976-BE10-2526F543F5E3 + version + 2 + + + config + + argumenttype + 2 + keyword + .sthelp + subtext + View the help file for this Workflow + text + Sublime Text Projects Help + withspace + + + type + alfred.workflow.input.keyword + uid + 36EE8FBB-FCCE-483E-AFCE-AC73906117C3 + version + 1 + + + readme + + uidata + + 0D6DB001-6C1A-4973-BD3C-0CD4706096CB + + xpos + 320 + ypos + 50 + + 0F77272E-39A5-44AD-BB24-462F199B5590 + + xpos + 720 + ypos + 330 + + 1958F9DC-5B69-4F0C-B4DC-FCBAE0AB1EF8 + + xpos + 520 + ypos + 330 + + 31AB2913-1B83-4976-BE10-2526F543F5E3 + + xpos + 520 + ypos + 470 + + 36EE8FBB-FCCE-483E-AFCE-AC73906117C3 + + xpos + 320 + ypos + 470 + + 506077C9-6BF8-401D-B34D-ACAEAA975F30 + + xpos + 520 + ypos + 50 + + 7D52E522-509F-4E58-8CB7-B705389C78CB + + xpos + 720 + ypos + 50 + + 9981F708-6C83-44CD-BC06-B6C10A2B00F6 + + xpos + 320 + ypos + 330 + + E934D964-B0B8-4A32-8DF3-4692A8F26E97 + + xpos + 520 + ypos + 190 + + + variables + + INTERVAL_FIND + 30m + INTERVAL_LOCATE + 12h + INTERVAL_MDFIND + 10m + + version + 3.0.0 + webaddress + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..4f9663f --- /dev/null +++ b/main.go @@ -0,0 +1,128 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-26 +// + +package main + +import ( + "log" + "path/filepath" + + aw "github.com/deanishe/awgo" + docopt "github.com/docopt/docopt.go" +) + +var usage = `alfsubl [] + +Alfred workflow to show Sublime Text projects. + +Usage: + alfsubl search [] + alfsubl config [] + alfsubl rescan [--force] + alfsubl reset + alfsubl [help] + +Options: + -f, --force ignore cached data + -h, --help show this message and exit + -v, --version show version number and exit +` + +var ( + cacheKey = "projects.json" + conf *config + configFile string + command string + force bool + query string + commands = []string{"config", "search", "rescan", "reset", "help"} + wf *aw.Workflow +) + +func init() { + wf = aw.New() + configFile = filepath.Join(wf.DataDir(), "sublime.toml") +} + +// set variables based on user input +func parseArgs(argv []string) error { + opts, err := docopt.ParseArgs(usage, argv, wf.Version()) + if err != nil { + return err + } + + for _, s := range commands { + if opts[s] == true { + command = s + break + } + } + + force = opts["--force"].(bool) + + if s, ok := opts[""].(string); ok { + query = s + } + + log.Printf("opts=%#v", opts) + + return nil +} + +// workflow entry point +func run() { + + var err error + + // Command-line args + if err := parseArgs(wf.Args()); err != nil { + log.Printf("couldn't parse args (%#v): %v", wf.Args(), err) + wf.Fatal("Couldn't parse args. Check log file.") + } + + // Load configuration file + if err := initConfig(); err != nil { + log.Printf("couldn't create config (%s): %v", configFile, err) + wf.Fatal("Couldn't create config. Check log file.") + } + conf, err = loadConfig(configFile) + if err != nil { + log.Printf("couldn't read config (%s): %v", configFile, err) + wf.Fatal("Couldn't read config. Check log file.") + } + + log.Printf("command=%s, query=%s", command, query) + // log.Printf("configFile=%s", configFile) + // log.Printf("config=%s", conf.String()) + + switch command { + case "help", "": + docopt.PrintHelpOnly(nil, usage) + return + + case "search": + runSearch() + return + + case "config": + runConfig() + return + + case "rescan": + runScan() + return + + default: + wf.Fatalf("Unknown Command: %s", command) + } +} + +// wrap run() in AwGo to catch and display panics +func main() { + wf.Run(run) +} diff --git a/modd.conf b/modd.conf new file mode 100644 index 0000000..e6de8ee --- /dev/null +++ b/modd.conf @@ -0,0 +1,23 @@ + +bin/build +*.html { + prep: ./bin/build +} + +icons/icons.txt { + prep: ./bin/icons +} + +**/*_test.go +!vendor/** { + prep: go test -v @dirmods +} + +modd.conf +**/*.go +!**/*_test.go +!vendor/** { + prep: go test -v @dirmods && \ + ./bin/build && \ + ./alfsubl search "omegat" +} diff --git a/project.go b/project.go new file mode 100644 index 0000000..688c88a --- /dev/null +++ b/project.go @@ -0,0 +1,75 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-27 +// + +package main + +import ( + "encoding/json" + "io/ioutil" + "path/filepath" + "strings" +) + +// Project is a Sublime Text project. +type Project struct { + Path string + Folders []string +} + +type sublimeProject struct { + Folders []sublimeFolder `json:"folders"` +} + +type sublimeFolder struct { + Path string `json:"path"` +} + +// NewProject reads a .sublime-project file. +func NewProject(path string) (Project, error) { + + var ( + dir = filepath.Dir(path) + proj = Project{Path: path} + raw = sublimeProject{} + data []byte + err error + ) + + data, err = ioutil.ReadFile(path) + if err != nil { + return proj, err + } + + if err = json.Unmarshal(data, &raw); err == nil { + + proj.Folders = []string{} + for _, f := range raw.Folders { + + if p := resolvePath(dir, f.Path); p != "" { + proj.Folders = append(proj.Folders, p) + } + + } + + } + + return proj, err +} + +func resolvePath(base, relpath string) string { + + if strings.HasPrefix(relpath, "/") { + return relpath + } + if base == "" || relpath == "" { + return "" + } + + p := filepath.Join(base, relpath) + return filepath.Clean(p) +} diff --git a/project_test.go b/project_test.go new file mode 100644 index 0000000..bd71fd5 --- /dev/null +++ b/project_test.go @@ -0,0 +1,110 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-27 +// + +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" +) + +var ( + testProjJS = `{ + "folders": + [ + { + "path": "/usr/local/bin" + }, + { + "path": "/etc" + }, + { + "path": "." + } + ] +}` + testProjPaths = []string{"/usr/local/bin", "/etc"} +) + +func withTestFile(data []byte, fn func(path string)) error { + + f, err := ioutil.TempFile("", "alfred-sublime-") + if err != nil { + return err + } + defer os.Remove(f.Name()) + + if _, err := f.Write(data); err != nil { + return err + } + + fn(f.Name()) + + return nil +} + +func TestParseProject(t *testing.T) { + + err := withTestFile([]byte(testProjJS), func(path string) { + + dir := filepath.Dir(path) + paths := make([]string, len(testProjPaths)) + copy(paths, testProjPaths) + paths = append(paths, dir) + + proj, err := NewProject(path) + if err != nil { + t.Fatalf("couldn't create new project: %v", err) + } + + if proj.Path != path { + t.Errorf("Bad Path. Expected=%v, Got=%v", path, proj.Path) + } + + if len(proj.Folders) != len(paths) { + t.Fatalf("Bad Folders length. Expected=%v, Got=%v", len(paths), len(proj.Folders)) + } + + for i, s := range proj.Folders { + if s != paths[i] { + t.Errorf("Bad Folder. Expected=%v, Got=%v", paths[i], s) + } + } + + }) + if err != nil { + t.Fatalf("couldn't create tempfile: %v", err) + } + +} + +func TestResolvePath(t *testing.T) { + data := []struct { + base, rel, out string + }{ + {"/", "home/bob", "/home/bob"}, + {"/home/bob", ".", "/home/bob"}, + {".", "/home/bob", "/home/bob"}, + {".", "bob", "bob"}, + {".", "bob/public", "bob/public"}, + {"./bob", "public", "bob/public"}, + {"home", "bob", "home/bob"}, + {"", "", ""}, + {"home", "", ""}, + {"", "bob", ""}, + } + + for _, td := range data { + s := resolvePath(td.base, td.rel) + if s != td.out { + t.Errorf("Bad ResolvePath. Expected=%v, Got=%v", td.out, s) + } + } +} diff --git a/scan.go b/scan.go new file mode 100644 index 0000000..f076f35 --- /dev/null +++ b/scan.go @@ -0,0 +1,401 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-26 +// + +package main + +import ( + "bufio" + "bytes" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/deanishe/awgo/util" + "github.com/gobwas/glob" +) + +var ( + locateDBPath = "/var/db/locate.database" + scanners = map[string]Scanner{ + "mdfind": &mdfindScanner{&cacher{name: "mdfind"}}, + "locate": &locateScanner{&cacher{name: "locate"}}, + } +) + +// return true if at least one scanner wants to scan +func scanDue() bool { + + if !wf.Cache.Exists(cacheKey) { + return true + } + + for _, sc := range scanners { + if sc.Due() { + log.Printf("[%s] rescan due", sc.Name()) + return true + } + } + return false +} + +// load all ST projects +func scan() <-chan ScanResult { + + var ( + ins []<-chan ScanResult + out = make(<-chan ScanResult) + ) + + for name, scanner := range scanners { + + if !scanner.Ready() { + log.Printf("[%s] inactive", scanner.Name()) + continue + } + + log.Printf("[%s] starting ...", name) + if c, err := scanner.Scan(); err == nil { + ins = append(ins, c) + } else { + log.Printf("[%s] error: %v", name, err) + } + } + + // real programs have middleware + out = filterExcludes( + filterNotExist( + filterDupes( + filterNotProject( + merge(ins...), + ), + ), + ), + conf.Excludes) + + return out +} + +// ScanResult is returned by a scanner. +type ScanResult struct { + Dir string // first directory listed in the .sublime-project file + Path string // path of the .sublime-project file + Scanner string // name of scanner that found it +} + +// Name returns the name of the project (the filename w/o extension). +func (r ScanResult) Name() string { + + if r.Path == "" { + return "" + } + + s, x := filepath.Base(r.Path), filepath.Ext(r.Path) + if x == "" || x == "." { + return s + } + + return s[0 : len(s)-len(x)] +} + +func (r ScanResult) String() string { + return fmt.Sprintf("[%s] %s", r.Scanner, r.Path) +} + +// Scanner finds Sublime Text project files. +type Scanner interface { + Name() string // name of scanner + Due() bool // whether scanner wants to rescan + Ready() bool // whether scanner is runnable + Scan() (<-chan ScanResult, error) // scan for projects +} + +// cacher is a base Scanner that can load and save cached data. +type cacher struct { + name string + fromCache bool +} + +func (c *cacher) Name() string { return c.name } + +func (c *cacher) cacheName() string { + return "projects-" + c.Name() + ".txt" +} + +// HasCache returns true if cache is valid. +func (c *cacher) HasCache(maxAge time.Duration) bool { + return !wf.Cache.Expired(c.cacheName(), maxAge) +} + +func (c *cacher) Loader() chan ScanResult { + + var out = make(chan ScanResult) + + go func() { + + defer close(out) + + data, err := wf.Cache.Load(c.cacheName()) + if err != nil { + log.Printf(`[cache] load error for "%s": %v`, c.Name(), err) + return + } + + buf := bytes.NewBuffer(data) + scanner := bufio.NewScanner(buf) + var i int + for scanner.Scan() { + out <- ScanResult{Path: scanner.Text(), Scanner: c.Name()} + i++ + } + if err := scanner.Err(); err != nil { + log.Printf(`[cache] reading error for "%s": %v`, c.Name(), err) + } else { + log.Printf(`[cache] %d projects loaded for "%s"`, i, c.Name()) + } + }() + + return out +} +func (c *cacher) Saver(in <-chan ScanResult, err error) (chan ScanResult, error) { + + if err != nil { + return nil, err + } + + var ( + out = make(chan ScanResult) + paths []string + ) + + go func() { + defer close(out) + + for r := range in { + + out <- r + + paths = append(paths, r.Path) + } + + data := []byte(strings.Join(paths, "\n") + "\n") + if err := wf.Cache.Store(c.cacheName(), data); err != nil { + log.Printf(`[cache] save error for "%s": %v`, c.Name(), err) + return + } + log.Printf(`[cache] %d projects saved for "%s"`, len(paths), c.Name()) + }() + + return out, nil +} + +// Find .sublime-project files with `mdfind` +type mdfindScanner struct { + *cacher +} + +func (s *mdfindScanner) Name() string { return "mdfind" } + +func (s *mdfindScanner) Due() bool { + if conf.MDFindInterval == 0 { + return false + } + return !s.HasCache(conf.MDFindInterval) +} + +func (s *mdfindScanner) Ready() bool { + return conf.MDFindInterval != 0 +} + +func (s *mdfindScanner) Scan() (<-chan ScanResult, error) { + if s.HasCache(conf.MDFindInterval) { + return s.Loader(), nil + } + cmd := exec.Command("/usr/bin/mdfind", "-name", ".sublime-project") + return s.Saver(lineCommand(cmd, s.Name())) +} + +// Find *.sublime-project files with `locate` +type locateScanner struct { + *cacher +} + +func (s *locateScanner) Name() string { return "locate" } + +func (s *locateScanner) Due() bool { + if conf.LocateInterval == 0 { + return false + } + return !s.HasCache(conf.LocateInterval) +} + +func (s *locateScanner) Ready() bool { + if conf.LocateInterval == 0 { + return false + } + if !util.PathExists(locateDBPath) { + return false + } + return true +} +func (s *locateScanner) Scan() (<-chan ScanResult, error) { + if s.HasCache(conf.LocateInterval) { + return s.Loader(), nil + } + cmd := exec.Command("/usr/bin/locate", "*.sublime-project") + return s.Saver(lineCommand(cmd, s.Name())) +} + +// Run a command and write the lines of its output to a channel. +func lineCommand(cmd *exec.Cmd, name string) (chan ScanResult, error) { + + var ( + out = make(chan ScanResult, 100) + err error + ) + + go func() { + + defer close(out) + defer timed(time.Now(), fmt.Sprintf("%s scan", name)) + + stdout, err := cmd.StdoutPipe() + if err != nil { + log.Printf("[%s] command failed: %v", name, err) + return + } + + if err := cmd.Start(); err != nil { + log.Printf("[%s] command failed: %v", name, err) + return + } + + // Read mdfind output and send it to channel + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + out <- ScanResult{Path: scanner.Text(), Scanner: name} + } + if err := scanner.Err(); err != nil { + log.Printf("[%s] couldn't parse output: %v", name, err) + } + + if err != cmd.Wait() { + log.Printf("[%s] command failed: %v", name, err) + } + }() + + return out, err +} + +// Filter files that match any of the glob patterns. +func filterExcludes(in <-chan ScanResult, patterns []string) <-chan ScanResult { + var globs []glob.Glob + + // Compile patterns + for _, s := range patterns { + if g, err := glob.Compile(s); err == nil { + globs = append(globs, g) + } else { + log.Printf("[filter] invalid pattern (%s): %v", s, err) + } + } + + return filterMatches(in, func(r ScanResult) bool { + for _, g := range globs { + if g.Match(r.Path) { + // log.Printf("[filter] ignored (%s): %s", g, r.String()) + return true + } + } + return false + }) +} + +func filterNotProject(in <-chan ScanResult) <-chan ScanResult { + return filterMatches(in, func(r ScanResult) bool { + return !strings.HasSuffix(r.Path, ".sublime-project") + }) +} + +// Filter files that don't exist. +func filterNotExist(in <-chan ScanResult) <-chan ScanResult { + return filterMatches(in, func(r ScanResult) bool { + if _, err := os.Stat(r.Path); err != nil { + // log.Printf("[filter] doesn't exist: %s", p) + return true + } + return false + }) +} + +// Filter files that have already passed through. +func filterDupes(in <-chan ScanResult) <-chan ScanResult { + + seen := map[string]bool{} + + return filterMatches(in, func(r ScanResult) bool { + + if seen[r.Path] { + // log.Printf("[filter] duplicate: %s", r.String()) + return true + } + + seen[r.Path] = true + return false + }) +} + +// passes through paths from in to out, ignoring those for which ignore(path) returns true. +func filterMatches(in <-chan ScanResult, ignore func(r ScanResult) bool) <-chan ScanResult { + + var out = make(chan ScanResult) + + go func() { + defer close(out) + + for r := range in { + if ignore(r) { + continue + } + out <- r + } + }() + + return out +} + +// Combine the output of multiple channels into one. +func merge(ins ...<-chan ScanResult) <-chan ScanResult { + var ( + wg sync.WaitGroup + out = make(chan ScanResult) + ) + + wg.Add(len(ins)) + + for _, in := range ins { + + go func(in <-chan ScanResult) { + defer wg.Done() + for r := range in { + out <- r + } + }(in) + } + + go func() { + wg.Wait() + close(out) + }() + + return out +} diff --git a/scan_test.go b/scan_test.go new file mode 100644 index 0000000..9117b45 --- /dev/null +++ b/scan_test.go @@ -0,0 +1,35 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-27 +// + +package main + +import "testing" + +func TestScanResult(t *testing.T) { + paths := []struct { + in, out string + }{ + {"", ""}, + {".", "."}, + {"path/.", "."}, + {"/", "/"}, + {"~/Documents", "Documents"}, + {"/Applications/Safari.app", "Safari"}, + {"./Alfred Sublime.sublime-project", "Alfred Sublime"}, + {"./path/to/something.txt", "something"}, + } + + for _, td := range paths { + + r := ScanResult{Path: td.in} + + if r.Name() != td.out { + t.Errorf("Bad Name. Expected=%v, Got=%v", td.out, r.Name()) + } + } +} diff --git a/sublime.toml b/sublime.toml new file mode 100644 index 0000000..9ce5352 --- /dev/null +++ b/sublime.toml @@ -0,0 +1,28 @@ + +# How many directories deep to search by default. +# 0 = the path itself +# 1 = immediate children of the path +# 2 = grandchildren of the path +# etc. +# default: 2 +# +# depth = 2 + +# How long to cache the list of projects for. +# default: 5m +# +# cache-age = "5m" + +# Each search path is specified by a [[paths]] header and +# requires a path value. +# E.g.: +# +# [[paths]] +# path = "~/Dropbox" +# +# You can override the default depth: +# +# +# [[paths]] +# path = "~/Code" +# depth = 3 diff --git a/util.go b/util.go new file mode 100644 index 0000000..2ba3bd1 --- /dev/null +++ b/util.go @@ -0,0 +1,68 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-26 +// + +package main + +import ( + "os" + "path/filepath" + "strings" +) + +// calculate the relative depth between base and dir. +// +// base itself has a depth of 0, its immediate children of 1 etc. +// If dir is not under base (and is not base itself), -1 is returned. +func reldepth(base, dir string) int { + + base = filepath.Clean(base) + dir = filepath.Clean(dir) + + if base == "." { + base = "" + } + if dir == "." { + dir = "" + } + + if !strings.HasPrefix(dir, base) { + // log("no match: base=%s, dir=%s", base, dir) + return -1 + } + + if base == dir { + return 0 + } + + if strings.HasPrefix(dir, "/") { + base = base[1:] + dir = dir[1:] + } + + db := len(strings.Split(base, "/")) + dd := len(strings.Split(dir, "/")) + + if base == "" { + db = 0 + } + if dir == "" { + dd = 0 + } + + return (dd - db) +} + +// Replace ~ in a path with the home directory. +func expandPath(path string) string { + if !strings.HasPrefix(path, "~") { + return path + } + + path = os.ExpandEnv("$HOME") + path[1:] + return filepath.Clean(path) +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..0eb8de3 --- /dev/null +++ b/util_test.go @@ -0,0 +1,50 @@ +// +// Copyright (c) 2018 Dean Jackson +// +// MIT Licence. See http://opensource.org/licenses/MIT +// +// Created on 2018-01-26 +// + +package main + +import "testing" + +func TestRelDepth(t *testing.T) { + data := []struct { + base, dir string + depth int + }{ + {"", "", 0}, + {".", ".", 0}, + {".", ".", 0}, + {"/", "/", 0}, + + {"/", "/dir1", 1}, + {"/", "/dir1/dir2", 2}, + {"/", "/dir1/dir2/dir3", 3}, + {"/", "/dir1/dir2/dir3/dir4", 4}, + {"/dir1", "/dir1/dir2/dir3/dir4", 3}, + {"/dir1/dir2", "/dir1/dir2/dir3/dir4", 2}, + {"/dir1/dir2/dir3", "/dir1/dir2/dir3/dir4", 1}, + + {"", "dir1", 1}, + {"", "dir1/dir2", 2}, + {"", "dir1/dir2/dir3", 3}, + {"", "dir1/dir2/dir3/dir4", 4}, + {"dir1", "dir1/dir2/dir3/dir4", 3}, + {"dir1/dir2", "dir1/dir2/dir3/dir4", 2}, + {"dir1/dir2/dir3", "dir1/dir2/dir3/dir4", 1}, + + {"/dir1", "/dir2", -1}, + {"/dir1", "/", -1}, + } + + for _, td := range data { + n := reldepth(td.base, td.dir) + if n != td.depth { + t.Errorf("Bad Depth. Expected=%d, Got=%d, Base=%s, Dir=%s", + td.depth, n, td.base, td.dir) + } + } +}