diff --git a/internal/geocode/geocode.go b/internal/geocode/geocode.go index 5f1852957..16c715eaf 100644 --- a/internal/geocode/geocode.go +++ b/internal/geocode/geocode.go @@ -21,6 +21,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "log" "net/url" @@ -60,6 +61,10 @@ type Rect struct { // when Lookup is not being called. var AltLookupFn func(ctx context.Context, address string) ([]Rect, error) +const ( + apiKeyName = "google-geocode.key" +) + var ( mu sync.RWMutex cache = map[string][]Rect{} @@ -68,7 +73,18 @@ var ( sf singleflight.Group ) -func getAPIKey() (string, error) { +// GetAPIKeyPath returns the file path to the Google geocoding API key. +func GetAPIKeyPath() (string, error) { + dir, err := osutil.PerkeepConfigDir() + if err != nil { + return "", fmt.Errorf("could not get config dir: %v", err) + } + return filepath.Join(dir, apiKeyName), nil +} + +// GetAPIKey returns the Google geocoding API key stored in the Perkeep +// configuration directory as google-geocode.key. +func GetAPIKey() (string, error) { mu.RLock() key := apiKey mu.RUnlock() @@ -82,7 +98,7 @@ func getAPIKey() (string, error) { if err != nil { return "", err } - slurp, err := wkfs.ReadFile(filepath.Join(dir, "google-geocode.key")) + slurp, err := wkfs.ReadFile(filepath.Join(dir, apiKeyName)) if os.IsNotExist(err) { return "", ErrNoGoogleKey } @@ -113,7 +129,7 @@ func Lookup(ctx context.Context, address string) ([]Rect, error) { return rects, nil } - key, err := getAPIKey() + key, err := GetAPIKey() if err != nil { return nil, err } diff --git a/pkg/deploy/gce/deploy.go b/pkg/deploy/gce/deploy.go index a0109e425..58c76e5a6 100644 --- a/pkg/deploy/gce/deploy.go +++ b/pkg/deploy/gce/deploy.go @@ -349,6 +349,7 @@ func (d *Deployer) enableAPIs() error { "storage-api.googleapis.com": "Google Cloud Storage JSON", "logging.googleapis.com": "Stackdriver Logging", "compute.googleapis.com": "Google Compute Engine", + "geocoding-backend.googleapis.com": "Google Maps Geocoding", } enabledServices := make(map[string]bool) for _, v := range list.Services { diff --git a/server/perkeepd/perkeepd.go b/server/perkeepd/perkeepd.go index 3ca86a805..e3a8f8c99 100644 --- a/server/perkeepd/perkeepd.go +++ b/server/perkeepd/perkeepd.go @@ -35,6 +35,7 @@ import ( "syscall" "time" + "perkeep.org/internal/geocode" "perkeep.org/internal/httputil" "perkeep.org/internal/netutil" "perkeep.org/internal/osutil" @@ -369,6 +370,23 @@ func setBlobpackedRecovery() { } } +// checkGeoKey returns nil if we have a Google Geocoding API key file stored +// in the config dir. Otherwise it returns instruction about it as the error. +func checkGeoKey() error { + if _, err := geocode.GetAPIKey(); err == nil { + return nil + } + keyPath, err := geocode.GetAPIKeyPath() + if err != nil { + return fmt.Errorf("error getting Geocoding API key path: %v", err) + } + if env.OnGCE() { + keyPath = strings.TrimPrefix(keyPath, "/gcs/") + return fmt.Errorf("for location related requests to properly work, you need to create a Google Geocoding API Key (see https://developers.google.com/maps/documentation/geocoding/get-api-key ), and save it in your VM's configuration bucket as: %v", keyPath) + } + return fmt.Errorf("for location related requests to properly work, you need to create a Google Geocoding API Key (see https://developers.google.com/maps/documentation/geocoding/get-api-key ), and save it in Perkeep's configuration directory as: %v", keyPath) +} + // main wraps Main so tests (which generate their own func main) can still run Main. func main() { Main() } @@ -451,6 +469,10 @@ func Main() { gce.FixUserDataForPerkeepRename() } + if err := checkGeoKey(); err != nil { + log.Printf("perkeepd: %v", err) + } + urlToOpen := baseURL + config.UIPath() if *flagOpenBrowser { diff --git a/server/perkeepd/ui/index.js b/server/perkeepd/ui/index.js index eb5d9ad0b..dba9aa520 100644 --- a/server/perkeepd/ui/index.js +++ b/server/perkeepd/ui/index.js @@ -173,6 +173,10 @@ cam.IndexPage = React.createClass({ // messageDialogVisible to true. messageDialogContents: null, messageDialogVisible: false, + // dialogWidth and dialogHeight should be set to accomodate the size of + // the text message we display in the dialog. + dialogWidth: 0, + dialogHeight: 0, }; }, @@ -728,7 +732,8 @@ cam.IndexPage = React.createClass({ } console.log('Creating new search session for query %s', queryString); - var ss = new cam.SearchSession(this.props.serverConnection, this.baseURL_.clone(), opt_query, opt_targetBlobref, opt_sort); + var ss = new cam.SearchSession(this.props.serverConnection, this.baseURL_.clone(), opt_query, + this.handleSearchQueryError_.bind(this), opt_targetBlobref, opt_sort); this.eh_.listen(ss, cam.SearchSession.SEARCH_SESSION_CHANGED, function() { this.forceUpdate(); }); @@ -745,6 +750,25 @@ cam.IndexPage = React.createClass({ return ss; }, + // handleSearchQueryError_ removes the last search query from the search session + // cache, and displays the errorMsg in a dialog. + handleSearchQueryError_: function(errorMsg) { + this.searchSessionCache_.splice(0, 1); + var nbl = errorMsg.length / 40; // 40 chars per line. + this.setState({ + messageDialogVisible: true, + dialogWidth: 40*16, // 16px char width, 40 chars width + dialogHeight: (nbl+1)*1.5*16, // 16px char height, and 1.5 to account for line spacing + messageDialogContents: React.DOM.div({ + style: { + textAlign: 'center', + fontSize: 'medium', + },}, + React.DOM.div({}, errorMsg) + ), + }); + }, + pruneSearchSessionCache_: function() { for (var i = this.SEARCH_SESSION_CACHE_SIZE_; i < this.searchSessionCache_.length; i++) { this.searchSessionCache_[i].close(); @@ -1412,23 +1436,27 @@ cam.IndexPage = React.createClass({ } var borderWidth = 18; - // TODO(mpl): make it dynamically proportional to the size of - // the contents. For now, I know I want to display a ~40 chars wide - // message, hence the rough 50em*16px/em. - var w = 50*16; - var h = 10*16; + var w = this.state.dialogWidth; + var h = this.state.dialogHeight; + if (w == 0 || h == 0) { + // arbitrary defaults + w = 50*16; + h = 10*16; + } return React.createElement(cam.Dialog, { availWidth: this.props.availWidth, availHeight: this.props.availHeight, - width: w, - height: h, + width: this.state.dialogWidth, + height: this.state.dialogHeight, borderWidth: borderWidth, onClose: function() { this.setState({ messageDialogVisible: false, messageDialogContents: null, importShareURL: null, + dialogWidth: 0, + dialogHeight: 0, }); }.bind(this), }, diff --git a/server/perkeepd/ui/search_session.js b/server/perkeepd/ui/search_session.js index 52244a9a2..128b0227f 100644 --- a/server/perkeepd/ui/search_session.js +++ b/server/perkeepd/ui/search_session.js @@ -30,11 +30,12 @@ goog.require('cam.ServerConnection'); // - Initial XHR query can also specify tag. This tag times out if not used rapidly. Send this same tag in socket query. // - Socket assumes that client already has first batch of results (slightly racey though) // - Prefer to use socket on client-side, test whether it works and fall back to XHR if not. -cam.SearchSession = function(connection, currentUri, query, opt_aroundBlobref, opt_sort) { +cam.SearchSession = function(connection, currentUri, query, opt_getDialog, opt_aroundBlobref, opt_sort) { goog.base(this); this.connection_ = connection; this.currentUri_ = currentUri; + this.getDialog_ = opt_getDialog || function(message) { console.log(message) }; this.initSocketUri_(currentUri); this.hasSocketError_ = false; this.query_ = query; @@ -245,7 +246,13 @@ cam.SearchSession.prototype.getContinuation_ = function(changeType, opts) { opts.describe = cam.ServerConnection.DESCRIBE_REQUEST; return this.connection_.search.bind(this.connection_, this.stripMapZoom(this.query_), opts, - this.searchDone_.bind(this, changeType)); + function(result) { + if (result && result.error && result.error != '') { + this.getDialog_(result.error); + return; + } + this.searchDone_(changeType, result); + }.bind(this)); }; cam.SearchSession.prototype.searchDone_ = function(changeType, result) { diff --git a/server/perkeepd/ui/server_connection.js b/server/perkeepd/ui/server_connection.js index 7b0f39a4b..baf50d746 100644 --- a/server/perkeepd/ui/server_connection.js +++ b/server/perkeepd/ui/server_connection.js @@ -153,7 +153,7 @@ cam.ServerConnection.prototype.handleXhrResponseJson_ = function(callbacks, e) { if (error) { if (fail) { - fail(result.error || result); + fail(result); } else { console.log('Failed XHR (JSON) in ServerConnection: ' + result.error || result); } @@ -250,7 +250,7 @@ cam.ServerConnection.prototype.buildQuery = function(callerQuery, opts) { cam.ServerConnection.prototype.search = function(query, opts, callback) { var path = goog.uri.utils.appendPath(this.config_.searchRoot, 'camli/search/query'); this.sendXhr_(path, - goog.bind(this.handleXhrResponseJson_, this, {success: callback}), + goog.bind(this.handleXhrResponseJson_, this, {success: callback, fail: callback}), "POST", JSON.stringify(this.buildQuery(query, opts))); }; diff --git a/vendor/go4.org/wkfs/gcs/gcs.go b/vendor/go4.org/wkfs/gcs/gcs.go index 7d29e568a..59929f34b 100644 --- a/vendor/go4.org/wkfs/gcs/gcs.go +++ b/vendor/go4.org/wkfs/gcs/gcs.go @@ -102,6 +102,9 @@ func (fs *gcsFS) Open(name string) (wkfs.File, error) { obj := fs.sc.Bucket(bucket).Object(fileName) attrs, err := obj.Attrs(fs.ctx) if err != nil { + if err == storage.ErrObjectNotExist { + return nil, os.ErrNotExist + } return nil, err } size := attrs.Size