mirror of https://github.com/perkeep/perkeep.git
Merge branch 'master' of https://camlistore.googlesource.com/camlistore
This commit is contained in:
commit
4b2acfe155
|
@ -30,7 +30,7 @@ func TestMongoStorage(t *testing.T) {
|
|||
// SetupMongoContainer may skip or fatal the test if docker isn't found or something goes wrong when setting up the container.
|
||||
// Thus, no error is returned
|
||||
containerID, ip := dockertest.SetupMongoContainer(t)
|
||||
defer dockertest.KillContainer(containerID)
|
||||
defer containerID.Kill()
|
||||
|
||||
sto, err := newMongoStorage(config{
|
||||
server: ip,
|
||||
|
|
|
@ -19,7 +19,6 @@ package mysql_test
|
|||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"testing"
|
||||
|
@ -81,16 +80,8 @@ func newSorted(t *testing.T) (kv sorted.KeyValue, clean func()) {
|
|||
do(rootdb, "DROP DATABASE IF EXISTS "+dbname)
|
||||
do(rootdb, "CREATE DATABASE "+dbname)
|
||||
|
||||
db, err := sql.Open("mymysql", dbname+"/root/root")
|
||||
if err != nil {
|
||||
t.Fatalf("opening test database: " + err.Error())
|
||||
}
|
||||
for _, tableSql := range mysql.SQLCreateTables() {
|
||||
do(db, tableSql)
|
||||
}
|
||||
do(db, fmt.Sprintf(`REPLACE INTO meta VALUES ('version', '%d')`, mysql.SchemaVersion()))
|
||||
|
||||
kv, err = mysql.NewKeyValue(mysql.Config{
|
||||
kv, err := mysql.NewKeyValue(mysql.Config{
|
||||
Host: "localhost:3306",
|
||||
Database: dbname,
|
||||
User: "root",
|
||||
Password: "root",
|
||||
|
|
|
@ -23,21 +23,19 @@ import (
|
|||
"camlistore.org/pkg/test/dockertest"
|
||||
)
|
||||
|
||||
const mongoImage = "robinvdvleuten/mongo"
|
||||
|
||||
// TestMongoKV tests against a real MongoDB instance, using a Docker container.
|
||||
// Currently using https://index.docker.io/u/robinvdvleuten/mongo/
|
||||
func TestMongoKV(t *testing.T) {
|
||||
// SetupMongoContainer may skip or fatal the test if docker isn't found or something goes wrong when setting up the container.
|
||||
// Thus, no error is returned
|
||||
containerID, ip := dockertest.SetupMongoContainer(t)
|
||||
defer dockertest.KillContainer(containerID)
|
||||
defer containerID.Kill()
|
||||
|
||||
kv, err := NewKeyValue(Config{
|
||||
Server: ip,
|
||||
Database: "camlitest",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("monogo.NewKeyValue = %v", err)
|
||||
t.Fatalf("mongo.NewKeyValue = %v", err)
|
||||
}
|
||||
kvtest.TestSorted(t, kv)
|
||||
}
|
||||
|
|
|
@ -28,12 +28,12 @@ func SchemaVersion() int {
|
|||
// which is purely about bytes.
|
||||
func SQLCreateTables() []string {
|
||||
return []string{
|
||||
`CREATE TABLE rows (
|
||||
`CREATE TABLE IF NOT EXISTS rows (
|
||||
k VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
v VARCHAR(255))
|
||||
DEFAULT CHARACTER SET binary`,
|
||||
|
||||
`CREATE TABLE meta (
|
||||
`CREATE TABLE IF NOT EXISTS meta (
|
||||
metakey VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
value VARCHAR(255) NOT NULL)
|
||||
DEFAULT CHARACTER SET binary`,
|
||||
|
|
|
@ -22,6 +22,7 @@ import (
|
|||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"camlistore.org/pkg/jsonconfig"
|
||||
"camlistore.org/pkg/sorted"
|
||||
|
@ -36,7 +37,11 @@ func init() {
|
|||
|
||||
// Config holds the parameters used to connect to the MySQL db.
|
||||
type Config struct {
|
||||
Host string // Optional. Defaults to "localhost" in ConfigFromJSON.
|
||||
// Host optionally specifies the address on which mysqld listens. It should
|
||||
// be of the form hostname:port, or addr:port. If empty, a local connection
|
||||
// will be used. If the address does not have a colon, it is assumed the
|
||||
// port is missing and the default MySQL (3306) one will be set in ConfigFromJSON.
|
||||
Host string
|
||||
Database string // Required.
|
||||
User string // Required.
|
||||
Password string // Optional.
|
||||
|
@ -45,8 +50,14 @@ type Config struct {
|
|||
// ConfigFromJSON populates Config from config, and validates
|
||||
// config. It returns an error if config fails to validate.
|
||||
func ConfigFromJSON(config jsonconfig.Obj) (Config, error) {
|
||||
host := config.OptionalString("host", "")
|
||||
if host != "" {
|
||||
if !strings.Contains(host, ":") {
|
||||
host = host + ":3306"
|
||||
}
|
||||
}
|
||||
conf := Config{
|
||||
Host: config.OptionalString("host", "localhost"),
|
||||
Host: host,
|
||||
User: config.RequiredString("user"),
|
||||
Password: config.OptionalString("password", ""),
|
||||
Database: config.RequiredString("database"),
|
||||
|
@ -67,14 +78,23 @@ func newKeyValueFromJSONConfig(cfg jsonconfig.Obj) (sorted.KeyValue, error) {
|
|||
|
||||
// NewKeyValue returns a sorted.KeyValue implementation of the described MySQL database.
|
||||
func NewKeyValue(cfg Config) (sorted.KeyValue, error) {
|
||||
// TODO(bradfitz,mpl): host is ignored for now. I think we can connect to host with:
|
||||
// tcp:ADDR*DBNAME/USER/PASSWD (http://godoc.org/github.com/ziutek/mymysql/godrv#Driver.Open)
|
||||
// I suppose we'll have to do a lookup first.
|
||||
dsn := cfg.Database + "/" + cfg.User + "/" + cfg.Password
|
||||
if cfg.Host != "" {
|
||||
dsn = "tcp:" + cfg.Host + "*" + dsn
|
||||
}
|
||||
db, err := sql.Open("mymysql", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tableSql := range SQLCreateTables() {
|
||||
if _, err := db.Exec(tableSql); err != nil {
|
||||
return nil, fmt.Errorf("error creating table with %q: %v", tableSql, err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(fmt.Sprintf(`REPLACE INTO meta VALUES ('version', '%d')`, SchemaVersion())); err != nil {
|
||||
return nil, fmt.Errorf("error setting schema version: %v", err)
|
||||
}
|
||||
|
||||
kv := &keyValue{
|
||||
db: db,
|
||||
KeyValue: &sqlkv.KeyValue{
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
Copyright 2014 The Camlistore Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"camlistore.org/pkg/osutil"
|
||||
"camlistore.org/pkg/sorted/kvtest"
|
||||
"camlistore.org/pkg/test/dockertest"
|
||||
)
|
||||
|
||||
// TestMySQLKV tests against a real MySQL instance, using a Docker container.
|
||||
func TestMySQLKV(t *testing.T) {
|
||||
dbname := "camlitest_" + osutil.Username()
|
||||
containerID, ip := dockertest.SetupMySQLContainer(t, dbname)
|
||||
defer containerID.Kill()
|
||||
|
||||
kv, err := NewKeyValue(Config{
|
||||
Host: ip + ":3306",
|
||||
Database: dbname,
|
||||
User: "root",
|
||||
Password: "root",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mysql.NewKeyValue = %v", err)
|
||||
}
|
||||
kvtest.TestSorted(t, kv)
|
||||
}
|
|
@ -24,11 +24,42 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"camlistore.org/pkg/netutil"
|
||||
)
|
||||
|
||||
func HaveImage(name string) (ok bool, err error) {
|
||||
/// runLongTest checks all the conditions for running a docker container
|
||||
// based on image.
|
||||
func runLongTest(t *testing.T, image string) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping in short mode")
|
||||
}
|
||||
if !haveDocker() {
|
||||
t.Skip("skipping test; 'docker' command not found")
|
||||
}
|
||||
if ok, err := haveImage(image); !ok || err != nil {
|
||||
if err != nil {
|
||||
t.Skipf("Error running docker to check for %s: %v", image, err)
|
||||
}
|
||||
log.Printf("Pulling docker image %s ...", image)
|
||||
if err := Pull(image); err != nil {
|
||||
t.Skipf("Error pulling %s: %v", image, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// haveDocker returns whether the "docker" command was found.
|
||||
func haveDocker() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func haveImage(name string) (ok bool, err error) {
|
||||
out, err := exec.Command("docker", "images", "--no-trunc").Output()
|
||||
if err != nil {
|
||||
return
|
||||
|
@ -36,12 +67,15 @@ func HaveImage(name string) (ok bool, err error) {
|
|||
return bytes.Contains(out, []byte(name)), nil
|
||||
}
|
||||
|
||||
func Run(args ...string) (containerID string, err error) {
|
||||
runOut, err := exec.Command("docker", append([]string{"run"}, args...)...).Output()
|
||||
if err != nil {
|
||||
func run(args ...string) (containerID string, err error) {
|
||||
cmd := exec.Command("docker", append([]string{"run"}, args...)...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err = cmd.Run(); err != nil {
|
||||
err = fmt.Errorf("%v%v", stderr.String(), err)
|
||||
return
|
||||
}
|
||||
containerID = strings.TrimSpace(string(runOut))
|
||||
containerID = strings.TrimSpace(stdout.String())
|
||||
if containerID == "" {
|
||||
return "", errors.New("unexpected empty output from `docker run`")
|
||||
}
|
||||
|
@ -52,14 +86,16 @@ func KillContainer(container string) error {
|
|||
return exec.Command("docker", "kill", container).Run()
|
||||
}
|
||||
|
||||
func Pull(name string) error {
|
||||
out, err := exec.Command("docker", "pull", name).CombinedOutput()
|
||||
// Pull retrieves the docker image with 'docker pull'.
|
||||
func Pull(image string) error {
|
||||
out, err := exec.Command("docker", "pull", image).CombinedOutput()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("%v: %s", err, out)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// IP returns the IP address of the container.
|
||||
func IP(containerID string) (string, error) {
|
||||
out, err := exec.Command("docker", "inspect", containerID).Output()
|
||||
if err != nil {
|
||||
|
@ -81,5 +117,75 @@ func IP(containerID string) (string, error) {
|
|||
if ip := c[0].NetworkSettings.IPAddress; ip != "" {
|
||||
return ip, nil
|
||||
}
|
||||
return "", errors.New("no IP. Not running?")
|
||||
return "", fmt.Errorf("could not find an IP for %v. Not running?", containerID)
|
||||
}
|
||||
|
||||
type ContainerID string
|
||||
|
||||
func (c ContainerID) IP() (string, error) {
|
||||
return IP(string(c))
|
||||
}
|
||||
|
||||
func (c ContainerID) Kill() error {
|
||||
return KillContainer(string(c))
|
||||
}
|
||||
|
||||
// lookup retrieves the ip address of the container, and tries to reach
|
||||
// before timeout the tcp address at this ip and given port.
|
||||
func (c ContainerID) lookup(port int, timeout time.Duration) (ip string, err error) {
|
||||
ip, err = c.IP()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Error getting container IP: %v", err)
|
||||
return
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", ip, port)
|
||||
if err = netutil.AwaitReachable(addr, timeout); err != nil {
|
||||
err = fmt.Errorf("timeout trying to reach %s for container %v: %v", addr, c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
mongoImage = "robinvdvleuten/mongo"
|
||||
mysqlImage = "orchardup/mysql"
|
||||
)
|
||||
|
||||
// SetupMongoContainer sets up a real MongoDB instance for testing purposes,
|
||||
// using a Docker container. It returns the container ID and its IP address,
|
||||
// or makes the test fail on error.
|
||||
// Currently using https://index.docker.io/u/robinvdvleuten/mongo/
|
||||
func SetupMongoContainer(t *testing.T) (c ContainerID, ip string) {
|
||||
runLongTest(t, mongoImage)
|
||||
|
||||
containerID, err := run("-d", mongoImage, "--smallfiles")
|
||||
if err != nil {
|
||||
t.Fatalf("docker run: %v", err)
|
||||
}
|
||||
c = ContainerID(containerID)
|
||||
ip, err = c.lookup(27017, 20*time.Second)
|
||||
if err != nil {
|
||||
c.Kill()
|
||||
t.Fatalf("container lookup: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SetupMySQLContainer sets up a real MySQL instance for testing purposes,
|
||||
// using a Docker container. It returns the container ID and its IP address,
|
||||
// or makes the test fail on error.
|
||||
// Currently using https://index.docker.io/u/orchardup/mysql/
|
||||
func SetupMySQLContainer(t *testing.T, dbname string) (c ContainerID, ip string) {
|
||||
runLongTest(t, mongoImage)
|
||||
|
||||
containerID, err := run("-d", "-e", "MYSQL_ROOT_PASSWORD=root", "-e", "MYSQL_DATABASE="+dbname, mysqlImage)
|
||||
if err != nil {
|
||||
t.Fatalf("docker run: %v", err)
|
||||
}
|
||||
c = ContainerID(containerID)
|
||||
ip, err = c.lookup(3306, 10*time.Second)
|
||||
if err != nil {
|
||||
c.Kill()
|
||||
t.Fatalf("container lookup: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
/*
|
||||
Copyright 2014 The Camlistore Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package dockertest
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"camlistore.org/pkg/netutil"
|
||||
)
|
||||
|
||||
const mongoImage = "robinvdvleuten/mongo"
|
||||
|
||||
// SetupMongoContainer sets up a real MongoDB instance for testing purposes, using a Docker container.
|
||||
// Currently using https://index.docker.io/u/robinvdvleuten/mongo/
|
||||
func SetupMongoContainer(t *testing.T) (containerID, ip string) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping in short mode")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("skipping without docker available in path")
|
||||
}
|
||||
if ok, err := HaveImage(mongoImage); !ok || err != nil {
|
||||
if err != nil {
|
||||
t.Skipf("Error running docker to check for %s: %v", mongoImage, err)
|
||||
}
|
||||
log.Printf("Pulling docker image %s ...", mongoImage)
|
||||
if err := Pull(mongoImage); err != nil {
|
||||
t.Skipf("Error pulling %s: %v", mongoImage, err)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
containerID, err = Run("-d", mongoImage, "--smallfiles")
|
||||
if err != nil {
|
||||
t.Fatalf("docker run: %v", err)
|
||||
}
|
||||
|
||||
ip, err = IP(containerID)
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting container IP: %v", err)
|
||||
}
|
||||
|
||||
if err := netutil.AwaitReachable(ip+":27017", 20*time.Second); err != nil {
|
||||
t.Fatal("timeout waiting for port to become reachable")
|
||||
}
|
||||
return
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
/* TODO(aa): All this needs to get renamed to image-detail */
|
||||
.detail-view {
|
||||
background: black;
|
||||
left: 0;
|
||||
|
|
|
@ -24,6 +24,7 @@ goog.require('goog.string');
|
|||
|
||||
goog.require('cam.AnimationLoop');
|
||||
goog.require('cam.BlobItemReactData');
|
||||
goog.require('cam.ImageDetail');
|
||||
goog.require('cam.imageUtil');
|
||||
goog.require('cam.Navigator');
|
||||
goog.require('cam.reactUtil');
|
||||
|
@ -34,10 +35,6 @@ goog.require('cam.SpritedAnimation');
|
|||
cam.DetailView = React.createClass({
|
||||
displayName: 'DetailView',
|
||||
|
||||
IMG_MARGIN: 20,
|
||||
PIGGY_WIDTH: 88,
|
||||
PIGGY_HEIGHT: 62,
|
||||
|
||||
propTypes: {
|
||||
blobref: React.PropTypes.string.isRequired,
|
||||
getDetailURL: React.PropTypes.func.isRequired,
|
||||
|
@ -52,25 +49,15 @@ cam.DetailView = React.createClass({
|
|||
},
|
||||
|
||||
getInitialState: function() {
|
||||
this.imgSize_ = null;
|
||||
this.lastImageHeight_ = 0;
|
||||
this.pendingNavigation_ = 0;
|
||||
this.navCount_ = 1;
|
||||
this.eh_ = new goog.events.EventHandler(this);
|
||||
|
||||
return {
|
||||
imgHasLoaded: false,
|
||||
backwardPiggy: false,
|
||||
lastNavigateWasBackward: false,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(nextProps) {
|
||||
if (this.props.blobref != nextProps.blobref) {
|
||||
this.blobItemData_ = null;
|
||||
this.imgSize_ = null;
|
||||
this.lastImageHeight_ = 0;
|
||||
this.setState({imgHasLoaded: false});
|
||||
}
|
||||
componentWillMount: function() {
|
||||
this.pendingNavigation_ = 0;
|
||||
this.navCount_ = 1;
|
||||
this.eh_ = new goog.events.EventHandler(this);
|
||||
},
|
||||
|
||||
componentDidMount: function(root) {
|
||||
|
@ -79,103 +66,21 @@ cam.DetailView = React.createClass({
|
|||
this.searchUpdated_();
|
||||
},
|
||||
|
||||
componentDidUpdate: function(prevProps, prevState) {
|
||||
var img = this.getImageRef_();
|
||||
if (img) {
|
||||
// This function gets called multiple times, but the DOM de-dupes listeners for us. Thanks DOM.
|
||||
img.getDOMNode().addEventListener('load', this.onImgLoad_);
|
||||
img.getDOMNode().addEventListener('error', function() {
|
||||
console.error('Could not load image: %s', img.props.src);
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.blobItemData_ = this.getBlobItemData_();
|
||||
this.imgSize_ = this.getImgSize_();
|
||||
return React.DOM.div({className:'detail-view', style: this.getStyle_()}, [
|
||||
this.getImg_(),
|
||||
this.getPiggy_(),
|
||||
this.getSidebar_(),
|
||||
]);
|
||||
},
|
||||
|
||||
getSidebar_: function() {
|
||||
var children = !this.blobItemData_ ? [] : [
|
||||
this.getGeneralProperties_(),
|
||||
this.getFileishProperties_(),
|
||||
this.getImageProperties_(),
|
||||
this.getNavProperties_(),
|
||||
];
|
||||
return cam.PropertySheetContainer({className:'detail-view-sidebar', style:this.getSidebarStyle_()}, children);
|
||||
},
|
||||
|
||||
getGeneralProperties_: function() {
|
||||
if (this.blobItemData_.m.camliType != 'permanode') {
|
||||
return null;
|
||||
}
|
||||
return cam.PropertySheet({key:'general', title:'Generalities'}, [
|
||||
React.DOM.h1({className:'detail-title'}, this.getSinglePermanodeAttr_('title') || '<no title>'),
|
||||
React.DOM.p({className:'detail-description'}, this.getSinglePermanodeAttr_('description') || '<no description>'),
|
||||
]);
|
||||
},
|
||||
|
||||
getFileishProperties_: function() {
|
||||
var isFile = this.blobItemData_.rm.camliType == 'file';
|
||||
var isDir = this.blobItemData_.rm.camliType == 'directory';
|
||||
if (!isFile && !isDir) {
|
||||
return null;
|
||||
}
|
||||
return cam.PropertySheet({className:'detail-fileish-properties', key:'file', title: isFile ? 'File' : 'Directory'}, [
|
||||
React.DOM.table({}, [
|
||||
React.DOM.tr({}, [
|
||||
React.DOM.td({}, 'filename'),
|
||||
React.DOM.td({}, isFile ? this.blobItemData_.rm.file.fileName : this.blobItemData_.rm.dir.fileName),
|
||||
]),
|
||||
React.DOM.tr({}, [
|
||||
React.DOM.td({}, 'size'),
|
||||
React.DOM.td({}, this.blobItemData_.rm.file.size + ' bytes'), // TODO(aa): Humanize units
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
|
||||
getImageProperties_: function() {
|
||||
if (!this.blobItemData_.im) {
|
||||
return null;
|
||||
if (!this.dataIsLoaded_()) {
|
||||
return React.DOM.div();
|
||||
}
|
||||
|
||||
return cam.PropertySheet({className:'detail-image-properties', key:'image', title: 'Image'}, [
|
||||
React.DOM.table({}, [
|
||||
React.DOM.tr({}, [
|
||||
React.DOM.td({}, 'width'),
|
||||
React.DOM.td({}, this.blobItemData_.im.width),
|
||||
]),
|
||||
React.DOM.tr({}, [
|
||||
React.DOM.td({}, 'height'),
|
||||
React.DOM.td({}, this.blobItemData_.im.height),
|
||||
]),
|
||||
// TODO(aa): encoding type, exif data, etc.
|
||||
]),
|
||||
]);
|
||||
},
|
||||
|
||||
getNavProperties_: function() {
|
||||
return cam.PropertySheet({key:'nav', title:'Elsewhere'}, [
|
||||
React.DOM.a({key:'search-link', href:this.props.searchURL.toString(), onClick:this.handleEscape_}, 'Back to search'),
|
||||
React.DOM.br(),
|
||||
React.DOM.a({key:'old-link', href:this.props.oldURL.toString()}, 'Old (editable) UI'),
|
||||
]);
|
||||
},
|
||||
|
||||
// TODO(aa): We need a Permanode utility class that wraps the JSON goop.
|
||||
getSinglePermanodeAttr_: function(name) {
|
||||
var m = this.blobItemData_.m;
|
||||
if (m.camliType == 'permanode' && m.permanode.attr[name]) {
|
||||
return goog.isArray(m.permanode.attr[name]) ? m.permanode.attr[name][0] : m.permanode.attr[name];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
// TODO(aa): Different types of detail views can go here based on what's in blobItemData.
|
||||
return cam.ImageDetail({
|
||||
backwardPiggy: this.state.lastNavigateWasBackward,
|
||||
blobItemData: new cam.BlobItemReactData(this.props.blobref, this.props.searchSession.getCurrentResults().description.meta),
|
||||
height: this.props.height,
|
||||
oldURL: this.props.oldURL,
|
||||
onEscape: this.handleEscape_,
|
||||
searchURL: this.props.searchURL,
|
||||
width: this.props.width,
|
||||
});
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
|
@ -195,7 +100,7 @@ cam.DetailView = React.createClass({
|
|||
navigate_: function(offset) {
|
||||
this.pendingNavigation_ = offset;
|
||||
++this.navCount_;
|
||||
this.setState({backwardPiggy: offset < 0});
|
||||
this.setState({lastNavigateWasBackward: offset < 0});
|
||||
this.handlePendingNavigation_();
|
||||
},
|
||||
|
||||
|
@ -240,15 +145,10 @@ cam.DetailView = React.createClass({
|
|||
this.props.navigator.navigate(this.props.getDetailURL(results.blobs[index].blob));
|
||||
},
|
||||
|
||||
onImgLoad_: function() {
|
||||
this.setState({imgHasLoaded:true});
|
||||
},
|
||||
|
||||
searchUpdated_: function() {
|
||||
this.handlePendingNavigation_();
|
||||
|
||||
this.blobItemData_ = this.getBlobItemData_();
|
||||
if (this.blobItemData_) {
|
||||
if (this.dataIsLoaded_()) {
|
||||
this.forceUpdate();
|
||||
return;
|
||||
}
|
||||
|
@ -265,113 +165,7 @@ cam.DetailView = React.createClass({
|
|||
this.props.searchSession.loadMoreResults();
|
||||
},
|
||||
|
||||
getImg_: function() {
|
||||
var transition = React.addons.TransitionGroup({transitionName: 'detail-img'}, []);
|
||||
if (this.imgSize_) {
|
||||
transition.props.children.push(
|
||||
React.DOM.img({
|
||||
className: React.addons.classSet({
|
||||
'detail-view-img': true,
|
||||
'detail-view-img-loaded': this.state.imgHasLoaded
|
||||
}),
|
||||
// We want each image to have its own node in the DOM so that during the crossfade, we don't see the image jump to the next image's size.
|
||||
key: this.getImageId_(),
|
||||
ref: this.getImageId_(),
|
||||
src: this.getSrc_(),
|
||||
style: this.getCenteredProps_(this.imgSize_.width, this.imgSize_.height)
|
||||
})
|
||||
);
|
||||
}
|
||||
return transition;
|
||||
dataIsLoaded_: function() {
|
||||
return Boolean(this.props.searchSession.getCurrentResults().description.meta[this.props.blobref]);
|
||||
},
|
||||
|
||||
getPiggy_: function() {
|
||||
var transition = React.addons.TransitionGroup({transitionName: 'detail-piggy'}, []);
|
||||
if (!this.state.imgHasLoaded) {
|
||||
transition.props.children.push(
|
||||
cam.SpritedAnimation({
|
||||
src: 'glitch/npc_piggy__x1_walk_png_1354829432.png',
|
||||
className: React.addons.classSet({
|
||||
'detail-view-piggy': true,
|
||||
'detail-view-piggy-backward': this.state.backwardPiggy
|
||||
}),
|
||||
spriteWidth: this.PIGGY_WIDTH,
|
||||
spriteHeight: this.PIGGY_HEIGHT,
|
||||
sheetWidth: 8,
|
||||
sheetHeight: 3,
|
||||
interval: 30,
|
||||
style: this.getCenteredProps_(this.PIGGY_WIDTH, this.PIGGY_HEIGHT)
|
||||
}));
|
||||
}
|
||||
return transition;
|
||||
},
|
||||
|
||||
getCenteredProps_: function(w, h) {
|
||||
var avail = new goog.math.Size(this.props.width - this.getSidebarWidth_(), this.props.height);
|
||||
return {
|
||||
top: (avail.height - h) / 2,
|
||||
left: (avail.width - w) / 2,
|
||||
width: w,
|
||||
height: h
|
||||
}
|
||||
},
|
||||
|
||||
getSrc_: function() {
|
||||
this.lastImageHeight_ = Math.min(this.blobItemData_.im.height, cam.imageUtil.getSizeToRequest(this.imgSize_.height, this.lastImageHeight_));
|
||||
return this.blobItemData_.getThumbSrc(this.lastImageHeight_);
|
||||
},
|
||||
|
||||
getImgSize_: function() {
|
||||
if (!this.blobItemData_ || !this.blobItemData_.im) {
|
||||
return null;
|
||||
}
|
||||
var rawSize = new goog.math.Size(this.blobItemData_.im.width, this.blobItemData_.im.height);
|
||||
var available = new goog.math.Size(
|
||||
this.props.width - this.getSidebarWidth_() - this.IMG_MARGIN * 2,
|
||||
this.props.height - this.IMG_MARGIN * 2);
|
||||
if (rawSize.height <= available.height && rawSize.width <= available.width) {
|
||||
return rawSize;
|
||||
}
|
||||
return rawSize.scaleToFit(available);
|
||||
},
|
||||
|
||||
getStyle_: function() {
|
||||
return {
|
||||
width: this.props.width,
|
||||
height: this.props.height
|
||||
}
|
||||
},
|
||||
|
||||
getSidebarStyle_: function() {
|
||||
return {
|
||||
width: this.getSidebarWidth_()
|
||||
}
|
||||
},
|
||||
|
||||
getSidebarWidth_: function() {
|
||||
return Math.max(this.props.width * 0.2, 300);
|
||||
},
|
||||
|
||||
getPermanodeMeta_: function() {
|
||||
if (!this.blobItemData_) {
|
||||
return null;
|
||||
}
|
||||
return this.blobItemData_.m;
|
||||
},
|
||||
|
||||
getBlobItemData_: function() {
|
||||
var metabag = this.props.searchSession.getCurrentResults().description.meta;
|
||||
if (!metabag[this.props.blobref]) {
|
||||
return null;
|
||||
}
|
||||
return new cam.BlobItemReactData(this.props.blobref, metabag);
|
||||
},
|
||||
|
||||
getImageRef_: function() {
|
||||
return this.refs && this.refs[this.getImageId_()];
|
||||
},
|
||||
|
||||
getImageId_: function() {
|
||||
return 'img' + this.props.blobref;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,259 @@
|
|||
/*
|
||||
Copyright 2014 The Camlistore Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
goog.provide('cam.ImageDetail');
|
||||
|
||||
cam.ImageDetail = React.createClass({
|
||||
displayName: 'ImageDetail',
|
||||
|
||||
IMG_MARGIN: 20,
|
||||
PIGGY_WIDTH: 88,
|
||||
PIGGY_HEIGHT: 62,
|
||||
|
||||
propTypes: {
|
||||
backwardPiggy: false,
|
||||
blobItemData: React.PropTypes.instanceOf(cam.BlobItemReactData).isRequired,
|
||||
height: React.PropTypes.number.isRequired,
|
||||
oldURL: React.PropTypes.instanceOf(goog.Uri).isRequired,
|
||||
onEscape: React.PropTypes.func.isRequired,
|
||||
searchURL: React.PropTypes.instanceOf(goog.Uri).isRequired,
|
||||
width: React.PropTypes.number.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
this.imgSize_ = null;
|
||||
this.lastImageHeight_ = 0;
|
||||
|
||||
return {
|
||||
imgHasLoaded: false,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(nextProps) {
|
||||
if (this.props.blobItemData.blobref != nextProps.blobItemData.blobref) {
|
||||
this.imgSize_ = null;
|
||||
this.lastImageHeight_ = 0;
|
||||
this.setState({imgHasLoaded: false});
|
||||
}
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.componentDidUpdate();
|
||||
},
|
||||
|
||||
componentDidUpdate: function() {
|
||||
var img = this.getImageRef_();
|
||||
if (img) {
|
||||
// This function gets called multiple times, but the DOM de-dupes listeners for us. Thanks DOM.
|
||||
img.getDOMNode().addEventListener('load', this.onImgLoad_);
|
||||
img.getDOMNode().addEventListener('error', function() {
|
||||
console.error('Could not load image: %s', img.props.src);
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.imgSize_ = this.getImgSize_();
|
||||
return React.DOM.div({className:'detail-view', style: this.getStyle_()}, [
|
||||
this.getImg_(),
|
||||
this.getPiggy_(),
|
||||
this.getSidebar_(),
|
||||
]);
|
||||
},
|
||||
|
||||
getSidebar_: function() {
|
||||
return cam.PropertySheetContainer({className:'detail-view-sidebar', style:this.getSidebarStyle_()}, [
|
||||
this.getGeneralProperties_(),
|
||||
this.getFileishProperties_(),
|
||||
this.getImageProperties_(),
|
||||
this.getNavProperties_(),
|
||||
]);
|
||||
},
|
||||
|
||||
getGeneralProperties_: function() {
|
||||
if (this.props.blobItemData.m.camliType != 'permanode') {
|
||||
return null;
|
||||
}
|
||||
return cam.PropertySheet({key:'general', title:'Generalities'}, [
|
||||
React.DOM.h1({className:'detail-title'}, this.getSinglePermanodeAttr_('title') || '<no title>'),
|
||||
React.DOM.p({className:'detail-description'}, this.getSinglePermanodeAttr_('description') || '<no description>'),
|
||||
]);
|
||||
},
|
||||
|
||||
getFileishProperties_: function() {
|
||||
var isFile = this.props.blobItemData.rm.camliType == 'file';
|
||||
var isDir = this.props.blobItemData.rm.camliType == 'directory';
|
||||
if (!isFile && !isDir) {
|
||||
return null;
|
||||
}
|
||||
return cam.PropertySheet({className:'detail-fileish-properties', key:'file', title: isFile ? 'File' : 'Directory'}, [
|
||||
React.DOM.table({}, [
|
||||
React.DOM.tr({}, [
|
||||
React.DOM.td({}, 'filename'),
|
||||
React.DOM.td({}, isFile ? this.props.blobItemData.rm.file.fileName : this.props.blobItemData.rm.dir.fileName),
|
||||
]),
|
||||
React.DOM.tr({}, [
|
||||
React.DOM.td({}, 'size'),
|
||||
React.DOM.td({}, this.props.blobItemData.rm.file.size + ' bytes'), // TODO(aa): Humanize units
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
|
||||
getImageProperties_: function() {
|
||||
if (!this.props.blobItemData.im) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cam.PropertySheet({className:'detail-image-properties', key:'image', title: 'Image'}, [
|
||||
React.DOM.table({}, [
|
||||
React.DOM.tr({}, [
|
||||
React.DOM.td({}, 'width'),
|
||||
React.DOM.td({}, this.props.blobItemData.im.width),
|
||||
]),
|
||||
React.DOM.tr({}, [
|
||||
React.DOM.td({}, 'height'),
|
||||
React.DOM.td({}, this.props.blobItemData.im.height),
|
||||
]),
|
||||
// TODO(aa): encoding type, exif data, etc.
|
||||
]),
|
||||
]);
|
||||
},
|
||||
|
||||
getNavProperties_: function() {
|
||||
return cam.PropertySheet({key:'nav', title:'Elsewhere'}, [
|
||||
React.DOM.a({key:'search-link', href:this.props.searchURL.toString(), onClick:this.props.onEscape}, 'Back to search'),
|
||||
React.DOM.br(),
|
||||
React.DOM.a({key:'old-link', href:this.props.oldURL.toString()}, 'Old (editable) UI'),
|
||||
]);
|
||||
},
|
||||
|
||||
// TODO(aa): We need a Permanode utility class that wraps the JSON goop.
|
||||
getSinglePermanodeAttr_: function(name) {
|
||||
var m = this.props.blobItemData.m;
|
||||
if (m.camliType == 'permanode' && m.permanode.attr[name]) {
|
||||
return goog.isArray(m.permanode.attr[name]) ? m.permanode.attr[name][0] : m.permanode.attr[name];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
onImgLoad_: function() {
|
||||
this.setState({imgHasLoaded:true});
|
||||
},
|
||||
|
||||
getImg_: function() {
|
||||
var transition = React.addons.TransitionGroup({transitionName: 'detail-img'}, []);
|
||||
if (this.imgSize_) {
|
||||
transition.props.children.push(
|
||||
React.DOM.img({
|
||||
className: React.addons.classSet({
|
||||
'detail-view-img': true,
|
||||
'detail-view-img-loaded': this.state.imgHasLoaded
|
||||
}),
|
||||
// We want each image to have its own node in the DOM so that during the crossfade, we don't see the image jump to the next image's size.
|
||||
key: this.getImageId_(),
|
||||
ref: this.getImageId_(),
|
||||
src: this.getSrc_(),
|
||||
style: this.getCenteredProps_(this.imgSize_.width, this.imgSize_.height)
|
||||
})
|
||||
);
|
||||
}
|
||||
return transition;
|
||||
},
|
||||
|
||||
getPiggy_: function() {
|
||||
var transition = React.addons.TransitionGroup({transitionName: 'detail-piggy'}, []);
|
||||
if (!this.state.imgHasLoaded) {
|
||||
transition.props.children.push(
|
||||
cam.SpritedAnimation({
|
||||
src: 'glitch/npc_piggy__x1_walk_png_1354829432.png',
|
||||
className: React.addons.classSet({
|
||||
'detail-view-piggy': true,
|
||||
'detail-view-piggy-backward': this.props.backwardPiggy
|
||||
}),
|
||||
spriteWidth: this.PIGGY_WIDTH,
|
||||
spriteHeight: this.PIGGY_HEIGHT,
|
||||
sheetWidth: 8,
|
||||
sheetHeight: 3,
|
||||
interval: 30,
|
||||
style: this.getCenteredProps_(this.PIGGY_WIDTH, this.PIGGY_HEIGHT)
|
||||
}));
|
||||
}
|
||||
return transition;
|
||||
},
|
||||
|
||||
getCenteredProps_: function(w, h) {
|
||||
var avail = new goog.math.Size(this.props.width - this.getSidebarWidth_(), this.props.height);
|
||||
return {
|
||||
top: (avail.height - h) / 2,
|
||||
left: (avail.width - w) / 2,
|
||||
width: w,
|
||||
height: h
|
||||
}
|
||||
},
|
||||
|
||||
getSrc_: function() {
|
||||
this.lastImageHeight_ = Math.min(this.props.blobItemData.im.height, cam.imageUtil.getSizeToRequest(this.imgSize_.height, this.lastImageHeight_));
|
||||
return this.props.blobItemData.getThumbSrc(this.lastImageHeight_);
|
||||
},
|
||||
|
||||
getImgSize_: function() {
|
||||
if (!this.props.blobItemData || !this.props.blobItemData.im) {
|
||||
return null;
|
||||
}
|
||||
var rawSize = new goog.math.Size(this.props.blobItemData.im.width, this.props.blobItemData.im.height);
|
||||
var available = new goog.math.Size(
|
||||
this.props.width - this.getSidebarWidth_() - this.IMG_MARGIN * 2,
|
||||
this.props.height - this.IMG_MARGIN * 2);
|
||||
if (rawSize.height <= available.height && rawSize.width <= available.width) {
|
||||
return rawSize;
|
||||
}
|
||||
return rawSize.scaleToFit(available);
|
||||
},
|
||||
|
||||
getStyle_: function() {
|
||||
return {
|
||||
width: this.props.width,
|
||||
height: this.props.height
|
||||
}
|
||||
},
|
||||
|
||||
getSidebarStyle_: function() {
|
||||
return {
|
||||
width: this.getSidebarWidth_()
|
||||
}
|
||||
},
|
||||
|
||||
getSidebarWidth_: function() {
|
||||
return Math.max(this.props.width * 0.2, 300);
|
||||
},
|
||||
|
||||
getPermanodeMeta_: function() {
|
||||
if (!this.props.blobItemData) {
|
||||
return null;
|
||||
}
|
||||
return this.props.blobItemData.m;
|
||||
},
|
||||
|
||||
getImageRef_: function() {
|
||||
return this.refs && this.refs[this.getImageId_()];
|
||||
},
|
||||
|
||||
getImageId_: function() {
|
||||
return 'img' + this.props.blobItemData.blobref;
|
||||
}
|
||||
});
|
|
@ -51,4 +51,5 @@ Or browse at Github: <a href="https://github.com/bradfitz/camlistore/tree/0.8">g
|
|||
</ul>
|
||||
<h3>General</h3>
|
||||
<ul>
|
||||
<li>kv: all the verifydb flags are on by default on dev, to help with detecting corruptions.</li>
|
||||
</ul>
|
||||
|
|
Loading…
Reference in New Issue