This commit is contained in:
Brad Fitzpatrick 2014-03-27 19:42:49 -07:00
commit 4b2acfe155
12 changed files with 473 additions and 324 deletions

View File

@ -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,

View File

@ -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",

View File

@ -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)
}

View File

@ -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`,

View File

@ -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{

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -1,3 +1,4 @@
/* TODO(aa): All this needs to get renamed to image-detail */
.detail-view {
background: black;
left: 0;

View File

@ -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;
}
});

View File

@ -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;
}
});

View File

@ -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>