diff options
110 files changed, 9061 insertions, 641 deletions
diff --git a/.gitignore b/.gitignore index 79761adac..ebd5e4342 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ dist npm-debug.log bundle*.js - +model/version.go +model/version.go.bak # Compiled Object files, Static and Dynamic libs (Shared Objects) *.o diff --git a/.travis.yml b/.travis.yml index 877977dd4..02e1234d3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,10 @@ language: go go: - 1.4.2 +- 1.5.1 +env: +- TRAVIS_DB=mysql +- TRAVIS_DB=postgres before_install: - gem install compass - sudo apt-get update -qq @@ -24,6 +28,9 @@ before_script: - mysql -e "CREATE DATABASE IF NOT EXISTS mattermost_test ;" -uroot - mysql -e "CREATE USER 'mmuser'@'%' IDENTIFIED BY 'mostest' ;" -uroot - mysql -e "GRANT ALL ON mattermost_test.* TO 'mmuser'@'%' ;" -uroot +- psql -c "create database mattermost_test ;" -U postgres +- psql -c "create user mmuser with password 'mostest' ;" -U postgres +- psql -c 'grant all privileges on database "mattermost_test" to mmuser ;' -U postgres services: - redis-server addons: @@ -38,6 +45,8 @@ deploy: on: repo: mattermost/platform tags: true + go: 1.4.2 + condition: $TRAVIS_DB = mysql - provider: s3 access_key_id: AKIAJCO3KJYEGWJIKDIQ @@ -52,3 +61,5 @@ deploy: on: repo: mattermost/platform branch: master + go: 1.4.2 + condition: $TRAVIS_DB = mysql diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aac49ff2..18238d9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,18 @@ The "UNDER DEVELOPMENT" section of the Mattermost changelog appears in the produ ### New Features -- [See Product Roadmap for anticipated features](https://mattermost.atlassian.net/issues/?filter=10002) +Messaging, Comments and Notifications + +- (Preview) Added support for emoji codes rendering to image files + +Admin Console + +- (Preview) Ability to view server logs and change config settings + +Integrations + +- (Preview) Added API for incoming webhooks +- (Preview) Added OAuth2 as a service provider to allow for more secure connection to external apps ### Improvements @@ -32,6 +43,10 @@ Performance Code Quality - Reformatted Javascript per Mattermost Style Guide + +UI + +- Added version, build number, build date and build hash under Account Settings -> Security (to be moved to "About" dialog later) ### Bug Fixes @@ -42,7 +57,11 @@ Code Quality Many thanks to our external contributors. In no particular order: -- TBA +- [Trozz](https://github.com/Trozz) +- [LAndres](https://github.com/LAndreas) +- [JessBot](https://github.com/JessBot) +- [apaatsio](https://github.com/apaatsio) +- [chengweiv5](https://github.com/chengweiv5) ## Release v0.7.0 (Beta1) diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index 7a037e6ab..d6503a1d5 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -13,6 +13,11 @@ "Rev": "69e2a90ed92d03812364aeb947b7068dc42e561e" }, { + "ImportPath": "code.google.com/p/graphics-go/graphics", + "Comment": "null-25", + "Rev": "f843bfcd8ac420072c7f6804599995b0a229070b" + }, + { "ImportPath": "code.google.com/p/log4go", "Comment": "go.weekly.2012-02-22-1", "Rev": "c3294304d93f48a37d3bed1d382882a9c2989f99" @@ -88,6 +93,14 @@ "Rev": "dc93e1b98c579d90ee2fa15c1fd6dac34f6e7899" }, { + "ImportPath": "github.com/rwcarlsen/goexif/exif", + "Rev": "709fab3d192d7c62f86043caff1e7e3fb0f42bd8" + }, + { + "ImportPath": "github.com/rwcarlsen/goexif/tiff", + "Rev": "709fab3d192d7c62f86043caff1e7e3fb0f42bd8" + }, + { "ImportPath": "github.com/stretchr/objx", "Rev": "cbeaeb16a013161a98496fad62933b1d21786672" }, diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/Makefile b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/Makefile new file mode 100644 index 000000000..28a06f0e8 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/Makefile @@ -0,0 +1,15 @@ +# Copyright 2011 The Graphics-Go Authors. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +include $(GOROOT)/src/Make.inc + +TARG=code.google.com/p/graphics-go/graphics +GOFILES=\ + affine.go\ + blur.go\ + rotate.go\ + scale.go\ + thumbnail.go\ + +include $(GOROOT)/src/Make.pkg diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/affine.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/affine.go new file mode 100644 index 000000000..0ac2ec9da --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/affine.go @@ -0,0 +1,174 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "code.google.com/p/graphics-go/graphics/interp" + "errors" + "image" + "image/draw" + "math" +) + +// I is the identity Affine transform matrix. +var I = Affine{ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1, +} + +// Affine is a 3x3 2D affine transform matrix. +// M(i,j) is Affine[i*3+j]. +type Affine [9]float64 + +// Mul returns the multiplication of two affine transform matrices. +func (a Affine) Mul(b Affine) Affine { + return Affine{ + a[0]*b[0] + a[1]*b[3] + a[2]*b[6], + a[0]*b[1] + a[1]*b[4] + a[2]*b[7], + a[0]*b[2] + a[1]*b[5] + a[2]*b[8], + a[3]*b[0] + a[4]*b[3] + a[5]*b[6], + a[3]*b[1] + a[4]*b[4] + a[5]*b[7], + a[3]*b[2] + a[4]*b[5] + a[5]*b[8], + a[6]*b[0] + a[7]*b[3] + a[8]*b[6], + a[6]*b[1] + a[7]*b[4] + a[8]*b[7], + a[6]*b[2] + a[7]*b[5] + a[8]*b[8], + } +} + +func (a Affine) transformRGBA(dst *image.RGBA, src *image.RGBA, i interp.RGBA) error { + srcb := src.Bounds() + b := dst.Bounds() + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + sx, sy := a.pt(x, y) + if inBounds(srcb, sx, sy) { + c := i.RGBA(src, sx, sy) + off := (y-dst.Rect.Min.Y)*dst.Stride + (x-dst.Rect.Min.X)*4 + dst.Pix[off+0] = c.R + dst.Pix[off+1] = c.G + dst.Pix[off+2] = c.B + dst.Pix[off+3] = c.A + } + } + } + return nil +} + +// Transform applies the affine transform to src and produces dst. +func (a Affine) Transform(dst draw.Image, src image.Image, i interp.Interp) error { + if dst == nil { + return errors.New("graphics: dst is nil") + } + if src == nil { + return errors.New("graphics: src is nil") + } + + // RGBA fast path. + dstRGBA, dstOk := dst.(*image.RGBA) + srcRGBA, srcOk := src.(*image.RGBA) + interpRGBA, interpOk := i.(interp.RGBA) + if dstOk && srcOk && interpOk { + return a.transformRGBA(dstRGBA, srcRGBA, interpRGBA) + } + + srcb := src.Bounds() + b := dst.Bounds() + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + sx, sy := a.pt(x, y) + if inBounds(srcb, sx, sy) { + dst.Set(x, y, i.Interp(src, sx, sy)) + } + } + } + return nil +} + +func inBounds(b image.Rectangle, x, y float64) bool { + if x < float64(b.Min.X) || x >= float64(b.Max.X) { + return false + } + if y < float64(b.Min.Y) || y >= float64(b.Max.Y) { + return false + } + return true +} + +func (a Affine) pt(x0, y0 int) (x1, y1 float64) { + fx := float64(x0) + 0.5 + fy := float64(y0) + 0.5 + x1 = fx*a[0] + fy*a[1] + a[2] + y1 = fx*a[3] + fy*a[4] + a[5] + return x1, y1 +} + +// TransformCenter applies the affine transform to src and produces dst. +// Equivalent to +// a.CenterFit(dst, src).Transform(dst, src, i). +func (a Affine) TransformCenter(dst draw.Image, src image.Image, i interp.Interp) error { + if dst == nil { + return errors.New("graphics: dst is nil") + } + if src == nil { + return errors.New("graphics: src is nil") + } + + return a.CenterFit(dst.Bounds(), src.Bounds()).Transform(dst, src, i) +} + +// Scale produces a scaling transform of factors x and y. +func (a Affine) Scale(x, y float64) Affine { + return a.Mul(Affine{ + 1 / x, 0, 0, + 0, 1 / y, 0, + 0, 0, 1, + }) +} + +// Rotate produces a clockwise rotation transform of angle, in radians. +func (a Affine) Rotate(angle float64) Affine { + s, c := math.Sincos(angle) + return a.Mul(Affine{ + +c, +s, +0, + -s, +c, +0, + +0, +0, +1, + }) +} + +// Shear produces a shear transform by the slopes x and y. +func (a Affine) Shear(x, y float64) Affine { + d := 1 - x*y + return a.Mul(Affine{ + +1 / d, -x / d, 0, + -y / d, +1 / d, 0, + 0, 0, 1, + }) +} + +// Translate produces a translation transform with pixel distances x and y. +func (a Affine) Translate(x, y float64) Affine { + return a.Mul(Affine{ + 1, 0, -x, + 0, 1, -y, + 0, 0, +1, + }) +} + +// Center produces the affine transform, centered around the provided point. +func (a Affine) Center(x, y float64) Affine { + return I.Translate(-x, -y).Mul(a).Translate(x, y) +} + +// CenterFit produces the affine transform, centered around the rectangles. +// It is equivalent to +// I.Translate(-<center of src>).Mul(a).Translate(<center of dst>) +func (a Affine) CenterFit(dst, src image.Rectangle) Affine { + dx := float64(dst.Min.X) + float64(dst.Dx())/2 + dy := float64(dst.Min.Y) + float64(dst.Dy())/2 + sx := float64(src.Min.X) + float64(src.Dx())/2 + sy := float64(src.Min.Y) + float64(src.Dy())/2 + return I.Translate(-sx, -sy).Mul(a).Translate(dx, dy) +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/blur.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/blur.go new file mode 100644 index 000000000..9a54d5ad5 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/blur.go @@ -0,0 +1,68 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "code.google.com/p/graphics-go/graphics/convolve" + "errors" + "image" + "image/draw" + "math" +) + +// DefaultStdDev is the default blurring parameter. +var DefaultStdDev = 0.5 + +// BlurOptions are the blurring parameters. +// StdDev is the standard deviation of the normal, higher is blurrier. +// Size is the size of the kernel. If zero, it is set to Ceil(6 * StdDev). +type BlurOptions struct { + StdDev float64 + Size int +} + +// Blur produces a blurred version of the image, using a Gaussian blur. +func Blur(dst draw.Image, src image.Image, opt *BlurOptions) error { + if dst == nil { + return errors.New("graphics: dst is nil") + } + if src == nil { + return errors.New("graphics: src is nil") + } + + sd := DefaultStdDev + size := 0 + + if opt != nil { + sd = opt.StdDev + size = opt.Size + } + + if size < 1 { + size = int(math.Ceil(sd * 6)) + } + + kernel := make([]float64, 2*size+1) + for i := 0; i <= size; i++ { + x := float64(i) / sd + x = math.Pow(1/math.SqrtE, x*x) + kernel[size-i] = x + kernel[size+i] = x + } + + // Normalize the weights to sum to 1.0. + kSum := 0.0 + for _, k := range kernel { + kSum += k + } + for i, k := range kernel { + kernel[i] = k / kSum + } + + return convolve.Convolve(dst, src, &convolve.SeparableKernel{ + X: kernel, + Y: kernel, + }) +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/blur_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/blur_test.go new file mode 100644 index 000000000..1d84fa604 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/blur_test.go @@ -0,0 +1,207 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "code.google.com/p/graphics-go/graphics/graphicstest" + "image" + "image/color" + "testing" + + _ "image/png" +) + +var blurOneColorTests = []transformOneColorTest{ + { + "1x1-blank", 1, 1, 1, 1, + &BlurOptions{0.83, 1}, + []uint8{0xff}, + []uint8{0xff}, + }, + { + "1x1-spreadblank", 1, 1, 1, 1, + &BlurOptions{0.83, 2}, + []uint8{0xff}, + []uint8{0xff}, + }, + { + "3x3-blank", 3, 3, 3, 3, + &BlurOptions{0.83, 2}, + []uint8{ + 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, + }, + []uint8{ + 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, + }, + }, + { + "3x3-dot", 3, 3, 3, 3, + &BlurOptions{0.34, 1}, + []uint8{ + 0x00, 0x00, 0x00, + 0x00, 0xff, 0x00, + 0x00, 0x00, 0x00, + }, + []uint8{ + 0x00, 0x03, 0x00, + 0x03, 0xf2, 0x03, + 0x00, 0x03, 0x00, + }, + }, + { + "5x5-dot", 5, 5, 5, 5, + &BlurOptions{0.34, 1}, + []uint8{ + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + }, + []uint8{ + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, + 0x00, 0x03, 0xf2, 0x03, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + }, + }, + { + "5x5-dot-spread", 5, 5, 5, 5, + &BlurOptions{0.85, 1}, + []uint8{ + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + }, + []uint8{ + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x10, 0x20, 0x10, 0x00, + 0x00, 0x20, 0x40, 0x20, 0x00, + 0x00, 0x10, 0x20, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + }, + }, + { + "4x4-box", 4, 4, 4, 4, + &BlurOptions{0.34, 1}, + []uint8{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x00, + 0x00, 0x00, 0x00, 0x00, + }, + []uint8{ + 0x00, 0x03, 0x03, 0x00, + 0x03, 0xf8, 0xf8, 0x03, + 0x03, 0xf8, 0xf8, 0x03, + 0x00, 0x03, 0x03, 0x00, + }, + }, + { + "5x5-twodots", 5, 5, 5, 5, + &BlurOptions{0.34, 1}, + []uint8{ + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x96, 0x00, 0x96, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + }, + []uint8{ + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x02, 0x00, 0x02, 0x00, + 0x02, 0x8e, 0x04, 0x8e, 0x02, + 0x00, 0x02, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + }, + }, +} + +func TestBlurOneColor(t *testing.T) { + for _, oc := range blurOneColorTests { + dst := oc.newDst() + src := oc.newSrc() + opt := oc.opt.(*BlurOptions) + if err := Blur(dst, src, opt); err != nil { + t.Fatal(err) + } + + if !checkTransformTest(t, &oc, dst) { + continue + } + } +} + +func TestBlurEmpty(t *testing.T) { + empty := image.NewRGBA(image.Rect(0, 0, 0, 0)) + if err := Blur(empty, empty, nil); err != nil { + t.Fatal(err) + } +} + +func TestBlurGopher(t *testing.T) { + src, err := graphicstest.LoadImage("../testdata/gopher.png") + if err != nil { + t.Fatal(err) + } + + dst := image.NewRGBA(src.Bounds()) + if err = Blur(dst, src, &BlurOptions{StdDev: 1.1}); err != nil { + t.Fatal(err) + } + + cmp, err := graphicstest.LoadImage("../testdata/gopher-blur.png") + if err != nil { + t.Fatal(err) + } + err = graphicstest.ImageWithinTolerance(dst, cmp, 0x101) + if err != nil { + t.Fatal(err) + } +} + +func benchBlur(b *testing.B, bounds image.Rectangle) { + b.StopTimer() + + // Construct a fuzzy image. + src := image.NewRGBA(bounds) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + src.SetRGBA(x, y, color.RGBA{ + uint8(5 * x % 0x100), + uint8(7 * y % 0x100), + uint8((7*x + 5*y) % 0x100), + 0xff, + }) + } + } + dst := image.NewRGBA(bounds) + + b.StartTimer() + for i := 0; i < b.N; i++ { + Blur(dst, src, &BlurOptions{0.84, 3}) + } +} + +func BenchmarkBlur400x400x3(b *testing.B) { + benchBlur(b, image.Rect(0, 0, 400, 400)) +} + +// Exactly twice the pixel count of 400x400. +func BenchmarkBlur400x800x3(b *testing.B) { + benchBlur(b, image.Rect(0, 0, 400, 800)) +} + +// Exactly twice the pixel count of 400x800 +func BenchmarkBlur400x1600x3(b *testing.B) { + benchBlur(b, image.Rect(0, 0, 400, 1600)) +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/convolve/Makefile b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/convolve/Makefile new file mode 100644 index 000000000..a5691fa30 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/convolve/Makefile @@ -0,0 +1,11 @@ +# Copyright 2011 The Graphics-Go Authors. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +include $(GOROOT)/src/Make.inc + +TARG=code.google.com/p/graphics-go/graphics/convolve +GOFILES=\ + convolve.go\ + +include $(GOROOT)/src/Make.pkg diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/convolve/convolve.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/convolve/convolve.go new file mode 100644 index 000000000..da69496d0 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/convolve/convolve.go @@ -0,0 +1,274 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package convolve + +import ( + "errors" + "fmt" + "image" + "image/draw" + "math" +) + +// clamp clamps x to the range [x0, x1]. +func clamp(x, x0, x1 float64) float64 { + if x < x0 { + return x0 + } + if x > x1 { + return x1 + } + return x +} + +// Kernel is a square matrix that defines a convolution. +type Kernel interface { + // Weights returns the square matrix of weights in row major order. + Weights() []float64 +} + +// SeparableKernel is a linearly separable, square convolution kernel. +// X and Y are the per-axis weights. Each slice must be the same length, and +// have an odd length. The middle element of each slice is the weight for the +// central pixel. For example, the horizontal Sobel kernel is: +// sobelX := &SeparableKernel{ +// X: []float64{-1, 0, +1}, +// Y: []float64{1, 2, 1}, +// } +type SeparableKernel struct { + X, Y []float64 +} + +func (k *SeparableKernel) Weights() []float64 { + n := len(k.X) + w := make([]float64, n*n) + for y := 0; y < n; y++ { + for x := 0; x < n; x++ { + w[y*n+x] = k.X[x] * k.Y[y] + } + } + return w +} + +// fullKernel is a square convolution kernel. +type fullKernel []float64 + +func (k fullKernel) Weights() []float64 { return k } + +func kernelSize(w []float64) (size int, err error) { + size = int(math.Sqrt(float64(len(w)))) + if size*size != len(w) { + return 0, errors.New("graphics: kernel is not square") + } + if size%2 != 1 { + return 0, errors.New("graphics: kernel size is not odd") + } + return size, nil +} + +// NewKernel returns a square convolution kernel. +func NewKernel(w []float64) (Kernel, error) { + if _, err := kernelSize(w); err != nil { + return nil, err + } + return fullKernel(w), nil +} + +func convolveRGBASep(dst *image.RGBA, src image.Image, k *SeparableKernel) error { + if len(k.X) != len(k.Y) { + return fmt.Errorf("graphics: kernel not square (x %d, y %d)", len(k.X), len(k.Y)) + } + if len(k.X)%2 != 1 { + return fmt.Errorf("graphics: kernel length (%d) not odd", len(k.X)) + } + radius := (len(k.X) - 1) / 2 + + // buf holds the result of vertically blurring src. + bounds := dst.Bounds() + width, height := bounds.Dx(), bounds.Dy() + buf := make([]float64, width*height*4) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + var r, g, b, a float64 + // k0 is the kernel weight for the center pixel. This may be greater + // than kernel[0], near the boundary of the source image, to avoid + // vignetting. + k0 := k.X[radius] + + // Add the pixels from above. + for i := 1; i <= radius; i++ { + f := k.Y[radius-i] + if y-i < bounds.Min.Y { + k0 += f + } else { + or, og, ob, oa := src.At(x, y-i).RGBA() + r += float64(or>>8) * f + g += float64(og>>8) * f + b += float64(ob>>8) * f + a += float64(oa>>8) * f + } + } + + // Add the pixels from below. + for i := 1; i <= radius; i++ { + f := k.Y[radius+i] + if y+i >= bounds.Max.Y { + k0 += f + } else { + or, og, ob, oa := src.At(x, y+i).RGBA() + r += float64(or>>8) * f + g += float64(og>>8) * f + b += float64(ob>>8) * f + a += float64(oa>>8) * f + } + } + + // Add the central pixel. + or, og, ob, oa := src.At(x, y).RGBA() + r += float64(or>>8) * k0 + g += float64(og>>8) * k0 + b += float64(ob>>8) * k0 + a += float64(oa>>8) * k0 + + // Write to buf. + o := (y-bounds.Min.Y)*width*4 + (x-bounds.Min.X)*4 + buf[o+0] = r + buf[o+1] = g + buf[o+2] = b + buf[o+3] = a + } + } + + // dst holds the result of horizontally blurring buf. + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + var r, g, b, a float64 + k0, off := k.X[radius], y*width*4+x*4 + + // Add the pixels from the left. + for i := 1; i <= radius; i++ { + f := k.X[radius-i] + if x-i < 0 { + k0 += f + } else { + o := off - i*4 + r += buf[o+0] * f + g += buf[o+1] * f + b += buf[o+2] * f + a += buf[o+3] * f + } + } + + // Add the pixels from the right. + for i := 1; i <= radius; i++ { + f := k.X[radius+i] + if x+i >= width { + k0 += f + } else { + o := off + i*4 + r += buf[o+0] * f + g += buf[o+1] * f + b += buf[o+2] * f + a += buf[o+3] * f + } + } + + // Add the central pixel. + r += buf[off+0] * k0 + g += buf[off+1] * k0 + b += buf[off+2] * k0 + a += buf[off+3] * k0 + + // Write to dst, clamping to the range [0, 255]. + dstOff := (y-dst.Rect.Min.Y)*dst.Stride + (x-dst.Rect.Min.X)*4 + dst.Pix[dstOff+0] = uint8(clamp(r+0.5, 0, 255)) + dst.Pix[dstOff+1] = uint8(clamp(g+0.5, 0, 255)) + dst.Pix[dstOff+2] = uint8(clamp(b+0.5, 0, 255)) + dst.Pix[dstOff+3] = uint8(clamp(a+0.5, 0, 255)) + } + } + + return nil +} + +func convolveRGBA(dst *image.RGBA, src image.Image, k Kernel) error { + b := dst.Bounds() + bs := src.Bounds() + w := k.Weights() + size, err := kernelSize(w) + if err != nil { + return err + } + radius := (size - 1) / 2 + + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if !image.Pt(x, y).In(bs) { + continue + } + + var r, g, b, a, adj float64 + for cy := y - radius; cy <= y+radius; cy++ { + for cx := x - radius; cx <= x+radius; cx++ { + factor := w[(cy-y+radius)*size+cx-x+radius] + if !image.Pt(cx, cy).In(bs) { + adj += factor + } else { + sr, sg, sb, sa := src.At(cx, cy).RGBA() + r += float64(sr>>8) * factor + g += float64(sg>>8) * factor + b += float64(sb>>8) * factor + a += float64(sa>>8) * factor + } + } + } + + if adj != 0 { + sr, sg, sb, sa := src.At(x, y).RGBA() + r += float64(sr>>8) * adj + g += float64(sg>>8) * adj + b += float64(sb>>8) * adj + a += float64(sa>>8) * adj + } + + off := (y-dst.Rect.Min.Y)*dst.Stride + (x-dst.Rect.Min.X)*4 + dst.Pix[off+0] = uint8(clamp(r+0.5, 0, 0xff)) + dst.Pix[off+1] = uint8(clamp(g+0.5, 0, 0xff)) + dst.Pix[off+2] = uint8(clamp(b+0.5, 0, 0xff)) + dst.Pix[off+3] = uint8(clamp(a+0.5, 0, 0xff)) + } + } + + return nil +} + +// Convolve produces dst by applying the convolution kernel k to src. +func Convolve(dst draw.Image, src image.Image, k Kernel) (err error) { + if dst == nil || src == nil || k == nil { + return nil + } + + b := dst.Bounds() + dstRgba, ok := dst.(*image.RGBA) + if !ok { + dstRgba = image.NewRGBA(b) + } + + switch k := k.(type) { + case *SeparableKernel: + err = convolveRGBASep(dstRgba, src, k) + default: + err = convolveRGBA(dstRgba, src, k) + } + + if err != nil { + return err + } + + if !ok { + draw.Draw(dst, b, dstRgba, b.Min, draw.Src) + } + return nil +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/convolve/convolve_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/convolve/convolve_test.go new file mode 100644 index 000000000..f34d7afc8 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/convolve/convolve_test.go @@ -0,0 +1,78 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package convolve + +import ( + "code.google.com/p/graphics-go/graphics/graphicstest" + "image" + "reflect" + "testing" + + _ "image/png" +) + +func TestSeparableWeights(t *testing.T) { + sobelXFull := []float64{ + -1, 0, 1, + -2, 0, 2, + -1, 0, 1, + } + sobelXSep := &SeparableKernel{ + X: []float64{-1, 0, +1}, + Y: []float64{1, 2, 1}, + } + w := sobelXSep.Weights() + if !reflect.DeepEqual(w, sobelXFull) { + t.Errorf("got %v want %v", w, sobelXFull) + } +} + +func TestConvolve(t *testing.T) { + kernFull, err := NewKernel([]float64{ + 0, 0, 0, + 1, 1, 1, + 0, 0, 0, + }) + if err != nil { + t.Fatal(err) + } + + kernSep := &SeparableKernel{ + X: []float64{1, 1, 1}, + Y: []float64{0, 1, 0}, + } + + src, err := graphicstest.LoadImage("../../testdata/gopher.png") + if err != nil { + t.Fatal(err) + } + b := src.Bounds() + + sep := image.NewRGBA(b) + if err = Convolve(sep, src, kernSep); err != nil { + t.Fatal(err) + } + + full := image.NewRGBA(b) + Convolve(full, src, kernFull) + + err = graphicstest.ImageWithinTolerance(sep, full, 0x101) + if err != nil { + t.Fatal(err) + } +} + +func TestConvolveNil(t *testing.T) { + if err := Convolve(nil, nil, nil); err != nil { + t.Fatal(err) + } +} + +func TestConvolveEmpty(t *testing.T) { + empty := image.NewRGBA(image.Rect(0, 0, 0, 0)) + if err := Convolve(empty, empty, nil); err != nil { + t.Fatal(err) + } +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/Makefile b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/Makefile new file mode 100644 index 000000000..0b1c6cb3e --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/Makefile @@ -0,0 +1,15 @@ +# Copyright 2011 The Graphics-Go Authors. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +include $(GOROOT)/src/Make.inc + +TARG=code.google.com/p/graphics-go/graphics +GOFILES=\ + detect.go\ + doc.go\ + integral.go\ + opencv_parser.go\ + projector.go\ + +include $(GOROOT)/src/Make.pkg diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/detect.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/detect.go new file mode 100644 index 000000000..dde941cbe --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/detect.go @@ -0,0 +1,133 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package detect + +import ( + "image" + "math" +) + +// Feature is a Haar-like feature. +type Feature struct { + Rect image.Rectangle + Weight float64 +} + +// Classifier is a set of features with a threshold. +type Classifier struct { + Feature []Feature + Threshold float64 + Left float64 + Right float64 +} + +// CascadeStage is a cascade of classifiers. +type CascadeStage struct { + Classifier []Classifier + Threshold float64 +} + +// Cascade is a degenerate tree of Haar-like classifiers. +type Cascade struct { + Stage []CascadeStage + Size image.Point +} + +// Match returns true if the full image is classified as an object. +func (c *Cascade) Match(m image.Image) bool { + return c.classify(newWindow(m)) +} + +// Find returns a set of areas of m that match the feature cascade c. +func (c *Cascade) Find(m image.Image) []image.Rectangle { + // TODO(crawshaw): Consider de-duping strategies. + matches := []image.Rectangle{} + w := newWindow(m) + + b := m.Bounds() + origScale := c.Size + for s := origScale; s.X < b.Dx() && s.Y < b.Dy(); s = s.Add(s.Div(10)) { + // translate region and classify + tx := image.Pt(s.X/10, 0) + ty := image.Pt(0, s.Y/10) + for r := image.Rect(0, 0, s.X, s.Y).Add(b.Min); r.In(b); r = r.Add(ty) { + for r1 := r; r1.In(b); r1 = r1.Add(tx) { + if c.classify(w.subWindow(r1)) { + matches = append(matches, r1) + } + } + } + } + return matches +} + +type window struct { + mi *integral + miSq *integral + rect image.Rectangle + invArea float64 + stdDev float64 +} + +func (w *window) init() { + w.invArea = 1 / float64(w.rect.Dx()*w.rect.Dy()) + mean := float64(w.mi.sum(w.rect)) * w.invArea + vr := float64(w.miSq.sum(w.rect))*w.invArea - mean*mean + if vr < 0 { + vr = 1 + } + w.stdDev = math.Sqrt(vr) +} + +func newWindow(m image.Image) *window { + mi, miSq := newIntegrals(m) + res := &window{ + mi: mi, + miSq: miSq, + rect: m.Bounds(), + } + res.init() + return res +} + +func (w *window) subWindow(r image.Rectangle) *window { + res := &window{ + mi: w.mi, + miSq: w.miSq, + rect: r, + } + res.init() + return res +} + +func (c *Classifier) classify(w *window, pr *projector) float64 { + s := 0.0 + for _, f := range c.Feature { + s += float64(w.mi.sum(pr.rect(f.Rect))) * f.Weight + } + s *= w.invArea // normalize to maintain scale invariance + if s < c.Threshold*w.stdDev { + return c.Left + } + return c.Right +} + +func (s *CascadeStage) classify(w *window, pr *projector) bool { + sum := 0.0 + for _, c := range s.Classifier { + sum += c.classify(w, pr) + } + return sum >= s.Threshold +} + +func (c *Cascade) classify(w *window) bool { + pr := newProjector(w.rect, image.Rectangle{image.Pt(0, 0), c.Size}) + for _, s := range c.Stage { + if !s.classify(w, pr) { + return false + } + } + return true +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/detect_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/detect_test.go new file mode 100644 index 000000000..8a2df113d --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/detect_test.go @@ -0,0 +1,77 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package detect + +import ( + "image" + "image/draw" + "testing" +) + +var ( + c0 = Classifier{ + Feature: []Feature{ + Feature{Rect: image.Rect(0, 0, 3, 4), Weight: 4.0}, + }, + Threshold: 0.2, + Left: 0.8, + Right: 0.2, + } + c1 = Classifier{ + Feature: []Feature{ + Feature{Rect: image.Rect(3, 4, 4, 5), Weight: 4.0}, + }, + Threshold: 0.2, + Left: 0.8, + Right: 0.2, + } + c2 = Classifier{ + Feature: []Feature{ + Feature{Rect: image.Rect(0, 0, 1, 1), Weight: +4.0}, + Feature{Rect: image.Rect(0, 0, 2, 2), Weight: -1.0}, + }, + Threshold: 0.2, + Left: 0.8, + Right: 0.2, + } +) + +func TestClassifier(t *testing.T) { + m := image.NewGray(image.Rect(0, 0, 20, 20)) + b := m.Bounds() + draw.Draw(m, image.Rect(0, 0, 20, 20), image.White, image.ZP, draw.Src) + draw.Draw(m, image.Rect(3, 4, 4, 5), image.Black, image.ZP, draw.Src) + w := newWindow(m) + pr := newProjector(b, b) + + if res := c0.classify(w, pr); res != c0.Right { + t.Errorf("c0 got %f want %f", res, c0.Right) + } + if res := c1.classify(w, pr); res != c1.Left { + t.Errorf("c1 got %f want %f", res, c1.Left) + } + if res := c2.classify(w, pr); res != c1.Left { + t.Errorf("c2 got %f want %f", res, c1.Left) + } +} + +func TestClassifierScale(t *testing.T) { + m := image.NewGray(image.Rect(0, 0, 50, 50)) + b := m.Bounds() + draw.Draw(m, image.Rect(0, 0, 8, 10), image.White, b.Min, draw.Src) + draw.Draw(m, image.Rect(8, 10, 10, 13), image.Black, b.Min, draw.Src) + w := newWindow(m) + pr := newProjector(b, image.Rect(0, 0, 20, 20)) + + if res := c0.classify(w, pr); res != c0.Right { + t.Errorf("scaled c0 got %f want %f", res, c0.Right) + } + if res := c1.classify(w, pr); res != c1.Left { + t.Errorf("scaled c1 got %f want %f", res, c1.Left) + } + if res := c2.classify(w, pr); res != c1.Left { + t.Errorf("scaled c2 got %f want %f", res, c1.Left) + } +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/doc.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/doc.go new file mode 100644 index 000000000..a0f4e94cd --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/doc.go @@ -0,0 +1,31 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Package detect implements an object detector cascade. + +The technique used is a degenerate tree of Haar-like classifiers, commonly +used for face detection. It is described in + + P. Viola, M. Jones. + Rapid Object Detection using a Boosted Cascade of Simple Features, 2001 + IEEE Conference on Computer Vision and Pattern Recognition + +A Cascade can be constructed manually from a set of Classifiers in stages, +or can be loaded from an XML file in the OpenCV format with + + classifier, _, err := detect.ParseOpenCV(r) + +The classifier can be used to determine if a full image is detected as an +object using Detect + + if classifier.Match(m) { + // m is an image of a face. + } + +It is also possible to search an image for occurrences of an object + + objs := classifier.Find(m) +*/ +package detect diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/integral.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/integral.go new file mode 100644 index 000000000..814ced590 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/integral.go @@ -0,0 +1,93 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package detect + +import ( + "image" + "image/draw" +) + +// integral is an image.Image-like structure that stores the cumulative +// sum of the preceding pixels. This allows for O(1) summation of any +// rectangular region within the image. +type integral struct { + // pix holds the cumulative sum of the image's pixels. The pixel at + // (x, y) starts at pix[(y-rect.Min.Y)*stride + (x-rect.Min.X)*1]. + pix []uint64 + stride int + rect image.Rectangle +} + +func (p *integral) at(x, y int) uint64 { + return p.pix[(y-p.rect.Min.Y)*p.stride+(x-p.rect.Min.X)] +} + +func (p *integral) sum(b image.Rectangle) uint64 { + c := p.at(b.Max.X-1, b.Max.Y-1) + inY := b.Min.Y > p.rect.Min.Y + inX := b.Min.X > p.rect.Min.X + if inY && inX { + c += p.at(b.Min.X-1, b.Min.Y-1) + } + if inY { + c -= p.at(b.Max.X-1, b.Min.Y-1) + } + if inX { + c -= p.at(b.Min.X-1, b.Max.Y-1) + } + return c +} + +func (m *integral) integrate() { + b := m.rect + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + c := uint64(0) + if y > b.Min.Y && x > b.Min.X { + c += m.at(x-1, y) + c += m.at(x, y-1) + c -= m.at(x-1, y-1) + } else if y > b.Min.Y { + c += m.at(b.Min.X, y-1) + } else if x > b.Min.X { + c += m.at(x-1, b.Min.Y) + } + m.pix[(y-m.rect.Min.Y)*m.stride+(x-m.rect.Min.X)] += c + } + } +} + +// newIntegrals returns the integral and the squared integral. +func newIntegrals(src image.Image) (*integral, *integral) { + b := src.Bounds() + srcg, ok := src.(*image.Gray) + if !ok { + srcg = image.NewGray(b) + draw.Draw(srcg, b, src, b.Min, draw.Src) + } + + m := integral{ + pix: make([]uint64, b.Max.Y*b.Max.X), + stride: b.Max.X, + rect: b, + } + mSq := integral{ + pix: make([]uint64, b.Max.Y*b.Max.X), + stride: b.Max.X, + rect: b, + } + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + os := (y-b.Min.Y)*srcg.Stride + x - b.Min.X + om := (y-b.Min.Y)*m.stride + x - b.Min.X + c := uint64(srcg.Pix[os]) + m.pix[om] = c + mSq.pix[om] = c * c + } + } + m.integrate() + mSq.integrate() + return &m, &mSq +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/integral_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/integral_test.go new file mode 100644 index 000000000..0bc321a4d --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/integral_test.go @@ -0,0 +1,156 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package detect + +import ( + "bytes" + "fmt" + "image" + "testing" +) + +type integralTest struct { + x int + y int + src []uint8 + res []uint8 +} + +var integralTests = []integralTest{ + { + 1, 1, + []uint8{0x01}, + []uint8{0x01}, + }, + { + 2, 2, + []uint8{ + 0x01, 0x02, + 0x03, 0x04, + }, + []uint8{ + 0x01, 0x03, + 0x04, 0x0a, + }, + }, + { + 4, 4, + []uint8{ + 0x02, 0x03, 0x00, 0x01, + 0x01, 0x02, 0x01, 0x05, + 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, + }, + []uint8{ + 0x02, 0x05, 0x05, 0x06, + 0x03, 0x08, 0x09, 0x0f, + 0x04, 0x0a, 0x0c, 0x13, + 0x05, 0x0c, 0x0f, 0x17, + }, + }, +} + +func sprintBox(box []byte, width, height int) string { + buf := bytes.NewBuffer(nil) + i := 0 + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + fmt.Fprintf(buf, " 0x%02x,", box[i]) + i++ + } + buf.WriteByte('\n') + } + return buf.String() +} + +func TestIntegral(t *testing.T) { + for i, oc := range integralTests { + src := &image.Gray{ + Pix: oc.src, + Stride: oc.x, + Rect: image.Rect(0, 0, oc.x, oc.y), + } + dst, _ := newIntegrals(src) + res := make([]byte, len(dst.pix)) + for i, p := range dst.pix { + res[i] = byte(p) + } + + if !bytes.Equal(res, oc.res) { + got := sprintBox(res, oc.x, oc.y) + want := sprintBox(oc.res, oc.x, oc.y) + t.Errorf("%d: got\n%s\n want\n%s", i, got, want) + } + } +} + +func TestIntegralSum(t *testing.T) { + src := &image.Gray{ + Pix: []uint8{ + 0x02, 0x03, 0x00, 0x01, 0x03, + 0x01, 0x02, 0x01, 0x05, 0x05, + 0x01, 0x01, 0x01, 0x01, 0x02, + 0x01, 0x01, 0x01, 0x01, 0x07, + 0x02, 0x01, 0x00, 0x03, 0x01, + }, + Stride: 5, + Rect: image.Rect(0, 0, 5, 5), + } + img, _ := newIntegrals(src) + + type sumTest struct { + rect image.Rectangle + sum uint64 + } + + var sumTests = []sumTest{ + {image.Rect(0, 0, 1, 1), 2}, + {image.Rect(0, 0, 2, 1), 5}, + {image.Rect(0, 0, 1, 3), 4}, + {image.Rect(1, 1, 3, 3), 5}, + {image.Rect(2, 2, 4, 4), 4}, + {image.Rect(4, 3, 5, 5), 8}, + {image.Rect(2, 4, 3, 5), 0}, + } + + for _, st := range sumTests { + s := img.sum(st.rect) + if s != st.sum { + t.Errorf("%v: got %d want %d", st.rect, s, st.sum) + return + } + } +} + +func TestIntegralSubImage(t *testing.T) { + m0 := &image.Gray{ + Pix: []uint8{ + 0x02, 0x03, 0x00, 0x01, 0x03, + 0x01, 0x02, 0x01, 0x05, 0x05, + 0x01, 0x04, 0x01, 0x01, 0x02, + 0x01, 0x02, 0x01, 0x01, 0x07, + 0x02, 0x01, 0x09, 0x03, 0x01, + }, + Stride: 5, + Rect: image.Rect(0, 0, 5, 5), + } + b := image.Rect(1, 1, 4, 4) + m1 := m0.SubImage(b) + mi0, _ := newIntegrals(m0) + mi1, _ := newIntegrals(m1) + + sum0 := mi0.sum(b) + sum1 := mi1.sum(b) + if sum0 != sum1 { + t.Errorf("b got %d want %d", sum0, sum1) + } + + r0 := image.Rect(2, 2, 4, 4) + sum0 = mi0.sum(r0) + sum1 = mi1.sum(r0) + if sum0 != sum1 { + t.Errorf("r0 got %d want %d", sum1, sum0) + } +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/opencv_parser.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/opencv_parser.go new file mode 100644 index 000000000..51ded1a1c --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/opencv_parser.go @@ -0,0 +1,125 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package detect + +import ( + "bytes" + "encoding/xml" + "errors" + "fmt" + "image" + "io" + "io/ioutil" + "strconv" + "strings" +) + +type xmlFeature struct { + Rects []string `xml:"grp>feature>rects>grp"` + Tilted int `xml:"grp>feature>tilted"` + Threshold float64 `xml:"grp>threshold"` + Left float64 `xml:"grp>left_val"` + Right float64 `xml:"grp>right_val"` +} + +type xmlStages struct { + Trees []xmlFeature `xml:"trees>grp"` + Stage_threshold float64 `xml:"stage_threshold"` + Parent int `xml:"parent"` + Next int `xml:"next"` +} + +type opencv_storage struct { + Any struct { + XMLName xml.Name + Type string `xml:"type_id,attr"` + Size string `xml:"size"` + Stages []xmlStages `xml:"stages>grp"` + } `xml:",any"` +} + +func buildFeature(r string) (f Feature, err error) { + var x, y, w, h int + var weight float64 + _, err = fmt.Sscanf(r, "%d %d %d %d %f", &x, &y, &w, &h, &weight) + if err != nil { + return + } + f.Rect = image.Rect(x, y, x+w, y+h) + f.Weight = weight + return +} + +func buildCascade(s *opencv_storage) (c *Cascade, name string, err error) { + if s.Any.Type != "opencv-haar-classifier" { + err = fmt.Errorf("got %s want opencv-haar-classifier", s.Any.Type) + return + } + name = s.Any.XMLName.Local + + c = &Cascade{} + sizes := strings.Split(s.Any.Size, " ") + w, err := strconv.Atoi(sizes[0]) + if err != nil { + return nil, "", err + } + h, err := strconv.Atoi(sizes[1]) + if err != nil { + return nil, "", err + } + c.Size = image.Pt(w, h) + c.Stage = []CascadeStage{} + + for _, stage := range s.Any.Stages { + cs := CascadeStage{ + Classifier: []Classifier{}, + Threshold: stage.Stage_threshold, + } + for _, tree := range stage.Trees { + if tree.Tilted != 0 { + err = errors.New("Cascade does not support tilted features") + return + } + + cls := Classifier{ + Feature: []Feature{}, + Threshold: tree.Threshold, + Left: tree.Left, + Right: tree.Right, + } + + for _, rect := range tree.Rects { + f, err := buildFeature(rect) + if err != nil { + return nil, "", err + } + cls.Feature = append(cls.Feature, f) + } + + cs.Classifier = append(cs.Classifier, cls) + } + c.Stage = append(c.Stage, cs) + } + + return +} + +// ParseOpenCV produces a detection Cascade from an OpenCV XML file. +func ParseOpenCV(r io.Reader) (cascade *Cascade, name string, err error) { + // BUG(crawshaw): tag-based parsing doesn't seem to work with <_> + buf, err := ioutil.ReadAll(r) + if err != nil { + return + } + buf = bytes.Replace(buf, []byte("<_>"), []byte("<grp>"), -1) + buf = bytes.Replace(buf, []byte("</_>"), []byte("</grp>"), -1) + + s := &opencv_storage{} + err = xml.Unmarshal(buf, s) + if err != nil { + return + } + return buildCascade(s) +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/opencv_parser_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/opencv_parser_test.go new file mode 100644 index 000000000..343390499 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/opencv_parser_test.go @@ -0,0 +1,75 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package detect + +import ( + "image" + "os" + "reflect" + "testing" +) + +var ( + classifier0 = Classifier{ + Feature: []Feature{ + Feature{Rect: image.Rect(0, 0, 3, 4), Weight: -1}, + Feature{Rect: image.Rect(3, 4, 5, 6), Weight: 3.1}, + }, + Threshold: 0.03, + Left: 0.01, + Right: 0.8, + } + classifier1 = Classifier{ + Feature: []Feature{ + Feature{Rect: image.Rect(3, 7, 17, 11), Weight: -3.2}, + Feature{Rect: image.Rect(3, 9, 17, 11), Weight: 2.}, + }, + Threshold: 0.11, + Left: 0.03, + Right: 0.83, + } + classifier2 = Classifier{ + Feature: []Feature{ + Feature{Rect: image.Rect(1, 1, 3, 3), Weight: -1.}, + Feature{Rect: image.Rect(3, 3, 5, 5), Weight: 2.5}, + }, + Threshold: 0.07, + Left: 0.2, + Right: 0.4, + } + cascade = Cascade{ + Stage: []CascadeStage{ + CascadeStage{ + Classifier: []Classifier{classifier0, classifier1}, + Threshold: 0.82, + }, + CascadeStage{ + Classifier: []Classifier{classifier2}, + Threshold: 0.22, + }, + }, + Size: image.Pt(20, 20), + } +) + +func TestParseOpenCV(t *testing.T) { + file, err := os.Open("../../testdata/opencv.xml") + if err != nil { + t.Fatal(err) + } + defer file.Close() + + cascadeFile, name, err := ParseOpenCV(file) + if err != nil { + t.Fatal(err) + } + if name != "name_of_cascade" { + t.Fatalf("name: got %s want name_of_cascade", name) + } + + if !reflect.DeepEqual(cascade, *cascadeFile) { + t.Errorf("got\n %v want\n %v", *cascadeFile, cascade) + } +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/projector.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/projector.go new file mode 100644 index 000000000..1ebd6db59 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/projector.go @@ -0,0 +1,55 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package detect + +import ( + "image" +) + +// projector allows projecting from a source Rectangle onto a target Rectangle. +type projector struct { + // rx, ry is the scaling factor. + rx, ry float64 + // dx, dy is the translation factor. + dx, dy float64 + // r is the clipping region of the target. + r image.Rectangle +} + +// newProjector creates a Projector with source src and target dst. +func newProjector(dst image.Rectangle, src image.Rectangle) *projector { + return &projector{ + rx: float64(dst.Dx()) / float64(src.Dx()), + ry: float64(dst.Dy()) / float64(src.Dy()), + dx: float64(dst.Min.X - src.Min.X), + dy: float64(dst.Min.Y - src.Min.Y), + r: dst, + } +} + +// pt projects p from the source rectangle onto the target rectangle. +func (s *projector) pt(p image.Point) image.Point { + return image.Point{ + clamp(s.rx*float64(p.X)+s.dx, s.r.Min.X, s.r.Max.X), + clamp(s.ry*float64(p.Y)+s.dy, s.r.Min.Y, s.r.Max.Y), + } +} + +// rect projects r from the source rectangle onto the target rectangle. +func (s *projector) rect(r image.Rectangle) image.Rectangle { + return image.Rectangle{s.pt(r.Min), s.pt(r.Max)} +} + +// clamp rounds and clamps o to the integer range [x0, x1]. +func clamp(o float64, x0, x1 int) int { + x := int(o + 0.5) + if x < x0 { + return x0 + } + if x > x1 { + return x1 + } + return x +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/projector_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/projector_test.go new file mode 100644 index 000000000..c6d0b0cd5 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/detect/projector_test.go @@ -0,0 +1,49 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package detect + +import ( + "image" + "reflect" + "testing" +) + +type projectorTest struct { + dst image.Rectangle + src image.Rectangle + pdst image.Rectangle + psrc image.Rectangle +} + +var projectorTests = []projectorTest{ + { + image.Rect(0, 0, 6, 6), + image.Rect(0, 0, 2, 2), + image.Rect(0, 0, 6, 6), + image.Rect(0, 0, 2, 2), + }, + { + image.Rect(0, 0, 6, 6), + image.Rect(0, 0, 2, 2), + image.Rect(3, 3, 6, 6), + image.Rect(1, 1, 2, 2), + }, + { + image.Rect(30, 30, 40, 40), + image.Rect(10, 10, 20, 20), + image.Rect(32, 33, 34, 37), + image.Rect(12, 13, 14, 17), + }, +} + +func TestProjector(t *testing.T) { + for i, tt := range projectorTests { + pr := newProjector(tt.dst, tt.src) + res := pr.rect(tt.psrc) + if !reflect.DeepEqual(res, tt.pdst) { + t.Errorf("%d: got %v want %v", i, res, tt.pdst) + } + } +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/graphicstest/Makefile b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/graphicstest/Makefile new file mode 100644 index 000000000..7bfdf22d8 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/graphicstest/Makefile @@ -0,0 +1,11 @@ +# Copyright 2011 The Graphics-Go Authors. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +include $(GOROOT)/src/Make.inc + +TARG=code.google.com/p/graphics-go/graphics/graphicstest +GOFILES=\ + graphicstest.go\ + +include $(GOROOT)/src/Make.pkg diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/graphicstest/graphicstest.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/graphicstest/graphicstest.go new file mode 100644 index 000000000..ceb3a974d --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/graphicstest/graphicstest.go @@ -0,0 +1,112 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphicstest + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/color" + "os" +) + +// LoadImage decodes an image from a file. +func LoadImage(path string) (img image.Image, err error) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + img, _, err = image.Decode(file) + return +} + +func delta(u0, u1 uint32) int { + d := int(u0) - int(u1) + if d < 0 { + return -d + } + return d +} + +func withinTolerance(c0, c1 color.Color, tol int) bool { + r0, g0, b0, a0 := c0.RGBA() + r1, g1, b1, a1 := c1.RGBA() + r := delta(r0, r1) + g := delta(g0, g1) + b := delta(b0, b1) + a := delta(a0, a1) + return r <= tol && g <= tol && b <= tol && a <= tol +} + +// ImageWithinTolerance checks that each pixel varies by no more than tol. +func ImageWithinTolerance(m0, m1 image.Image, tol int) error { + b0 := m0.Bounds() + b1 := m1.Bounds() + if !b0.Eq(b1) { + return errors.New(fmt.Sprintf("got bounds %v want %v", b0, b1)) + } + + for y := b0.Min.Y; y < b0.Max.Y; y++ { + for x := b0.Min.X; x < b0.Max.X; x++ { + c0 := m0.At(x, y) + c1 := m1.At(x, y) + if !withinTolerance(c0, c1, tol) { + e := fmt.Sprintf("got %v want %v at (%d, %d)", c0, c1, x, y) + return errors.New(e) + } + } + } + return nil +} + +// SprintBox pretty prints the array as a hexidecimal matrix. +func SprintBox(box []byte, width, height int) string { + buf := bytes.NewBuffer(nil) + i := 0 + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + fmt.Fprintf(buf, " 0x%02x,", box[i]) + i++ + } + buf.WriteByte('\n') + } + return buf.String() +} + +// SprintImageR pretty prints the red channel of src. It looks like SprintBox. +func SprintImageR(src *image.RGBA) string { + w, h := src.Rect.Dx(), src.Rect.Dy() + i := 0 + box := make([]byte, w*h) + for y := src.Rect.Min.Y; y < src.Rect.Max.Y; y++ { + for x := src.Rect.Min.X; x < src.Rect.Max.X; x++ { + off := (y-src.Rect.Min.Y)*src.Stride + (x-src.Rect.Min.X)*4 + box[i] = src.Pix[off] + i++ + } + } + return SprintBox(box, w, h) +} + +// MakeRGBA returns an image with R, G, B taken from src. +func MakeRGBA(src []uint8, width int) *image.RGBA { + b := image.Rect(0, 0, width, len(src)/width) + ret := image.NewRGBA(b) + i := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + ret.SetRGBA(x, y, color.RGBA{ + R: src[i], + G: src[i], + B: src[i], + A: 0xff, + }) + i++ + } + } + return ret +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/Makefile b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/Makefile new file mode 100644 index 000000000..4d8f524fb --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/Makefile @@ -0,0 +1,13 @@ +# Copyright 2012 The Graphics-Go Authors. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +include $(GOROOT)/src/Make.inc + +TARG=code.google.com/p/graphics-go/graphics/interp +GOFILES=\ + bilinear.go\ + doc.go\ + interp.go\ + +include $(GOROOT)/src/Make.pkg diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/bilinear.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/bilinear.go new file mode 100644 index 000000000..e18321a15 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/bilinear.go @@ -0,0 +1,206 @@ +// Copyright 2012 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package interp + +import ( + "image" + "image/color" + "math" +) + +// Bilinear implements bilinear interpolation. +var Bilinear Interp = bilinear{} + +type bilinear struct{} + +func (i bilinear) Interp(src image.Image, x, y float64) color.Color { + if src, ok := src.(*image.RGBA); ok { + return i.RGBA(src, x, y) + } + return bilinearGeneral(src, x, y) +} + +func bilinearGeneral(src image.Image, x, y float64) color.Color { + p := findLinearSrc(src.Bounds(), x, y) + var fr, fg, fb, fa float64 + var r, g, b, a uint32 + + r, g, b, a = src.At(p.low.X, p.low.Y).RGBA() + fr += float64(r) * p.frac00 + fg += float64(g) * p.frac00 + fb += float64(b) * p.frac00 + fa += float64(a) * p.frac00 + + r, g, b, a = src.At(p.high.X, p.low.Y).RGBA() + fr += float64(r) * p.frac01 + fg += float64(g) * p.frac01 + fb += float64(b) * p.frac01 + fa += float64(a) * p.frac01 + + r, g, b, a = src.At(p.low.X, p.high.Y).RGBA() + fr += float64(r) * p.frac10 + fg += float64(g) * p.frac10 + fb += float64(b) * p.frac10 + fa += float64(a) * p.frac10 + + r, g, b, a = src.At(p.high.X, p.high.Y).RGBA() + fr += float64(r) * p.frac11 + fg += float64(g) * p.frac11 + fb += float64(b) * p.frac11 + fa += float64(a) * p.frac11 + + var c color.RGBA64 + c.R = uint16(fr + 0.5) + c.G = uint16(fg + 0.5) + c.B = uint16(fb + 0.5) + c.A = uint16(fa + 0.5) + return c +} + +func (bilinear) RGBA(src *image.RGBA, x, y float64) color.RGBA { + p := findLinearSrc(src.Bounds(), x, y) + + // Array offsets for the surrounding pixels. + off00 := offRGBA(src, p.low.X, p.low.Y) + off01 := offRGBA(src, p.high.X, p.low.Y) + off10 := offRGBA(src, p.low.X, p.high.Y) + off11 := offRGBA(src, p.high.X, p.high.Y) + + var fr, fg, fb, fa float64 + + fr += float64(src.Pix[off00+0]) * p.frac00 + fg += float64(src.Pix[off00+1]) * p.frac00 + fb += float64(src.Pix[off00+2]) * p.frac00 + fa += float64(src.Pix[off00+3]) * p.frac00 + + fr += float64(src.Pix[off01+0]) * p.frac01 + fg += float64(src.Pix[off01+1]) * p.frac01 + fb += float64(src.Pix[off01+2]) * p.frac01 + fa += float64(src.Pix[off01+3]) * p.frac01 + + fr += float64(src.Pix[off10+0]) * p.frac10 + fg += float64(src.Pix[off10+1]) * p.frac10 + fb += float64(src.Pix[off10+2]) * p.frac10 + fa += float64(src.Pix[off10+3]) * p.frac10 + + fr += float64(src.Pix[off11+0]) * p.frac11 + fg += float64(src.Pix[off11+1]) * p.frac11 + fb += float64(src.Pix[off11+2]) * p.frac11 + fa += float64(src.Pix[off11+3]) * p.frac11 + + var c color.RGBA + c.R = uint8(fr + 0.5) + c.G = uint8(fg + 0.5) + c.B = uint8(fb + 0.5) + c.A = uint8(fa + 0.5) + return c +} + +func (bilinear) Gray(src *image.Gray, x, y float64) color.Gray { + p := findLinearSrc(src.Bounds(), x, y) + + // Array offsets for the surrounding pixels. + off00 := offGray(src, p.low.X, p.low.Y) + off01 := offGray(src, p.high.X, p.low.Y) + off10 := offGray(src, p.low.X, p.high.Y) + off11 := offGray(src, p.high.X, p.high.Y) + + var fc float64 + fc += float64(src.Pix[off00]) * p.frac00 + fc += float64(src.Pix[off01]) * p.frac01 + fc += float64(src.Pix[off10]) * p.frac10 + fc += float64(src.Pix[off11]) * p.frac11 + + var c color.Gray + c.Y = uint8(fc + 0.5) + return c +} + +type bilinearSrc struct { + // Top-left and bottom-right interpolation sources + low, high image.Point + // Fraction of each pixel to take. The 0 suffix indicates + // top/left, and the 1 suffix indicates bottom/right. + frac00, frac01, frac10, frac11 float64 +} + +func findLinearSrc(b image.Rectangle, sx, sy float64) bilinearSrc { + maxX := float64(b.Max.X) + maxY := float64(b.Max.Y) + minX := float64(b.Min.X) + minY := float64(b.Min.Y) + lowX := math.Floor(sx - 0.5) + lowY := math.Floor(sy - 0.5) + if lowX < minX { + lowX = minX + } + if lowY < minY { + lowY = minY + } + + highX := math.Ceil(sx - 0.5) + highY := math.Ceil(sy - 0.5) + if highX >= maxX { + highX = maxX - 1 + } + if highY >= maxY { + highY = maxY - 1 + } + + // In the variables below, the 0 suffix indicates top/left, and the + // 1 suffix indicates bottom/right. + + // Center of each surrounding pixel. + x00 := lowX + 0.5 + y00 := lowY + 0.5 + x01 := highX + 0.5 + y01 := lowY + 0.5 + x10 := lowX + 0.5 + y10 := highY + 0.5 + x11 := highX + 0.5 + y11 := highY + 0.5 + + p := bilinearSrc{ + low: image.Pt(int(lowX), int(lowY)), + high: image.Pt(int(highX), int(highY)), + } + + // Literally, edge cases. If we are close enough to the edge of + // the image, curtail the interpolation sources. + if lowX == highX && lowY == highY { + p.frac00 = 1.0 + } else if sy-minY <= 0.5 && sx-minX <= 0.5 { + p.frac00 = 1.0 + } else if maxY-sy <= 0.5 && maxX-sx <= 0.5 { + p.frac11 = 1.0 + } else if sy-minY <= 0.5 || lowY == highY { + p.frac00 = x01 - sx + p.frac01 = sx - x00 + } else if sx-minX <= 0.5 || lowX == highX { + p.frac00 = y10 - sy + p.frac10 = sy - y00 + } else if maxY-sy <= 0.5 { + p.frac10 = x11 - sx + p.frac11 = sx - x10 + } else if maxX-sx <= 0.5 { + p.frac01 = y11 - sy + p.frac11 = sy - y01 + } else { + p.frac00 = (x01 - sx) * (y10 - sy) + p.frac01 = (sx - x00) * (y11 - sy) + p.frac10 = (x11 - sx) * (sy - y00) + p.frac11 = (sx - x10) * (sy - y01) + } + + return p +} + +// TODO(crawshaw): When we have inlining, consider func (p *RGBA) Off(x, y) int +func offRGBA(src *image.RGBA, x, y int) int { + return (y-src.Rect.Min.Y)*src.Stride + (x-src.Rect.Min.X)*4 +} +func offGray(src *image.Gray, x, y int) int { + return (y-src.Rect.Min.Y)*src.Stride + (x - src.Rect.Min.X) +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/bilinear_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/bilinear_test.go new file mode 100644 index 000000000..242d70546 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/bilinear_test.go @@ -0,0 +1,143 @@ +// Copyright 2012 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package interp + +import ( + "image" + "image/color" + "testing" +) + +type interpTest struct { + desc string + src []uint8 + srcWidth int + x, y float64 + expect uint8 +} + +func (p *interpTest) newSrc() *image.RGBA { + b := image.Rect(0, 0, p.srcWidth, len(p.src)/p.srcWidth) + src := image.NewRGBA(b) + i := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + src.SetRGBA(x, y, color.RGBA{ + R: p.src[i], + G: p.src[i], + B: p.src[i], + A: 0xff, + }) + i++ + } + } + return src +} + +var interpTests = []interpTest{ + { + desc: "center of a single white pixel should match that pixel", + src: []uint8{0x00}, + srcWidth: 1, + x: 0.5, + y: 0.5, + expect: 0x00, + }, + { + desc: "middle of a square is equally weighted", + src: []uint8{ + 0x00, 0xff, + 0xff, 0x00, + }, + srcWidth: 2, + x: 1.0, + y: 1.0, + expect: 0x80, + }, + { + desc: "center of a pixel is just that pixel", + src: []uint8{ + 0x00, 0xff, + 0xff, 0x00, + }, + srcWidth: 2, + x: 1.5, + y: 0.5, + expect: 0xff, + }, + { + desc: "asymmetry abounds", + src: []uint8{ + 0xaa, 0x11, 0x55, + 0xff, 0x95, 0xdd, + }, + srcWidth: 3, + x: 2.0, + y: 1.0, + expect: 0x76, // (0x11 + 0x55 + 0x95 + 0xdd) / 4 + }, +} + +func TestBilinearRGBA(t *testing.T) { + for _, p := range interpTests { + src := p.newSrc() + + // Fast path. + c := Bilinear.(RGBA).RGBA(src, p.x, p.y) + if c.R != c.G || c.R != c.B || c.A != 0xff { + t.Errorf("expect channels to match, got %v", c) + continue + } + if c.R != p.expect { + t.Errorf("%s: got 0x%02x want 0x%02x", p.desc, c.R, p.expect) + continue + } + + // Standard Interp should use the fast path. + cStd := Bilinear.Interp(src, p.x, p.y) + if cStd != c { + t.Errorf("%s: standard mismatch got %v want %v", p.desc, cStd, c) + continue + } + + // General case should match the fast path. + cGen := color.RGBAModel.Convert(bilinearGeneral(src, p.x, p.y)) + r0, g0, b0, a0 := c.RGBA() + r1, g1, b1, a1 := cGen.RGBA() + if r0 != r1 || g0 != g1 || b0 != b1 || a0 != a1 { + t.Errorf("%s: general case mismatch got %v want %v", p.desc, c, cGen) + continue + } + } +} + +func TestBilinearSubImage(t *testing.T) { + b0 := image.Rect(0, 0, 4, 4) + src0 := image.NewRGBA(b0) + b1 := image.Rect(1, 1, 3, 3) + src1 := src0.SubImage(b1).(*image.RGBA) + src1.Set(1, 1, color.RGBA{0x11, 0, 0, 0xff}) + src1.Set(2, 1, color.RGBA{0x22, 0, 0, 0xff}) + src1.Set(1, 2, color.RGBA{0x33, 0, 0, 0xff}) + src1.Set(2, 2, color.RGBA{0x44, 0, 0, 0xff}) + + tests := []struct { + x, y float64 + want uint8 + }{ + {1, 1, 0x11}, + {3, 1, 0x22}, + {1, 3, 0x33}, + {3, 3, 0x44}, + {2, 2, 0x2b}, + } + + for _, p := range tests { + c := Bilinear.(RGBA).RGBA(src1, p.x, p.y) + if c.R != p.want { + t.Errorf("(%.0f, %.0f): got 0x%02x want 0x%02x", p.x, p.y, c.R, p.want) + } + } +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/doc.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/doc.go new file mode 100644 index 000000000..b115534cc --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/doc.go @@ -0,0 +1,25 @@ +// Copyright 2012 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Package interp implements image interpolation. + +An interpolator provides the Interp interface, which can be used +to interpolate a pixel: + + c := interp.Bilinear.Interp(src, 1.2, 1.8) + +To interpolate a large number of RGBA or Gray pixels, an implementation +may provide a fast-path by implementing the RGBA or Gray interfaces. + + i1, ok := i.(interp.RGBA) + if ok { + c := i1.RGBA(src, 1.2, 1.8) + // use c.R, c.G, etc + return + } + c := i.Interp(src, 1.2, 1.8) + // use generic color.Color +*/ +package interp diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/interp.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/interp.go new file mode 100644 index 000000000..560637d4a --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/interp/interp.go @@ -0,0 +1,29 @@ +// Copyright 2012 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package interp + +import ( + "image" + "image/color" +) + +// Interp interpolates an image's color at fractional co-ordinates. +type Interp interface { + // Interp interpolates (x, y). + Interp(src image.Image, x, y float64) color.Color +} + +// RGBA is a fast-path interpolation implementation for image.RGBA. +// It is common for an Interp to also implement RGBA. +type RGBA interface { + // RGBA interpolates (x, y). + RGBA(src *image.RGBA, x, y float64) color.RGBA +} + +// Gray is a fast-path interpolation implementation for image.Gray. +type Gray interface { + // Gray interpolates (x, y). + Gray(src *image.Gray, x, y float64) color.Gray +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/rotate.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/rotate.go new file mode 100644 index 000000000..62bde1a08 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/rotate.go @@ -0,0 +1,35 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "code.google.com/p/graphics-go/graphics/interp" + "errors" + "image" + "image/draw" +) + +// RotateOptions are the rotation parameters. +// Angle is the angle, in radians, to rotate the image clockwise. +type RotateOptions struct { + Angle float64 +} + +// Rotate produces a rotated version of src, drawn onto dst. +func Rotate(dst draw.Image, src image.Image, opt *RotateOptions) error { + if dst == nil { + return errors.New("graphics: dst is nil") + } + if src == nil { + return errors.New("graphics: src is nil") + } + + angle := 0.0 + if opt != nil { + angle = opt.Angle + } + + return I.Rotate(angle).TransformCenter(dst, src, interp.Bilinear) +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/rotate_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/rotate_test.go new file mode 100644 index 000000000..bfc532a0a --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/rotate_test.go @@ -0,0 +1,169 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "code.google.com/p/graphics-go/graphics/graphicstest" + "image" + "math" + "testing" + + _ "image/png" +) + +var rotateOneColorTests = []transformOneColorTest{ + { + "onepixel-onequarter", 1, 1, 1, 1, + &RotateOptions{math.Pi / 2}, + []uint8{0xff}, + []uint8{0xff}, + }, + { + "onepixel-partial", 1, 1, 1, 1, + &RotateOptions{math.Pi * 2.0 / 3.0}, + []uint8{0xff}, + []uint8{0xff}, + }, + { + "onepixel-complete", 1, 1, 1, 1, + &RotateOptions{2 * math.Pi}, + []uint8{0xff}, + []uint8{0xff}, + }, + { + "even-onequarter", 2, 2, 2, 2, + &RotateOptions{math.Pi / 2.0}, + []uint8{ + 0xff, 0x00, + 0x00, 0xff, + }, + []uint8{ + 0x00, 0xff, + 0xff, 0x00, + }, + }, + { + "even-complete", 2, 2, 2, 2, + &RotateOptions{2.0 * math.Pi}, + []uint8{ + 0xff, 0x00, + 0x00, 0xff, + }, + []uint8{ + 0xff, 0x00, + 0x00, 0xff, + }, + }, + { + "line-partial", 3, 3, 3, 3, + &RotateOptions{math.Pi * 1.0 / 3.0}, + []uint8{ + 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, + }, + []uint8{ + 0xa2, 0x80, 0x00, + 0x22, 0xff, 0x22, + 0x00, 0x80, 0xa2, + }, + }, + { + "line-offset-partial", 3, 3, 3, 3, + &RotateOptions{math.Pi * 3 / 2}, + []uint8{ + 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, + 0x00, 0x00, 0x00, + }, + []uint8{ + 0x00, 0xff, 0x00, + 0x00, 0xff, 0x00, + 0x00, 0x00, 0x00, + }, + }, + { + "dot-partial", 4, 4, 4, 4, + &RotateOptions{math.Pi}, + []uint8{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + }, + []uint8{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0x00, + 0x00, 0x00, 0x00, 0x00, + }, + }, +} + +func TestRotateOneColor(t *testing.T) { + for _, oc := range rotateOneColorTests { + src := oc.newSrc() + dst := oc.newDst() + + if err := Rotate(dst, src, oc.opt.(*RotateOptions)); err != nil { + t.Errorf("rotate %s: %v", oc.desc, err) + continue + } + if !checkTransformTest(t, &oc, dst) { + continue + } + } +} + +func TestRotateEmpty(t *testing.T) { + empty := image.NewRGBA(image.Rect(0, 0, 0, 0)) + if err := Rotate(empty, empty, nil); err != nil { + t.Fatal(err) + } +} + +func TestRotateGopherSide(t *testing.T) { + src, err := graphicstest.LoadImage("../testdata/gopher.png") + if err != nil { + t.Fatal(err) + } + + srcb := src.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, srcb.Dy(), srcb.Dx())) + if err := Rotate(dst, src, &RotateOptions{math.Pi / 2.0}); err != nil { + t.Fatal(err) + } + + cmp, err := graphicstest.LoadImage("../testdata/gopher-rotate-side.png") + if err != nil { + t.Fatal(err) + } + err = graphicstest.ImageWithinTolerance(dst, cmp, 0x101) + if err != nil { + t.Fatal(err) + } +} + +func TestRotateGopherPartial(t *testing.T) { + src, err := graphicstest.LoadImage("../testdata/gopher.png") + if err != nil { + t.Fatal(err) + } + + srcb := src.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, srcb.Dx(), srcb.Dy())) + if err := Rotate(dst, src, &RotateOptions{math.Pi / 3.0}); err != nil { + t.Fatal(err) + } + + cmp, err := graphicstest.LoadImage("../testdata/gopher-rotate-partial.png") + if err != nil { + t.Fatal(err) + } + err = graphicstest.ImageWithinTolerance(dst, cmp, 0x101) + if err != nil { + t.Fatal(err) + } +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/scale.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/scale.go new file mode 100644 index 000000000..7a7fe9696 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/scale.go @@ -0,0 +1,31 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "code.google.com/p/graphics-go/graphics/interp" + "errors" + "image" + "image/draw" +) + +// Scale produces a scaled version of the image using bilinear interpolation. +func Scale(dst draw.Image, src image.Image) error { + if dst == nil { + return errors.New("graphics: dst is nil") + } + if src == nil { + return errors.New("graphics: src is nil") + } + + b := dst.Bounds() + srcb := src.Bounds() + if b.Empty() || srcb.Empty() { + return nil + } + sx := float64(b.Dx()) / float64(srcb.Dx()) + sy := float64(b.Dy()) / float64(srcb.Dy()) + return I.Scale(sx, sy).Transform(dst, src, interp.Bilinear) +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/scale_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/scale_test.go new file mode 100644 index 000000000..9c2468f11 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/scale_test.go @@ -0,0 +1,153 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "code.google.com/p/graphics-go/graphics/graphicstest" + "image" + "testing" + + _ "image/png" +) + +var scaleOneColorTests = []transformOneColorTest{ + { + "down-half", + 1, 1, + 2, 2, + nil, + []uint8{ + 0x80, 0x00, + 0x00, 0x80, + }, + []uint8{ + 0x40, + }, + }, + { + "up-double", + 4, 4, + 2, 2, + nil, + []uint8{ + 0x80, 0x00, + 0x00, 0x80, + }, + []uint8{ + 0x80, 0x60, 0x20, 0x00, + 0x60, 0x50, 0x30, 0x20, + 0x20, 0x30, 0x50, 0x60, + 0x00, 0x20, 0x60, 0x80, + }, + }, + { + "up-doublewidth", + 4, 2, + 2, 2, + nil, + []uint8{ + 0x80, 0x00, + 0x00, 0x80, + }, + []uint8{ + 0x80, 0x60, 0x20, 0x00, + 0x00, 0x20, 0x60, 0x80, + }, + }, + { + "up-doubleheight", + 2, 4, + 2, 2, + nil, + []uint8{ + 0x80, 0x00, + 0x00, 0x80, + }, + []uint8{ + 0x80, 0x00, + 0x60, 0x20, + 0x20, 0x60, + 0x00, 0x80, + }, + }, + { + "up-partial", + 3, 3, + 2, 2, + nil, + []uint8{ + 0x80, 0x00, + 0x00, 0x80, + }, + []uint8{ + 0x80, 0x40, 0x00, + 0x40, 0x40, 0x40, + 0x00, 0x40, 0x80, + }, + }, +} + +func TestScaleOneColor(t *testing.T) { + for _, oc := range scaleOneColorTests { + dst := oc.newDst() + src := oc.newSrc() + if err := Scale(dst, src); err != nil { + t.Errorf("scale %s: %v", oc.desc, err) + continue + } + + if !checkTransformTest(t, &oc, dst) { + continue + } + } +} + +func TestScaleEmpty(t *testing.T) { + empty := image.NewRGBA(image.Rect(0, 0, 0, 0)) + if err := Scale(empty, empty); err != nil { + t.Fatal(err) + } +} + +func TestScaleGopher(t *testing.T) { + dst := image.NewRGBA(image.Rect(0, 0, 100, 150)) + + src, err := graphicstest.LoadImage("../testdata/gopher.png") + if err != nil { + t.Error(err) + return + } + + // Down-sample. + if err := Scale(dst, src); err != nil { + t.Fatal(err) + } + cmp, err := graphicstest.LoadImage("../testdata/gopher-100x150.png") + if err != nil { + t.Error(err) + return + } + err = graphicstest.ImageWithinTolerance(dst, cmp, 0) + if err != nil { + t.Error(err) + return + } + + // Up-sample. + dst = image.NewRGBA(image.Rect(0, 0, 500, 750)) + if err := Scale(dst, src); err != nil { + t.Fatal(err) + } + cmp, err = graphicstest.LoadImage("../testdata/gopher-500x750.png") + if err != nil { + t.Error(err) + return + } + err = graphicstest.ImageWithinTolerance(dst, cmp, 0) + if err != nil { + t.Error(err) + return + } +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/shared_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/shared_test.go new file mode 100644 index 000000000..e1cd21fb3 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/shared_test.go @@ -0,0 +1,69 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "bytes" + "code.google.com/p/graphics-go/graphics/graphicstest" + "image" + "image/color" + "testing" +) + +type transformOneColorTest struct { + desc string + dstWidth int + dstHeight int + srcWidth int + srcHeight int + opt interface{} + src []uint8 + res []uint8 +} + +func (oc *transformOneColorTest) newSrc() *image.RGBA { + b := image.Rect(0, 0, oc.srcWidth, oc.srcHeight) + src := image.NewRGBA(b) + i := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + src.SetRGBA(x, y, color.RGBA{ + R: oc.src[i], + G: oc.src[i], + B: oc.src[i], + A: oc.src[i], + }) + i++ + } + } + return src +} + +func (oc *transformOneColorTest) newDst() *image.RGBA { + return image.NewRGBA(image.Rect(0, 0, oc.dstWidth, oc.dstHeight)) +} + +func checkTransformTest(t *testing.T, oc *transformOneColorTest, dst *image.RGBA) bool { + for ch := 0; ch < 4; ch++ { + i := 0 + res := make([]byte, len(oc.res)) + for y := 0; y < oc.dstHeight; y++ { + for x := 0; x < oc.dstWidth; x++ { + off := (y-dst.Rect.Min.Y)*dst.Stride + (x-dst.Rect.Min.X)*4 + res[i] = dst.Pix[off+ch] + i++ + } + } + + if !bytes.Equal(res, oc.res) { + got := graphicstest.SprintBox(res, oc.dstWidth, oc.dstHeight) + want := graphicstest.SprintBox(oc.res, oc.dstWidth, oc.dstHeight) + t.Errorf("%s: ch=%d\n got\n%s\n want\n%s", oc.desc, ch, got, want) + return false + } + } + + return true +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/thumbnail.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/thumbnail.go new file mode 100644 index 000000000..d3ad7e8f7 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/thumbnail.go @@ -0,0 +1,41 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "image" + "image/draw" +) + +// Thumbnail scales and crops src so it fits in dst. +func Thumbnail(dst draw.Image, src image.Image) error { + // Scale down src in the dimension that is closer to dst. + sb := src.Bounds() + db := dst.Bounds() + rx := float64(sb.Dx()) / float64(db.Dx()) + ry := float64(sb.Dy()) / float64(db.Dy()) + var b image.Rectangle + if rx < ry { + b = image.Rect(0, 0, db.Dx(), int(float64(sb.Dy())/rx)) + } else { + b = image.Rect(0, 0, int(float64(sb.Dx())/ry), db.Dy()) + } + + buf := image.NewRGBA(b) + if err := Scale(buf, src); err != nil { + return err + } + + // Crop. + // TODO(crawshaw): improve on center-alignment. + var pt image.Point + if rx < ry { + pt.Y = (b.Dy() - db.Dy()) / 2 + } else { + pt.X = (b.Dx() - db.Dx()) / 2 + } + draw.Draw(dst, db, buf, pt, draw.Src) + return nil +} diff --git a/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/thumbnail_test.go b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/thumbnail_test.go new file mode 100644 index 000000000..d12659f17 --- /dev/null +++ b/Godeps/_workspace/src/code.google.com/p/graphics-go/graphics/thumbnail_test.go @@ -0,0 +1,53 @@ +// Copyright 2011 The Graphics-Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package graphics + +import ( + "code.google.com/p/graphics-go/graphics/graphicstest" + "image" + "testing" + + _ "image/png" +) + +func TestThumbnailGopher(t *testing.T) { + dst := image.NewRGBA(image.Rect(0, 0, 80, 80)) + + src, err := graphicstest.LoadImage("../testdata/gopher.png") + if err != nil { + t.Fatal(err) + } + if err := Thumbnail(dst, src); err != nil { + t.Fatal(err) + } + cmp, err := graphicstest.LoadImage("../testdata/gopher-thumb-80x80.png") + if err != nil { + t.Fatal(err) + } + err = graphicstest.ImageWithinTolerance(dst, cmp, 0) + if err != nil { + t.Error(err) + } +} + +func TestThumbnailLongGopher(t *testing.T) { + dst := image.NewRGBA(image.Rect(0, 0, 50, 150)) + + src, err := graphicstest.LoadImage("../testdata/gopher.png") + if err != nil { + t.Fatal(err) + } + if err := Thumbnail(dst, src); err != nil { + t.Fatal(err) + } + cmp, err := graphicstest.LoadImage("../testdata/gopher-thumb-50x150.png") + if err != nil { + t.Fatal(err) + } + err = graphicstest.ImageWithinTolerance(dst, cmp, 0) + if err != nil { + t.Error(err) + } +} diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/README.md b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/README.md new file mode 100644 index 000000000..b3bf5fa0e --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/README.md @@ -0,0 +1,4 @@ + +To regenerate the regression test data, run `go generate` inside the exif +package directory and commit the changes to *regress_expected_test.go*. + diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/example_test.go b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/example_test.go new file mode 100644 index 000000000..45fd5d4ad --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/example_test.go @@ -0,0 +1,42 @@ +package exif_test + +import ( + "fmt" + "log" + "os" + + "github.com/rwcarlsen/goexif/exif" + "github.com/rwcarlsen/goexif/mknote" +) + +func ExampleDecode() { + fname := "sample1.jpg" + + f, err := os.Open(fname) + if err != nil { + log.Fatal(err) + } + + // Optionally register camera makenote data parsing - currently Nikon and + // Canon are supported. + exif.RegisterParsers(mknote.All...) + + x, err := exif.Decode(f) + if err != nil { + log.Fatal(err) + } + + camModel, _ := x.Get(exif.Model) // normally, don't ignore errors! + fmt.Println(camModel.StringVal()) + + focal, _ := x.Get(exif.FocalLength) + numer, denom, _ := focal.Rat2(0) // retrieve first (only) rat. value + fmt.Printf("%v/%v", numer, denom) + + // Two convenience functions exist for date/time taken and GPS coords: + tm, _ := x.DateTime() + fmt.Println("Taken: ", tm) + + lat, long, _ := x.LatLong() + fmt.Println("lat, long: ", lat, ", ", long) +} diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/exif.go b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/exif.go new file mode 100644 index 000000000..b420729da --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/exif.go @@ -0,0 +1,619 @@ +// Package exif implements decoding of EXIF data as defined in the EXIF 2.2 +// specification (http://www.exif.org/Exif2-2.PDF). +package exif + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "math" + "strconv" + "strings" + "time" + + "github.com/rwcarlsen/goexif/tiff" +) + +const ( + jpeg_APP1 = 0xE1 + + exifPointer = 0x8769 + gpsPointer = 0x8825 + interopPointer = 0xA005 +) + +// A decodeError is returned when the image cannot be decoded as a tiff image. +type decodeError struct { + cause error +} + +func (de decodeError) Error() string { + return fmt.Sprintf("exif: decode failed (%v) ", de.cause.Error()) +} + +// IsShortReadTagValueError identifies a ErrShortReadTagValue error. +func IsShortReadTagValueError(err error) bool { + de, ok := err.(decodeError) + if ok { + return de.cause == tiff.ErrShortReadTagValue + } + return false +} + +// A TagNotPresentError is returned when the requested field is not +// present in the EXIF. +type TagNotPresentError FieldName + +func (tag TagNotPresentError) Error() string { + return fmt.Sprintf("exif: tag %q is not present", string(tag)) +} + +func IsTagNotPresentError(err error) bool { + _, ok := err.(TagNotPresentError) + return ok +} + +// Parser allows the registration of custom parsing and field loading +// in the Decode function. +type Parser interface { + // Parse should read data from x and insert parsed fields into x via + // LoadTags. + Parse(x *Exif) error +} + +var parsers []Parser + +func init() { + RegisterParsers(&parser{}) +} + +// RegisterParsers registers one or more parsers to be automatically called +// when decoding EXIF data via the Decode function. +func RegisterParsers(ps ...Parser) { + parsers = append(parsers, ps...) +} + +type parser struct{} + +type tiffErrors map[tiffError]string + +func (te tiffErrors) Error() string { + var allErrors []string + for k, v := range te { + allErrors = append(allErrors, fmt.Sprintf("%s: %v\n", stagePrefix[k], v)) + } + return strings.Join(allErrors, "\n") +} + +// IsCriticalError, given the error returned by Decode, reports whether the +// returned *Exif may contain usable information. +func IsCriticalError(err error) bool { + _, ok := err.(tiffErrors) + return !ok +} + +// IsExifError reports whether the error happened while decoding the EXIF +// sub-IFD. +func IsExifError(err error) bool { + if te, ok := err.(tiffErrors); ok { + _, isExif := te[loadExif] + return isExif + } + return false +} + +// IsGPSError reports whether the error happened while decoding the GPS sub-IFD. +func IsGPSError(err error) bool { + if te, ok := err.(tiffErrors); ok { + _, isGPS := te[loadExif] + return isGPS + } + return false +} + +// IsInteroperabilityError reports whether the error happened while decoding the +// Interoperability sub-IFD. +func IsInteroperabilityError(err error) bool { + if te, ok := err.(tiffErrors); ok { + _, isInterop := te[loadInteroperability] + return isInterop + } + return false +} + +type tiffError int + +const ( + loadExif tiffError = iota + loadGPS + loadInteroperability +) + +var stagePrefix = map[tiffError]string{ + loadExif: "loading EXIF sub-IFD", + loadGPS: "loading GPS sub-IFD", + loadInteroperability: "loading Interoperability sub-IFD", +} + +// Parse reads data from the tiff data in x and populates the tags +// in x. If parsing a sub-IFD fails, the error is recorded and +// parsing continues with the remaining sub-IFDs. +func (p *parser) Parse(x *Exif) error { + x.LoadTags(x.Tiff.Dirs[0], exifFields, false) + + // thumbnails + if len(x.Tiff.Dirs) >= 2 { + x.LoadTags(x.Tiff.Dirs[1], thumbnailFields, false) + } + + te := make(tiffErrors) + + // recurse into exif, gps, and interop sub-IFDs + if err := loadSubDir(x, ExifIFDPointer, exifFields); err != nil { + te[loadExif] = err.Error() + } + if err := loadSubDir(x, GPSInfoIFDPointer, gpsFields); err != nil { + te[loadGPS] = err.Error() + } + + if err := loadSubDir(x, InteroperabilityIFDPointer, interopFields); err != nil { + te[loadInteroperability] = err.Error() + } + if len(te) > 0 { + return te + } + return nil +} + +func loadSubDir(x *Exif, ptr FieldName, fieldMap map[uint16]FieldName) error { + r := bytes.NewReader(x.Raw) + + tag, err := x.Get(ptr) + if err != nil { + return nil + } + offset, err := tag.Int64(0) + if err != nil { + return nil + } + + _, err = r.Seek(offset, 0) + if err != nil { + return fmt.Errorf("exif: seek to sub-IFD %s failed: %v", ptr, err) + } + subDir, _, err := tiff.DecodeDir(r, x.Tiff.Order) + if err != nil { + return fmt.Errorf("exif: sub-IFD %s decode failed: %v", ptr, err) + } + x.LoadTags(subDir, fieldMap, false) + return nil +} + +// Exif provides access to decoded EXIF metadata fields and values. +type Exif struct { + Tiff *tiff.Tiff + main map[FieldName]*tiff.Tag + Raw []byte +} + +// Decode parses EXIF-encoded data from r and returns a queryable Exif +// object. After the exif data section is called and the tiff structure +// decoded, each registered parser is called (in order of registration). If +// one parser returns an error, decoding terminates and the remaining +// parsers are not called. +// The error can be inspected with functions such as IsCriticalError to +// determine whether the returned object might still be usable. +func Decode(r io.Reader) (*Exif, error) { + // EXIF data in JPEG is stored in the APP1 marker. EXIF data uses the TIFF + // format to store data. + // If we're parsing a TIFF image, we don't need to strip away any data. + // If we're parsing a JPEG image, we need to strip away the JPEG APP1 + // marker and also the EXIF header. + + header := make([]byte, 4) + n, err := r.Read(header) + if err != nil { + return nil, err + } + if n < len(header) { + return nil, errors.New("exif: short read on header") + } + + var isTiff bool + switch string(header) { + case "II*\x00": + // TIFF - Little endian (Intel) + isTiff = true + case "MM\x00*": + // TIFF - Big endian (Motorola) + isTiff = true + default: + // Not TIFF, assume JPEG + } + + // Put the header bytes back into the reader. + r = io.MultiReader(bytes.NewReader(header), r) + var ( + er *bytes.Reader + tif *tiff.Tiff + ) + + if isTiff { + // Functions below need the IFDs from the TIFF data to be stored in a + // *bytes.Reader. We use TeeReader to get a copy of the bytes as a + // side-effect of tiff.Decode() doing its work. + b := &bytes.Buffer{} + tr := io.TeeReader(r, b) + tif, err = tiff.Decode(tr) + er = bytes.NewReader(b.Bytes()) + } else { + // Locate the JPEG APP1 header. + var sec *appSec + sec, err = newAppSec(jpeg_APP1, r) + if err != nil { + return nil, err + } + // Strip away EXIF header. + er, err = sec.exifReader() + if err != nil { + return nil, err + } + tif, err = tiff.Decode(er) + } + + if err != nil { + return nil, decodeError{cause: err} + } + + er.Seek(0, 0) + raw, err := ioutil.ReadAll(er) + if err != nil { + return nil, decodeError{cause: err} + } + + // build an exif structure from the tiff + x := &Exif{ + main: map[FieldName]*tiff.Tag{}, + Tiff: tif, + Raw: raw, + } + + for i, p := range parsers { + if err := p.Parse(x); err != nil { + if _, ok := err.(tiffErrors); ok { + return x, err + } + // This should never happen, as Parse always returns a tiffError + // for now, but that could change. + return x, fmt.Errorf("exif: parser %v failed (%v)", i, err) + } + } + + return x, nil +} + +// LoadTags loads tags into the available fields from the tiff Directory +// using the given tagid-fieldname mapping. Used to load makernote and +// other meta-data. If showMissing is true, tags in d that are not in the +// fieldMap will be loaded with the FieldName UnknownPrefix followed by the +// tag ID (in hex format). +func (x *Exif) LoadTags(d *tiff.Dir, fieldMap map[uint16]FieldName, showMissing bool) { + for _, tag := range d.Tags { + name := fieldMap[tag.Id] + if name == "" { + if !showMissing { + continue + } + name = FieldName(fmt.Sprintf("%v%x", UnknownPrefix, tag.Id)) + } + x.main[name] = tag + } +} + +// Get retrieves the EXIF tag for the given field name. +// +// If the tag is not known or not present, an error is returned. If the +// tag name is known, the error will be a TagNotPresentError. +func (x *Exif) Get(name FieldName) (*tiff.Tag, error) { + if tg, ok := x.main[name]; ok { + return tg, nil + } + return nil, TagNotPresentError(name) +} + +// Walker is the interface used to traverse all fields of an Exif object. +type Walker interface { + // Walk is called for each non-nil EXIF field. Returning a non-nil + // error aborts the walk/traversal. + Walk(name FieldName, tag *tiff.Tag) error +} + +// Walk calls the Walk method of w with the name and tag for every non-nil +// EXIF field. If w aborts the walk with an error, that error is returned. +func (x *Exif) Walk(w Walker) error { + for name, tag := range x.main { + if err := w.Walk(name, tag); err != nil { + return err + } + } + return nil +} + +// DateTime returns the EXIF's "DateTimeOriginal" field, which +// is the creation time of the photo. If not found, it tries +// the "DateTime" (which is meant as the modtime) instead. +// The error will be TagNotPresentErr if none of those tags +// were found, or a generic error if the tag value was +// not a string, or the error returned by time.Parse. +// +// If the EXIF lacks timezone information or GPS time, the returned +// time's Location will be time.Local. +func (x *Exif) DateTime() (time.Time, error) { + var dt time.Time + tag, err := x.Get(DateTimeOriginal) + if err != nil { + tag, err = x.Get(DateTime) + if err != nil { + return dt, err + } + } + if tag.Format() != tiff.StringVal { + return dt, errors.New("DateTime[Original] not in string format") + } + exifTimeLayout := "2006:01:02 15:04:05" + dateStr := strings.TrimRight(string(tag.Val), "\x00") + // TODO(bradfitz,mpl): look for timezone offset, GPS time, etc. + // For now, just always return the time.Local timezone. + return time.ParseInLocation(exifTimeLayout, dateStr, time.Local) +} + +func ratFloat(num, dem int64) float64 { + return float64(num) / float64(dem) +} + +// Tries to parse a Geo degrees value from a string as it was found in some +// EXIF data. +// Supported formats so far: +// - "52,00000,50,00000,34,01180" ==> 52 deg 50'34.0118" +// Probably due to locale the comma is used as decimal mark as well as the +// separator of three floats (degrees, minutes, seconds) +// http://en.wikipedia.org/wiki/Decimal_mark#Hindu.E2.80.93Arabic_numeral_system +// - "52.0,50.0,34.01180" ==> 52deg50'34.0118" +// - "52,50,34.01180" ==> 52deg50'34.0118" +func parseTagDegreesString(s string) (float64, error) { + const unparsableErrorFmt = "Unknown coordinate format: %s" + isSplitRune := func(c rune) bool { + return c == ',' || c == ';' + } + parts := strings.FieldsFunc(s, isSplitRune) + var degrees, minutes, seconds float64 + var err error + switch len(parts) { + case 6: + degrees, err = strconv.ParseFloat(parts[0]+"."+parts[1], 64) + if err != nil { + return 0.0, fmt.Errorf(unparsableErrorFmt, s) + } + minutes, err = strconv.ParseFloat(parts[2]+"."+parts[3], 64) + if err != nil { + return 0.0, fmt.Errorf(unparsableErrorFmt, s) + } + minutes = math.Copysign(minutes, degrees) + seconds, err = strconv.ParseFloat(parts[4]+"."+parts[5], 64) + if err != nil { + return 0.0, fmt.Errorf(unparsableErrorFmt, s) + } + seconds = math.Copysign(seconds, degrees) + case 3: + degrees, err = strconv.ParseFloat(parts[0], 64) + if err != nil { + return 0.0, fmt.Errorf(unparsableErrorFmt, s) + } + minutes, err = strconv.ParseFloat(parts[1], 64) + if err != nil { + return 0.0, fmt.Errorf(unparsableErrorFmt, s) + } + minutes = math.Copysign(minutes, degrees) + seconds, err = strconv.ParseFloat(parts[2], 64) + if err != nil { + return 0.0, fmt.Errorf(unparsableErrorFmt, s) + } + seconds = math.Copysign(seconds, degrees) + default: + return 0.0, fmt.Errorf(unparsableErrorFmt, s) + } + return degrees + minutes/60.0 + seconds/3600.0, nil +} + +func parse3Rat2(tag *tiff.Tag) ([3]float64, error) { + v := [3]float64{} + for i := range v { + num, den, err := tag.Rat2(i) + if err != nil { + return v, err + } + v[i] = ratFloat(num, den) + if tag.Count < uint32(i+2) { + break + } + } + return v, nil +} + +func tagDegrees(tag *tiff.Tag) (float64, error) { + switch tag.Format() { + case tiff.RatVal: + // The usual case, according to the Exif spec + // (http://www.kodak.com/global/plugins/acrobat/en/service/digCam/exifStandard2.pdf, + // sec 4.6.6, p. 52 et seq.) + v, err := parse3Rat2(tag) + if err != nil { + return 0.0, err + } + return v[0] + v[1]/60 + v[2]/3600.0, nil + case tiff.StringVal: + // Encountered this weird case with a panorama picture taken with a HTC phone + s, err := tag.StringVal() + if err != nil { + return 0.0, err + } + return parseTagDegreesString(s) + default: + // don't know how to parse value, give up + return 0.0, fmt.Errorf("Malformed EXIF Tag Degrees") + } +} + +// LatLong returns the latitude and longitude of the photo and +// whether it was present. +func (x *Exif) LatLong() (lat, long float64, err error) { + // All calls of x.Get might return an TagNotPresentError + longTag, err := x.Get(FieldName("GPSLongitude")) + if err != nil { + return + } + ewTag, err := x.Get(FieldName("GPSLongitudeRef")) + if err != nil { + return + } + latTag, err := x.Get(FieldName("GPSLatitude")) + if err != nil { + return + } + nsTag, err := x.Get(FieldName("GPSLatitudeRef")) + if err != nil { + return + } + if long, err = tagDegrees(longTag); err != nil { + return 0, 0, fmt.Errorf("Cannot parse longitude: %v", err) + } + if lat, err = tagDegrees(latTag); err != nil { + return 0, 0, fmt.Errorf("Cannot parse latitude: %v", err) + } + ew, err := ewTag.StringVal() + if err == nil && ew == "W" { + long *= -1.0 + } else if err != nil { + return 0, 0, fmt.Errorf("Cannot parse longitude: %v", err) + } + ns, err := nsTag.StringVal() + if err == nil && ns == "S" { + lat *= -1.0 + } else if err != nil { + return 0, 0, fmt.Errorf("Cannot parse longitude: %v", err) + } + return lat, long, nil +} + +// String returns a pretty text representation of the decoded exif data. +func (x *Exif) String() string { + var buf bytes.Buffer + for name, tag := range x.main { + fmt.Fprintf(&buf, "%s: %s\n", name, tag) + } + return buf.String() +} + +// JpegThumbnail returns the jpeg thumbnail if it exists. If it doesn't exist, +// TagNotPresentError will be returned +func (x *Exif) JpegThumbnail() ([]byte, error) { + offset, err := x.Get(ThumbJPEGInterchangeFormat) + if err != nil { + return nil, err + } + start, err := offset.Int(0) + if err != nil { + return nil, err + } + + length, err := x.Get(ThumbJPEGInterchangeFormatLength) + if err != nil { + return nil, err + } + l, err := length.Int(0) + if err != nil { + return nil, err + } + + return x.Raw[start : start+l], nil +} + +// MarshalJson implements the encoding/json.Marshaler interface providing output of +// all EXIF fields present (names and values). +func (x Exif) MarshalJSON() ([]byte, error) { + return json.Marshal(x.main) +} + +type appSec struct { + marker byte + data []byte +} + +// newAppSec finds marker in r and returns the corresponding application data +// section. +func newAppSec(marker byte, r io.Reader) (*appSec, error) { + br := bufio.NewReader(r) + app := &appSec{marker: marker} + var dataLen int + + // seek to marker + for dataLen == 0 { + if _, err := br.ReadBytes(0xFF); err != nil { + return nil, err + } + c, err := br.ReadByte() + if err != nil { + return nil, err + } else if c != marker { + continue + } + + dataLenBytes := make([]byte, 2) + for k,_ := range dataLenBytes { + c, err := br.ReadByte() + if err != nil { + return nil, err + } + dataLenBytes[k] = c + } + dataLen = int(binary.BigEndian.Uint16(dataLenBytes)) - 2 + } + + // read section data + nread := 0 + for nread < dataLen { + s := make([]byte, dataLen-nread) + n, err := br.Read(s) + nread += n + if err != nil && nread < dataLen { + return nil, err + } + app.data = append(app.data, s[:n]...) + } + return app, nil +} + +// reader returns a reader on this appSec. +func (app *appSec) reader() *bytes.Reader { + return bytes.NewReader(app.data) +} + +// exifReader returns a reader on this appSec with the read cursor advanced to +// the start of the exif's tiff encoded portion. +func (app *appSec) exifReader() (*bytes.Reader, error) { + if len(app.data) < 6 { + return nil, errors.New("exif: failed to find exif intro marker") + } + + // read/check for exif special mark + exif := app.data[:6] + if !bytes.Equal(exif, append([]byte("Exif"), 0x00, 0x00)) { + return nil, errors.New("exif: failed to find exif intro marker") + } + return bytes.NewReader(app.data[6:]), nil +} diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/exif_test.go b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/exif_test.go new file mode 100644 index 000000000..c53f1ddda --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/exif_test.go @@ -0,0 +1,202 @@ +package exif + +//go:generate go run regen_regress.go -- regress_expected_test.go +//go:generate go fmt regress_expected_test.go + +import ( + "flag" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rwcarlsen/goexif/tiff" +) + +var dataDir = flag.String("test_data_dir", ".", "Directory where the data files for testing are located") + +func TestDecode(t *testing.T) { + fpath := filepath.Join(*dataDir, "samples") + f, err := os.Open(fpath) + if err != nil { + t.Fatalf("Could not open sample directory '%s': %v", fpath, err) + } + + names, err := f.Readdirnames(0) + if err != nil { + t.Fatalf("Could not read sample directory '%s': %v", fpath, err) + } + + cnt := 0 + for _, name := range names { + if !strings.HasSuffix(name, ".jpg") { + t.Logf("skipping non .jpg file %v", name) + continue + } + t.Logf("testing file %v", name) + f, err := os.Open(filepath.Join(fpath, name)) + if err != nil { + t.Fatal(err) + } + + x, err := Decode(f) + if err != nil { + t.Fatal(err) + } else if x == nil { + t.Fatalf("No error and yet %v was not decoded", name) + } + + t.Logf("checking pic %v", name) + x.Walk(&walker{name, t}) + cnt++ + } + if cnt != len(regressExpected) { + t.Errorf("Did not process enough samples, got %d, want %d", cnt, len(regressExpected)) + } +} + +type walker struct { + picName string + t *testing.T +} + +func (w *walker) Walk(field FieldName, tag *tiff.Tag) error { + // this needs to be commented out when regenerating regress expected vals + pic := regressExpected[w.picName] + if pic == nil { + w.t.Errorf(" regression data not found") + return nil + } + + exp, ok := pic[field] + if !ok { + w.t.Errorf(" regression data does not have field %v", field) + return nil + } + + s := tag.String() + if tag.Count == 1 && s != "\"\"" { + s = fmt.Sprintf("[%s]", s) + } + got := tag.String() + + if exp != got { + fmt.Println("s: ", s) + fmt.Printf("len(s)=%v\n", len(s)) + w.t.Errorf(" field %v bad tag: expected '%s', got '%s'", field, exp, got) + } + return nil +} + +func TestMarshal(t *testing.T) { + name := filepath.Join(*dataDir, "sample1.jpg") + f, err := os.Open(name) + if err != nil { + t.Fatalf("%v\n", err) + } + defer f.Close() + + x, err := Decode(f) + if err != nil { + t.Fatal(err) + } + if x == nil { + t.Fatal("bad err") + } + + b, err := x.MarshalJSON() + if err != nil { + t.Fatal(err) + } + + t.Logf("%s", b) +} + +func testSingleParseDegreesString(t *testing.T, s string, w float64) { + g, err := parseTagDegreesString(s) + if err != nil { + t.Fatal(err) + } + if math.Abs(w-g) > 1e-10 { + t.Errorf("Wrong parsing result %s: Want %.12f, got %.12f", s, w, g) + } +} + +func TestParseTagDegreesString(t *testing.T) { + // semicolon as decimal mark + testSingleParseDegreesString(t, "52,00000,50,00000,34,01180", 52.842781055556) // comma as separator + testSingleParseDegreesString(t, "52,00000;50,00000;34,01180", 52.842781055556) // semicolon as separator + + // point as decimal mark + testSingleParseDegreesString(t, "14.00000,44.00000,34.01180", 14.742781055556) // comma as separator + testSingleParseDegreesString(t, "14.00000;44.00000;34.01180", 14.742781055556) // semicolon as separator + testSingleParseDegreesString(t, "14.00000;44.00000,34.01180", 14.742781055556) // mixed separators + + testSingleParseDegreesString(t, "-008.0,30.0,03.6", -8.501) // leading zeros + + // no decimal places + testSingleParseDegreesString(t, "-10,15,54", -10.265) + testSingleParseDegreesString(t, "-10;15;54", -10.265) + + // incorrect mix of comma and point as decimal mark + s := "-17,00000,15.00000,04.80000" + if _, err := parseTagDegreesString(s); err == nil { + t.Error("parseTagDegreesString: false positive for " + s) + } +} + +// Make sure we error out early when a tag had a count of MaxUint32 +func TestMaxUint32CountError(t *testing.T) { + name := filepath.Join(*dataDir, "corrupt/max_uint32_exif.jpg") + f, err := os.Open(name) + if err != nil { + t.Fatalf("%v\n", err) + } + defer f.Close() + + _, err = Decode(f) + if err == nil { + t.Fatal("no error on bad exif data") + } + if !strings.Contains(err.Error(), "invalid Count offset") { + t.Fatal("wrong error:", err.Error()) + } +} + +// Make sure we error out early with tag data sizes larger than the image file +func TestHugeTagError(t *testing.T) { + name := filepath.Join(*dataDir, "corrupt/huge_tag_exif.jpg") + f, err := os.Open(name) + if err != nil { + t.Fatalf("%v\n", err) + } + defer f.Close() + + _, err = Decode(f) + if err == nil { + t.Fatal("no error on bad exif data") + } + if !strings.Contains(err.Error(), "short read") { + t.Fatal("wrong error:", err.Error()) + } +} + +// Check for a 0-length tag value +func TestZeroLengthTagError(t *testing.T) { + name := filepath.Join(*dataDir, "corrupt/infinite_loop_exif.jpg") + f, err := os.Open(name) + if err != nil { + t.Fatalf("%v\n", err) + } + defer f.Close() + + _, err = Decode(f) + if err == nil { + t.Fatal("no error on bad exif data") + } + if !strings.Contains(err.Error(), "zero length tag value") { + t.Fatal("wrong error:", err.Error()) + } +} diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/fields.go b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/fields.go new file mode 100644 index 000000000..0388d2390 --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/fields.go @@ -0,0 +1,293 @@ +package exif + +type FieldName string + +// UnknownPrefix is used as the first part of field names for decoded tags for +// which there is no known/supported EXIF field. +const UnknownPrefix = "UnknownTag_" + +// Primary EXIF fields +const ( + ImageWidth FieldName = "ImageWidth" + ImageLength = "ImageLength" // Image height called Length by EXIF spec + BitsPerSample = "BitsPerSample" + Compression = "Compression" + PhotometricInterpretation = "PhotometricInterpretation" + Orientation = "Orientation" + SamplesPerPixel = "SamplesPerPixel" + PlanarConfiguration = "PlanarConfiguration" + YCbCrSubSampling = "YCbCrSubSampling" + YCbCrPositioning = "YCbCrPositioning" + XResolution = "XResolution" + YResolution = "YResolution" + ResolutionUnit = "ResolutionUnit" + DateTime = "DateTime" + ImageDescription = "ImageDescription" + Make = "Make" + Model = "Model" + Software = "Software" + Artist = "Artist" + Copyright = "Copyright" + ExifIFDPointer = "ExifIFDPointer" + GPSInfoIFDPointer = "GPSInfoIFDPointer" + InteroperabilityIFDPointer = "InteroperabilityIFDPointer" + ExifVersion = "ExifVersion" + FlashpixVersion = "FlashpixVersion" + ColorSpace = "ColorSpace" + ComponentsConfiguration = "ComponentsConfiguration" + CompressedBitsPerPixel = "CompressedBitsPerPixel" + PixelXDimension = "PixelXDimension" + PixelYDimension = "PixelYDimension" + MakerNote = "MakerNote" + UserComment = "UserComment" + RelatedSoundFile = "RelatedSoundFile" + DateTimeOriginal = "DateTimeOriginal" + DateTimeDigitized = "DateTimeDigitized" + SubSecTime = "SubSecTime" + SubSecTimeOriginal = "SubSecTimeOriginal" + SubSecTimeDigitized = "SubSecTimeDigitized" + ImageUniqueID = "ImageUniqueID" + ExposureTime = "ExposureTime" + FNumber = "FNumber" + ExposureProgram = "ExposureProgram" + SpectralSensitivity = "SpectralSensitivity" + ISOSpeedRatings = "ISOSpeedRatings" + OECF = "OECF" + ShutterSpeedValue = "ShutterSpeedValue" + ApertureValue = "ApertureValue" + BrightnessValue = "BrightnessValue" + ExposureBiasValue = "ExposureBiasValue" + MaxApertureValue = "MaxApertureValue" + SubjectDistance = "SubjectDistance" + MeteringMode = "MeteringMode" + LightSource = "LightSource" + Flash = "Flash" + FocalLength = "FocalLength" + SubjectArea = "SubjectArea" + FlashEnergy = "FlashEnergy" + SpatialFrequencyResponse = "SpatialFrequencyResponse" + FocalPlaneXResolution = "FocalPlaneXResolution" + FocalPlaneYResolution = "FocalPlaneYResolution" + FocalPlaneResolutionUnit = "FocalPlaneResolutionUnit" + SubjectLocation = "SubjectLocation" + ExposureIndex = "ExposureIndex" + SensingMethod = "SensingMethod" + FileSource = "FileSource" + SceneType = "SceneType" + CFAPattern = "CFAPattern" + CustomRendered = "CustomRendered" + ExposureMode = "ExposureMode" + WhiteBalance = "WhiteBalance" + DigitalZoomRatio = "DigitalZoomRatio" + FocalLengthIn35mmFilm = "FocalLengthIn35mmFilm" + SceneCaptureType = "SceneCaptureType" + GainControl = "GainControl" + Contrast = "Contrast" + Saturation = "Saturation" + Sharpness = "Sharpness" + DeviceSettingDescription = "DeviceSettingDescription" + SubjectDistanceRange = "SubjectDistanceRange" + LensMake = "LensMake" + LensModel = "LensModel" +) + +// thumbnail fields +const ( + ThumbJPEGInterchangeFormat = "ThumbJPEGInterchangeFormat" // offset to thumb jpeg SOI + ThumbJPEGInterchangeFormatLength = "ThumbJPEGInterchangeFormatLength" // byte length of thumb +) + +// GPS fields +const ( + GPSVersionID FieldName = "GPSVersionID" + GPSLatitudeRef = "GPSLatitudeRef" + GPSLatitude = "GPSLatitude" + GPSLongitudeRef = "GPSLongitudeRef" + GPSLongitude = "GPSLongitude" + GPSAltitudeRef = "GPSAltitudeRef" + GPSAltitude = "GPSAltitude" + GPSTimeStamp = "GPSTimeStamp" + GPSSatelites = "GPSSatelites" + GPSStatus = "GPSStatus" + GPSMeasureMode = "GPSMeasureMode" + GPSDOP = "GPSDOP" + GPSSpeedRef = "GPSSpeedRef" + GPSSpeed = "GPSSpeed" + GPSTrackRef = "GPSTrackRef" + GPSTrack = "GPSTrack" + GPSImgDirectionRef = "GPSImgDirectionRef" + GPSImgDirection = "GPSImgDirection" + GPSMapDatum = "GPSMapDatum" + GPSDestLatitudeRef = "GPSDestLatitudeRef" + GPSDestLatitude = "GPSDestLatitude" + GPSDestLongitudeRef = "GPSDestLongitudeRef" + GPSDestLongitude = "GPSDestLongitude" + GPSDestBearingRef = "GPSDestBearingRef" + GPSDestBearing = "GPSDestBearing" + GPSDestDistanceRef = "GPSDestDistanceRef" + GPSDestDistance = "GPSDestDistance" + GPSProcessingMethod = "GPSProcessingMethod" + GPSAreaInformation = "GPSAreaInformation" + GPSDateStamp = "GPSDateStamp" + GPSDifferential = "GPSDifferential" +) + +// interoperability fields +const ( + InteroperabilityIndex FieldName = "InteroperabilityIndex" +) + +var exifFields = map[uint16]FieldName{ + ///////////////////////////////////// + ////////// IFD 0 //////////////////// + ///////////////////////////////////// + + // image data structure for the thumbnail + 0x0100: ImageWidth, + 0x0101: ImageLength, + 0x0102: BitsPerSample, + 0x0103: Compression, + 0x0106: PhotometricInterpretation, + 0x0112: Orientation, + 0x0115: SamplesPerPixel, + 0x011C: PlanarConfiguration, + 0x0212: YCbCrSubSampling, + 0x0213: YCbCrPositioning, + 0x011A: XResolution, + 0x011B: YResolution, + 0x0128: ResolutionUnit, + + // Other tags + 0x0132: DateTime, + 0x010E: ImageDescription, + 0x010F: Make, + 0x0110: Model, + 0x0131: Software, + 0x013B: Artist, + 0x8298: Copyright, + + // private tags + exifPointer: ExifIFDPointer, + + ///////////////////////////////////// + ////////// Exif sub IFD ///////////// + ///////////////////////////////////// + + gpsPointer: GPSInfoIFDPointer, + interopPointer: InteroperabilityIFDPointer, + + 0x9000: ExifVersion, + 0xA000: FlashpixVersion, + + 0xA001: ColorSpace, + + 0x9101: ComponentsConfiguration, + 0x9102: CompressedBitsPerPixel, + 0xA002: PixelXDimension, + 0xA003: PixelYDimension, + + 0x927C: MakerNote, + 0x9286: UserComment, + + 0xA004: RelatedSoundFile, + 0x9003: DateTimeOriginal, + 0x9004: DateTimeDigitized, + 0x9290: SubSecTime, + 0x9291: SubSecTimeOriginal, + 0x9292: SubSecTimeDigitized, + + 0xA420: ImageUniqueID, + + // picture conditions + 0x829A: ExposureTime, + 0x829D: FNumber, + 0x8822: ExposureProgram, + 0x8824: SpectralSensitivity, + 0x8827: ISOSpeedRatings, + 0x8828: OECF, + 0x9201: ShutterSpeedValue, + 0x9202: ApertureValue, + 0x9203: BrightnessValue, + 0x9204: ExposureBiasValue, + 0x9205: MaxApertureValue, + 0x9206: SubjectDistance, + 0x9207: MeteringMode, + 0x9208: LightSource, + 0x9209: Flash, + 0x920A: FocalLength, + 0x9214: SubjectArea, + 0xA20B: FlashEnergy, + 0xA20C: SpatialFrequencyResponse, + 0xA20E: FocalPlaneXResolution, + 0xA20F: FocalPlaneYResolution, + 0xA210: FocalPlaneResolutionUnit, + 0xA214: SubjectLocation, + 0xA215: ExposureIndex, + 0xA217: SensingMethod, + 0xA300: FileSource, + 0xA301: SceneType, + 0xA302: CFAPattern, + 0xA401: CustomRendered, + 0xA402: ExposureMode, + 0xA403: WhiteBalance, + 0xA404: DigitalZoomRatio, + 0xA405: FocalLengthIn35mmFilm, + 0xA406: SceneCaptureType, + 0xA407: GainControl, + 0xA408: Contrast, + 0xA409: Saturation, + 0xA40A: Sharpness, + 0xA40B: DeviceSettingDescription, + 0xA40C: SubjectDistanceRange, + 0xA433: LensMake, + 0xA434: LensModel, +} + +var gpsFields = map[uint16]FieldName{ + ///////////////////////////////////// + //// GPS sub-IFD //////////////////// + ///////////////////////////////////// + 0x0: GPSVersionID, + 0x1: GPSLatitudeRef, + 0x2: GPSLatitude, + 0x3: GPSLongitudeRef, + 0x4: GPSLongitude, + 0x5: GPSAltitudeRef, + 0x6: GPSAltitude, + 0x7: GPSTimeStamp, + 0x8: GPSSatelites, + 0x9: GPSStatus, + 0xA: GPSMeasureMode, + 0xB: GPSDOP, + 0xC: GPSSpeedRef, + 0xD: GPSSpeed, + 0xE: GPSTrackRef, + 0xF: GPSTrack, + 0x10: GPSImgDirectionRef, + 0x11: GPSImgDirection, + 0x12: GPSMapDatum, + 0x13: GPSDestLatitudeRef, + 0x14: GPSDestLatitude, + 0x15: GPSDestLongitudeRef, + 0x16: GPSDestLongitude, + 0x17: GPSDestBearingRef, + 0x18: GPSDestBearing, + 0x19: GPSDestDistanceRef, + 0x1A: GPSDestDistance, + 0x1B: GPSProcessingMethod, + 0x1C: GPSAreaInformation, + 0x1D: GPSDateStamp, + 0x1E: GPSDifferential, +} + +var interopFields = map[uint16]FieldName{ + ///////////////////////////////////// + //// Interoperability sub-IFD /////// + ///////////////////////////////////// + 0x1: InteroperabilityIndex, +} + +var thumbnailFields = map[uint16]FieldName{ + 0x0201: ThumbJPEGInterchangeFormat, + 0x0202: ThumbJPEGInterchangeFormatLength, +} diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/regen_regress.go b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/regen_regress.go new file mode 100644 index 000000000..17bac5287 --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/regen_regress.go @@ -0,0 +1,79 @@ +// +build ignore + +package main + +import ( + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + "github.com/rwcarlsen/goexif/exif" + "github.com/rwcarlsen/goexif/tiff" +) + +func main() { + flag.Parse() + fname := flag.Arg(0) + + dst, err := os.Create(fname) + if err != nil { + log.Fatal(err) + } + defer dst.Close() + + dir, err := os.Open("samples") + if err != nil { + log.Fatal(err) + } + defer dir.Close() + + names, err := dir.Readdirnames(0) + if err != nil { + log.Fatal(err) + } + for i, name := range names { + names[i] = filepath.Join("samples", name) + } + makeExpected(names, dst) +} + +func makeExpected(files []string, w io.Writer) { + fmt.Fprintf(w, "package exif\n\n") + fmt.Fprintf(w, "var regressExpected = map[string]map[FieldName]string{\n") + + for _, name := range files { + f, err := os.Open(name) + if err != nil { + continue + } + + x, err := exif.Decode(f) + if err != nil { + f.Close() + continue + } + + fmt.Fprintf(w, "\"%v\": map[FieldName]string{\n", filepath.Base(name)) + x.Walk(®resswalk{w}) + fmt.Fprintf(w, "},\n") + f.Close() + } + fmt.Fprintf(w, "}") +} + +type regresswalk struct { + wr io.Writer +} + +func (w *regresswalk) Walk(name exif.FieldName, tag *tiff.Tag) error { + if strings.HasPrefix(string(name), exif.UnknownPrefix) { + fmt.Fprintf(w.wr, "\"%v\": `%v`,\n", name, tag.String()) + } else { + fmt.Fprintf(w.wr, "%v: `%v`,\n", name, tag.String()) + } + return nil +} diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/regress_expected_test.go b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/regress_expected_test.go new file mode 100644 index 000000000..bf3998189 --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/exif/regress_expected_test.go @@ -0,0 +1,2293 @@ +package exif + +var regressExpected = map[string]map[FieldName]string{ + "2004-01-11-22-45-15-sep-2004-01-11-22-45-15a.jpg": map[FieldName]string{ + PixelXDimension: `1600`, + InteroperabilityIFDPointer: `1009`, + SceneType: `""`, + InteroperabilityIndex: `"R98"`, + Make: `"Samsung Techwin"`, + DateTimeOriginal: `"2004:01:11 22:45:15"`, + DateTimeDigitized: `"2004:01:11 22:45:15"`, + ImageDescription: `"SAMSUNG DIGITAL CAMERA "`, + ExifVersion: `"0220"`, + MeteringMode: `2`, + Flash: `1`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + FlashpixVersion: `"0100"`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormat: `1039`, + MaxApertureValue: `"32/10"`, + ExposureProgram: `2`, + Software: `"M5011S-1031"`, + DateTime: `"2004:01:11 22:45:19"`, + FNumber: `"320/100"`, + ISOSpeedRatings: `150`, + ComponentsConfiguration: `""`, + CompressedBitsPerPixel: `"2/1"`, + RelatedSoundFile: `""`, + XResolution: `"72/1"`, + ExifIFDPointer: `251`, + ExposureTime: `"1000/30000"`, + LightSource: `0`, + FocalLength: `"82/11"`, + ColorSpace: `1`, + PixelYDimension: `1200`, + FileSource: `""`, + Model: `"U-CA 501"`, + ThumbJPEGInterchangeFormatLength: `3530`, + ExposureBiasValue: `"95/10"`, + }, + "2006-08-03-16-29-38-sep-2006-08-03-16-29-38a.jpg": map[FieldName]string{ + ThumbJPEGInterchangeFormat: `5108`, + ThumbJPEGInterchangeFormatLength: `4323`, + MaxApertureValue: `"95/32"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `196`, + ExposureTime: `"1/1500"`, + InteroperabilityIndex: `"R98"`, + FocalPlaneXResolution: `"2816000/225"`, + YResolution: `"180/1"`, + DateTime: `"2006:08:03 16:29:38"`, + ShutterSpeedValue: `"338/32"`, + ApertureValue: `"95/32"`, + FocalLength: `"5800/1000"`, + FlashpixVersion: `"0100"`, + Make: `"Canon"`, + DateTimeOriginal: `"2006:08:03 16:29:38"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `2824`, + ExposureMode: `0`, + SceneCaptureType: `0`, + FNumber: `"28/10"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + SensingMethod: `2`, + DateTimeDigitized: `"2006:08:03 16:29:38"`, + CompressedBitsPerPixel: `"5/1"`, + MeteringMode: `5`, + MakerNote: `""`, + UserComment: `""`, + PixelXDimension: `2816`, + Model: `"Canon PowerShot SD600"`, + ExposureBiasValue: `"0/3"`, + PixelYDimension: `2112`, + FocalPlaneResolutionUnit: `2`, + DigitalZoomRatio: `"2816/2816"`, + Orientation: `6`, + XResolution: `"180/1"`, + ExifVersion: `"0220"`, + Flash: `24`, + FocalPlaneYResolution: `"2112000/169"`, + CustomRendered: `0`, + }, + "2006-11-11-19-17-56-sep-2006-11-11-19-17-56a.jpg": map[FieldName]string{ + FNumber: `"28/10"`, + ExposureProgram: `2`, + Software: `"E3200v1.1"`, + DateTime: `"2006:11:11 19:17:56"`, + ExposureTime: `"10/601"`, + ISOSpeedRatings: `50`, + ComponentsConfiguration: `""`, + CompressedBitsPerPixel: `"4/1"`, + Saturation: `0`, + XResolution: `"300/1"`, + ExifIFDPointer: `284`, + ExposureBiasValue: `"0/10"`, + LightSource: `0`, + FocalLength: `"58/10"`, + ColorSpace: `1`, + PixelYDimension: `1536`, + FileSource: `""`, + Model: `"E3200"`, + ThumbJPEGInterchangeFormatLength: `4546`, + DateTimeDigitized: `"2006:11:11 19:17:56"`, + PixelXDimension: `2048`, + InteroperabilityIFDPointer: `1026`, + SceneType: `""`, + DigitalZoomRatio: `"0/100"`, + GainControl: `0`, + Make: `"NIKON"`, + DateTimeOriginal: `"2006:11:11 19:17:56"`, + InteroperabilityIndex: `"R98"`, + ImageDescription: `" "`, + Sharpness: `0`, + YCbCrPositioning: `2`, + ExifVersion: `"0220"`, + MeteringMode: `5`, + Flash: `25`, + FocalLengthIn35mmFilm: `38`, + SubjectDistanceRange: `0`, + Orientation: `1`, + ResolutionUnit: `2`, + MaxApertureValue: `"30/10"`, + FlashpixVersion: `"0100"`, + WhiteBalance: `0`, + YResolution: `"300/1"`, + ThumbJPEGInterchangeFormat: `4596`, + CustomRendered: `1`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Contrast: `0`, + MakerNote: `""`, + UserComment: `" "`, + }, + "2006-12-10-23-58-20-sep-2006-12-10-23-58-20a.jpg": map[FieldName]string{ + Model: `"Canon PowerShot A80"`, + ExposureBiasValue: `"0/3"`, + PixelYDimension: `1704`, + FocalPlaneResolutionUnit: `2`, + FocalPlaneYResolution: `"1704000/210"`, + CustomRendered: `0`, + DigitalZoomRatio: `"2272/2272"`, + Orientation: `1`, + XResolution: `"180/1"`, + ExifVersion: `"0220"`, + Flash: `24`, + ThumbJPEGInterchangeFormat: `2036`, + ThumbJPEGInterchangeFormatLength: `6465`, + MaxApertureValue: `"95/32"`, + InteroperabilityIndex: `"R98"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `196`, + ExposureTime: `"1/80"`, + FocalLength: `"250/32"`, + FlashpixVersion: `"0100"`, + FocalPlaneXResolution: `"2272000/280"`, + YResolution: `"180/1"`, + DateTime: `"2006:12:10 23:58:20"`, + ShutterSpeedValue: `"202/32"`, + ApertureValue: `"95/32"`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Make: `"Canon"`, + DateTimeOriginal: `"2006:12:10 23:58:20"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `1844`, + FNumber: `"28/10"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + UserComment: `""`, + PixelXDimension: `2272`, + SensingMethod: `2`, + DateTimeDigitized: `"2006:12:10 23:58:20"`, + CompressedBitsPerPixel: `"3/1"`, + MeteringMode: `5`, + MakerNote: `""`, + }, + "2006-12-17-07-09-14-sep-2006-12-17-07-09-14a.jpg": map[FieldName]string{ + WhiteBalance: `0`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormatLength: `7063`, + ExposureBiasValue: `"0/10"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `1536`, + ExposureMode: `0`, + InteroperabilityIndex: `"R98"`, + Make: `"PENTAX Corporation"`, + DateTimeDigitized: `"2006:12:17 07:09:14"`, + PixelXDimension: `2048`, + InteroperabilityIFDPointer: `31048`, + CustomRendered: `0`, + DateTime: `"2006:12:17 07:09:14"`, + ExposureProgram: `2`, + FocalLengthIn35mmFilm: `38`, + Saturation: `0`, + XResolution: `"72/1"`, + ISOSpeedRatings: `64`, + ExifVersion: `"0220"`, + CompressedBitsPerPixel: `"5725504/3145728"`, + Flash: `24`, + Model: `"PENTAX Optio S6"`, + ThumbJPEGInterchangeFormat: `31172`, + MaxApertureValue: `"27/10"`, + FocalLength: `"62/10"`, + ColorSpace: `1`, + DateTimeOriginal: `"2006:12:17 07:09:14"`, + MakerNote: `""`, + DigitalZoomRatio: `"100/100"`, + SceneCaptureType: `0`, + Contrast: `0`, + Software: `"Optio S6 Ver 1.00"`, + FNumber: `"270/100"`, + Sharpness: `0`, + ComponentsConfiguration: `""`, + MeteringMode: `5`, + SubjectDistanceRange: `2`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `586`, + ExposureTime: `"1/160"`, + }, + "2006-12-21-15-55-26-sep-2006-12-21-15-55-26a.jpg": map[FieldName]string{ + DateTimeDigitized: `"2006:12:21 15:55:26"`, + CompressedBitsPerPixel: `"8/1"`, + MeteringMode: `3`, + MakerNote: `""`, + PixelXDimension: `2592`, + Saturation: `0`, + ImageDescription: `" "`, + Model: `"DSC-W15"`, + ExposureBiasValue: `"-20/10"`, + PixelYDimension: `1944`, + Orientation: `1`, + XResolution: `"72/1"`, + ISOSpeedRatings: `100`, + ExifVersion: `"0220"`, + Flash: `79`, + CustomRendered: `0`, + ThumbJPEGInterchangeFormat: `2484`, + ThumbJPEGInterchangeFormatLength: `13571`, + ExposureProgram: `2`, + MaxApertureValue: `"48/16"`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `256`, + ExposureTime: `"10/400"`, + SceneType: `""`, + InteroperabilityIndex: `"R98"`, + YResolution: `"72/1"`, + DateTime: `"2006:12:21 15:55:26"`, + LightSource: `0`, + FocalLength: `"79/10"`, + FlashpixVersion: `"0100"`, + Contrast: `0`, + Make: `"SONY"`, + DateTimeOriginal: `"2006:12:21 15:55:26"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `2278`, + ExposureMode: `1`, + SceneCaptureType: `0`, + FNumber: `"28/10"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + Sharpness: `0`, + }, + "2007-01-01-12-00-00-sep-2007-01-01-12-00-00a.jpg": map[FieldName]string{ + MaxApertureValue: `"286/100"`, + ExposureIndex: `"200/1"`, + ThumbJPEGInterchangeFormat: `13848`, + ThumbJPEGInterchangeFormatLength: `3436`, + ExposureProgram: `2`, + ExposureTime: `"8942/1000000"`, + SceneType: `""`, + InteroperabilityIndex: `"R98"`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `340`, + ApertureValue: `"286/100"`, + LightSource: `0`, + FocalLength: `"60/10"`, + FlashpixVersion: `"0100"`, + YResolution: `"480/1"`, + Software: `"KODAK EASYSHARE C713 ZOOM DIGITAL CAMERA"`, + ShutterSpeedValue: `"680/100"`, + InteroperabilityIFDPointer: `13816`, + ExposureMode: `0`, + FocalLengthIn35mmFilm: `36`, + SceneCaptureType: `0`, + Contrast: `0`, + Make: `"EASTMAN KODAK COMPANY"`, + DateTimeOriginal: `"2007:01:01 12:00:00"`, + ComponentsConfiguration: `""`, + WhiteBalance: `0`, + Sharpness: `0`, + FNumber: `"270/100"`, + ColorSpace: `1`, + FileSource: `""`, + PixelXDimension: `1280`, + SensingMethod: `2`, + GainControl: `2`, + Saturation: `0`, + SubjectDistanceRange: `0`, + DateTimeDigitized: `"2007:01:01 12:00:00"`, + MeteringMode: `5`, + MakerNote: `""`, + Model: `"KODAK EASYSHARE C713 ZOOM DIGITAL CAMERA"`, + ExposureBiasValue: `"0/10"`, + PixelYDimension: `960`, + ExifVersion: `"0221"`, + Flash: `25`, + CustomRendered: `0`, + DigitalZoomRatio: `"0/10"`, + Orientation: `1`, + XResolution: `"480/1"`, + ISOSpeedRatings: `200`, + }, + "2007-01-17-21-49-44-sep-2007-01-17-21-49-44a.jpg": map[FieldName]string{ + XResolution: `"180/1"`, + ISOSpeedRatings: `50`, + ExifVersion: `"0220"`, + Flash: `24`, + CustomRendered: `0`, + Orientation: `1`, + ThumbJPEGInterchangeFormatLength: `7024`, + ExposureProgram: `2`, + MaxApertureValue: `"297/100"`, + ThumbJPEGInterchangeFormat: `956`, + YCbCrPositioning: `2`, + ExifIFDPointer: `266`, + ExposureTime: `"1/30"`, + InteroperabilityIndex: `"R98"`, + ResolutionUnit: `2`, + Software: `"1.00.018PR "`, + DateTime: `"2007:01:17 21:49:44"`, + ShutterSpeedValue: `"491/100"`, + ApertureValue: `"33/10"`, + LightSource: `0`, + FocalLength: `"73/10"`, + FlashpixVersion: `"0100"`, + YResolution: `"180/1"`, + DateTimeOriginal: `"2007:01:17 21:49:44"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `832`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Make: `"Digital Camera "`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + FNumber: `"33/10"`, + MeteringMode: `2`, + MakerNote: `"6106789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456"`, + PixelXDimension: `2816`, + SensingMethod: `2`, + DateTimeDigitized: `"2007:01:17 21:49:44"`, + Model: `"6MP-9Y8 "`, + ExposureBiasValue: `"0/10"`, + PixelYDimension: `2112`, + ImageDescription: `"Digital image "`, + }, + "2007-02-02-18-13-29-sep-2007-02-02-18-13-29a.jpg": map[FieldName]string{ + Software: `"Optio S5z Ver 1.00 "`, + FNumber: `"26/10"`, + Sharpness: `0`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `586`, + ExposureTime: `"1/60"`, + ComponentsConfiguration: `""`, + MeteringMode: `5`, + SubjectDistanceRange: `2`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormatLength: `8800`, + ExposureBiasValue: `"0/3"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `1920`, + WhiteBalance: `0`, + Make: `"PENTAX Corporation "`, + DateTimeDigitized: `"2007:02:02 18:13:29"`, + PixelXDimension: `2560`, + InteroperabilityIFDPointer: `30974`, + CustomRendered: `0`, + ExposureMode: `0`, + InteroperabilityIndex: `"R98"`, + DateTime: `"2007:02:02 18:13:29"`, + ExposureProgram: `2`, + XResolution: `"72/1"`, + ISOSpeedRatings: `200`, + ExifVersion: `"0220"`, + CompressedBitsPerPixel: `"27033600/4915200"`, + Flash: `25`, + FocalLengthIn35mmFilm: `35`, + Saturation: `0`, + Model: `"PENTAX Optio S5z "`, + ThumbJPEGInterchangeFormat: `31098`, + MaxApertureValue: `"28/10"`, + FocalLength: `"580/100"`, + ColorSpace: `1`, + DateTimeOriginal: `"2007:02:02 18:13:29"`, + MakerNote: `""`, + DigitalZoomRatio: `"0/0"`, + SceneCaptureType: `0`, + Contrast: `0`, + }, + "2007-05-02-17-02-21-sep-2007-05-02-17-02-21a.jpg": map[FieldName]string{ + UserComment: `""`, + PixelXDimension: `1600`, + SensingMethod: `2`, + DateTimeDigitized: `"2007:05:02 17:02:21"`, + CompressedBitsPerPixel: `"3/1"`, + MeteringMode: `5`, + MakerNote: `""`, + Model: `"Canon IXY DIGITAL 55"`, + ExposureBiasValue: `"0/3"`, + PixelYDimension: `1200`, + FocalPlaneResolutionUnit: `2`, + FocalPlaneYResolution: `"1200000/168"`, + CustomRendered: `0`, + DigitalZoomRatio: `"2592/2592"`, + Orientation: `1`, + XResolution: `"180/1"`, + ExifVersion: `"0220"`, + Flash: `9`, + ThumbJPEGInterchangeFormat: `5108`, + ThumbJPEGInterchangeFormatLength: `6306`, + MaxApertureValue: `"107/32"`, + InteroperabilityIndex: `"R98"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `196`, + ExposureTime: `"1/60"`, + FocalLength: `"7109/1000"`, + FlashpixVersion: `"0100"`, + FocalPlaneXResolution: `"1600000/225"`, + YResolution: `"180/1"`, + DateTime: `"2007:05:02 17:02:21"`, + ShutterSpeedValue: `"189/32"`, + ApertureValue: `"107/32"`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Make: `"Canon"`, + DateTimeOriginal: `"2007:05:02 17:02:21"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `2226`, + FNumber: `"32/10"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + }, + "2007-05-12-08-19-07-sep-2007-05-12-08-19-07a.jpg": map[FieldName]string{ + Model: `"EX-Z70 "`, + ExposureBiasValue: `"0/3"`, + PixelYDimension: `480`, + CustomRendered: `0`, + DigitalZoomRatio: `"0/0"`, + Orientation: `1`, + XResolution: `"72/1"`, + ExifVersion: `"0221"`, + Flash: `16`, + ThumbJPEGInterchangeFormat: `27422`, + ThumbJPEGInterchangeFormatLength: `8332`, + ExposureProgram: `2`, + MaxApertureValue: `"33/10"`, + InteroperabilityIndex: `"R98"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `282`, + ExposureTime: `"1/50"`, + FocalLength: `"630/100"`, + FlashpixVersion: `"0100"`, + YResolution: `"72/1"`, + Software: `"1.00 "`, + DateTime: `"2007:06:17 22:56:38"`, + LightSource: `0`, + ExposureMode: `0`, + FocalLengthIn35mmFilm: `38`, + SceneCaptureType: `0`, + Contrast: `0`, + Make: `"CASIO COMPUTER CO.,LTD."`, + DateTimeOriginal: `"2007:05:12 08:19:07"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `27298`, + Sharpness: `0`, + FNumber: `"31/10"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + PixelXDimension: `640`, + GainControl: `2`, + Saturation: `0`, + DateTimeDigitized: `"2007:06:17 22:56:38"`, + CompressedBitsPerPixel: `"252746/307200"`, + MeteringMode: `5`, + MakerNote: `""`, + }, + "2007-05-26-04-49-45-sep-2007-05-26-04-49-45a.jpg": map[FieldName]string{ + ThumbJPEGInterchangeFormat: `4596`, + ThumbJPEGInterchangeFormatLength: `10120`, + ExposureProgram: `2`, + MaxApertureValue: `"34/10"`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `284`, + ExposureTime: `"10/3486"`, + SceneType: `""`, + InteroperabilityIndex: `"R98"`, + YResolution: `"300/1"`, + Software: `"COOLPIX L3v1.2"`, + DateTime: `"2007:05:26 04:49:45"`, + LightSource: `0`, + FocalLength: `"63/10"`, + FlashpixVersion: `"0100"`, + Contrast: `0`, + Make: `"NIKON"`, + DateTimeOriginal: `"2007:05:26 04:49:45"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `1026`, + ExposureMode: `0`, + FocalLengthIn35mmFilm: `38`, + SceneCaptureType: `2`, + FNumber: `"32/10"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + Sharpness: `0`, + Saturation: `0`, + DateTimeDigitized: `"2007:05:26 04:49:45"`, + CompressedBitsPerPixel: `"4/1"`, + MeteringMode: `5`, + MakerNote: `""`, + UserComment: `" "`, + PixelXDimension: `2592`, + GainControl: `0`, + SubjectDistanceRange: `0`, + ImageDescription: `" "`, + Model: `"COOLPIX L3"`, + ExposureBiasValue: `"0/10"`, + PixelYDimension: `1944`, + Orientation: `1`, + XResolution: `"300/1"`, + ISOSpeedRatings: `50`, + ExifVersion: `"0220"`, + Flash: `24`, + CustomRendered: `0`, + DigitalZoomRatio: `"0/100"`, + }, + "2007-05-30-14-28-01-sep-2007-05-30-14-28-01a.jpg": map[FieldName]string{ + CompressedBitsPerPixel: `"2/1"`, + MeteringMode: `5`, + UserComment: `" "`, + GainControl: `1`, + Saturation: `0`, + SubjectDistanceRange: `0`, + ExifIFDPointer: `284`, + ExposureTime: `"10/40"`, + InteroperabilityIndex: `"R98"`, + Software: `"COOLPIX S6V1.0"`, + ImageDescription: `" "`, + Model: `"COOLPIX S6"`, + XResolution: `"300/1"`, + ISOSpeedRatings: `53`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `1026`, + CustomRendered: `0`, + DigitalZoomRatio: `"0/100"`, + Make: `"NIKON"`, + Orientation: `1`, + Contrast: `0`, + FocalLengthIn35mmFilm: `35`, + SceneCaptureType: `0`, + FileSource: `""`, + FNumber: `"30/10"`, + ExposureProgram: `2`, + DateTimeDigitized: `"2007:05:30 14:28:01"`, + MakerNote: `""`, + PixelXDimension: `2816`, + SceneType: `""`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExposureBiasValue: `"0/10"`, + LightSource: `0`, + FocalLength: `"58/10"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `2112`, + YResolution: `"300/1"`, + DateTime: `"2007:05:30 14:28:01"`, + Flash: `16`, + ExposureMode: `0`, + ExifVersion: `"0220"`, + DateTimeOriginal: `"2007:05:30 14:28:01"`, + MaxApertureValue: `"32/10"`, + ColorSpace: `1`, + WhiteBalance: `0`, + Sharpness: `0`, + ThumbJPEGInterchangeFormat: `4596`, + ThumbJPEGInterchangeFormatLength: `5274`, + }, + "2007-06-06-16-15-25-sep-2007-06-06-16-15-25a.jpg": map[FieldName]string{ + ExifVersion: `"0220"`, + DateTimeOriginal: `"2007:06:06 16:15:25"`, + Flash: `24`, + ExposureMode: `0`, + ThumbJPEGInterchangeFormat: `4596`, + ThumbJPEGInterchangeFormatLength: `5967`, + MaxApertureValue: `"30/10"`, + ColorSpace: `1`, + WhiteBalance: `0`, + Sharpness: `0`, + ExifIFDPointer: `284`, + ExposureTime: `"10/2870"`, + CompressedBitsPerPixel: `"2/1"`, + MeteringMode: `5`, + UserComment: `" "`, + GainControl: `0`, + Saturation: `0`, + SubjectDistanceRange: `0`, + InteroperabilityIndex: `"R98"`, + ImageDescription: `" "`, + Model: `"E3700"`, + Software: `"E3700v1.2"`, + Make: `"NIKON"`, + Orientation: `1`, + XResolution: `"300/1"`, + ISOSpeedRatings: `50`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `1026`, + CustomRendered: `0`, + DigitalZoomRatio: `"0/100"`, + FocalLengthIn35mmFilm: `35`, + SceneCaptureType: `0`, + Contrast: `0`, + FNumber: `"48/10"`, + ExposureProgram: `2`, + FileSource: `""`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + DateTimeDigitized: `"2007:06:06 16:15:25"`, + MakerNote: `""`, + PixelXDimension: `2048`, + SceneType: `""`, + YResolution: `"300/1"`, + DateTime: `"2007:06:06 16:15:25"`, + ExposureBiasValue: `"0/10"`, + LightSource: `0`, + FocalLength: `"54/10"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `1536`, + }, + "2007-06-26-10-13-04-sep-2007-06-26-10-13-04a.jpg": map[FieldName]string{ + ColorSpace: `1`, + FileSource: `""`, + FNumber: `"3/1"`, + CompressedBitsPerPixel: `"6389872/3145728"`, + MeteringMode: `2`, + MakerNote: `""`, + PixelXDimension: `2048`, + SensingMethod: `2`, + DateTimeDigitized: `"2007:06:26 10:13:04"`, + Model: `"DV"`, + Copyright: `"Copyright2004"`, + ExposureBiasValue: `"1/4"`, + PixelYDimension: `1536`, + ImageDescription: `"My beautiful picture"`, + XResolution: `"320/1"`, + ISOSpeedRatings: `100`, + ExifVersion: `"0210"`, + Flash: `0`, + Orientation: `1`, + ThumbJPEGInterchangeFormatLength: `6292`, + ExposureProgram: `3`, + MaxApertureValue: `"3/1"`, + ExposureIndex: `"146/1"`, + ThumbJPEGInterchangeFormat: `1306`, + YCbCrPositioning: `2`, + ExposureTime: `"23697424/268435456"`, + ExifIFDPointer: `262`, + RelatedSoundFile: `"RelatedSound"`, + SceneType: `""`, + InteroperabilityIndex: `"R98"`, + ResolutionUnit: `2`, + Software: `"DVWare 1.0"`, + DateTime: `"2007:06:26 10:13:04"`, + ShutterSpeedValue: `"7/1"`, + ApertureValue: `"3/1"`, + LightSource: `0`, + FlashpixVersion: `"0100"`, + YResolution: `"384/1"`, + DateTimeOriginal: `"2007:06:26 10:13:04"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `1170`, + Make: `"CEC"`, + }, + "2007-07-13-17-02-30-sep-2007-07-13-17-02-30a.jpg": map[FieldName]string{ + Software: `"Ver 1.00 "`, + DateTime: `"2007:07:13 17:02:30"`, + FNumber: `"48/10"`, + ExposureProgram: `2`, + RelatedSoundFile: `" "`, + Saturation: `0`, + XResolution: `"72/1"`, + ExifIFDPointer: `266`, + ExposureTime: `"1/110"`, + ISOSpeedRatings: `64`, + ComponentsConfiguration: `""`, + ColorSpace: `1`, + PixelYDimension: `2736`, + FileSource: `""`, + Model: `"ViviCam X30 "`, + ThumbJPEGInterchangeFormatLength: `20544`, + ShutterSpeedValue: `"678/100"`, + ExposureBiasValue: `"0/10"`, + LightSource: `0`, + DigitalZoomRatio: `"100/100"`, + GainControl: `0`, + InteroperabilityIndex: `"R98"`, + Make: `"Vivitar"`, + DateTimeOriginal: `"2007:07:13 17:02:30"`, + DateTimeDigitized: `"2007:07:13 17:02:30"`, + PixelXDimension: `3648`, + InteroperabilityIFDPointer: `1010`, + ImageDescription: `"Digital StillCamera"`, + Sharpness: `0`, + Flash: `0`, + FocalLengthIn35mmFilm: `35`, + SubjectDistanceRange: `0`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifVersion: `"0220"`, + MeteringMode: `2`, + WhiteBalance: `0`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormat: `1156`, + ApertureValue: `"45/10"`, + MaxApertureValue: `"30/10"`, + FlashpixVersion: `"0100"`, + MakerNote: `""`, + CustomRendered: `0`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Contrast: `0`, + }, + "2007-08-15-14-42-46-sep-2007-08-15-14-42-46a.jpg": map[FieldName]string{ + Model: `"KODAK C663 ZOOM DIGITAL CAMERA"`, + ShutterSpeedValue: `"73/10"`, + DigitalZoomRatio: `"0/100"`, + FocalLengthIn35mmFilm: `66`, + SceneCaptureType: `0`, + Contrast: `0`, + Make: `"EASTMAN KODAK COMPANY"`, + Orientation: `1`, + XResolution: `"230/1"`, + CustomRendered: `0`, + ISOSpeedRatings: `80`, + ComponentsConfiguration: `""`, + FNumber: `"36/10"`, + ExposureProgram: `2`, + FileSource: `""`, + PixelXDimension: `2832`, + SensingMethod: `2`, + SceneType: `""`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + DateTimeDigitized: `"2007:08:15 14:42:46"`, + MakerNote: `""`, + FocalLength: `"110/10"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `2128`, + YResolution: `"230/1"`, + ApertureValue: `"37/10"`, + ExposureBiasValue: `"0/3"`, + LightSource: `0`, + ExposureMode: `0`, + ExifVersion: `"0221"`, + DateTimeOriginal: `"2007:08:15 14:42:46"`, + Flash: `24`, + MaxApertureValue: `"37/10"`, + ColorSpace: `1`, + ExposureIndex: `"80/1"`, + WhiteBalance: `0`, + Sharpness: `0`, + ThumbJPEGInterchangeFormat: `8472`, + ThumbJPEGInterchangeFormatLength: `3060`, + ExposureTime: `"1/160"`, + MeteringMode: `5`, + ExifIFDPointer: `320`, + GainControl: `0`, + Saturation: `0`, + SubjectDistanceRange: `0`, + }, + "2007-08-24-02-40-42-sep-2007-08-24-02-40-42a.jpg": map[FieldName]string{ + ThumbJPEGInterchangeFormat: `5108`, + ThumbJPEGInterchangeFormatLength: `2084`, + MaxApertureValue: `"147/32"`, + ColorSpace: `1`, + WhiteBalance: `0`, + ExifIFDPointer: `196`, + ExposureTime: `"1/400"`, + CompressedBitsPerPixel: `"5/1"`, + MeteringMode: `5`, + UserComment: `""`, + InteroperabilityIndex: `"R98"`, + Model: `"Canon PowerShot SD450"`, + ShutterSpeedValue: `"277/32"`, + FocalPlaneResolutionUnit: `2`, + DigitalZoomRatio: `"2592/2592"`, + Make: `"Canon"`, + Orientation: `1`, + XResolution: `"180/1"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `2206`, + FocalPlaneYResolution: `"1944000/168"`, + CustomRendered: `0`, + SceneCaptureType: `0`, + FNumber: `"100/10"`, + FileSource: `""`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + DateTimeDigitized: `"2007:08:24 02:40:42"`, + MakerNote: `""`, + PixelXDimension: `2592`, + SensingMethod: `2`, + FocalPlaneXResolution: `"2592000/225"`, + YResolution: `"180/1"`, + DateTime: `"2007:08:24 02:40:42"`, + ApertureValue: `"213/32"`, + ExposureBiasValue: `"0/3"`, + FocalLength: `"17400/1000"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `1944`, + ExifVersion: `"0220"`, + DateTimeOriginal: `"2007:08:24 02:40:42"`, + Flash: `24`, + ExposureMode: `0`, + }, + "2007-11-07-11-40-44-sep-2007-11-07-11-40-44a.jpg": map[FieldName]string{ + YResolution: `"72/1"`, + Copyright: `" "`, + ThumbJPEGInterchangeFormat: `1306`, + ApertureValue: `"600/100"`, + MaxApertureValue: `"360/100"`, + FlashpixVersion: `"0100"`, + WhiteBalance: `0`, + MakerNote: `""`, + SensingMethod: `2`, + CustomRendered: `1`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Software: `"Digital Camera FinePix Z1 Ver1.00"`, + DateTime: `"2007:11:07 11:40:44"`, + FNumber: `"800/100"`, + ExposureProgram: `2`, + XResolution: `"72/1"`, + ExifIFDPointer: `294`, + ExposureTime: `"10/2000"`, + ISOSpeedRatings: `64`, + ComponentsConfiguration: `""`, + CompressedBitsPerPixel: `"20/10"`, + PixelYDimension: `1944`, + Model: `"FinePix Z1 "`, + ThumbJPEGInterchangeFormatLength: `9900`, + ShutterSpeedValue: `"764/100"`, + ExposureBiasValue: `"0/100"`, + LightSource: `0`, + FocalLength: `"610/100"`, + ColorSpace: `1`, + FocalPlaneXResolution: `"4442/1"`, + FileSource: `""`, + InteroperabilityIndex: `"R98"`, + Make: `"FUJIFILM"`, + DateTimeOriginal: `"2007:11:07 11:40:44"`, + DateTimeDigitized: `"2007:11:07 11:40:44"`, + BrightnessValue: `"906/100"`, + PixelXDimension: `2592`, + InteroperabilityIFDPointer: `1158`, + SceneType: `""`, + FocalPlaneResolutionUnit: `3`, + Sharpness: `0`, + SubjectDistanceRange: `0`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifVersion: `"0220"`, + MeteringMode: `5`, + Flash: `16`, + FocalPlaneYResolution: `"4442/1"`, + }, + "2008-06-02-10-03-57-sep-2008-06-02-10-03-57a.jpg": map[FieldName]string{ + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + Sharpness: `0`, + FNumber: `"2800/1000"`, + CompressedBitsPerPixel: `"5896224/3145728"`, + MeteringMode: `4`, + MakerNote: `""`, + PixelXDimension: `2048`, + SensingMethod: `2`, + DateTimeDigitized: `"2008:06:13 06:16:19"`, + Copyright: `"Copyright 2006"`, + ExposureBiasValue: `"0/10"`, + PixelYDimension: `1536`, + Model: `"i533"`, + XResolution: `"288/3"`, + ISOSpeedRatings: `100`, + ExifVersion: `"0220"`, + Flash: `65`, + DigitalZoomRatio: `"100/100"`, + Orientation: `1`, + ThumbJPEGInterchangeFormatLength: `5972`, + ExposureProgram: `7`, + MaxApertureValue: `"2970/1000"`, + ThumbJPEGInterchangeFormat: `3756`, + YCbCrPositioning: `2`, + ExifIFDPointer: `226`, + ExposureTime: `"10/600"`, + SceneType: `""`, + InteroperabilityIndex: `"R98"`, + ResolutionUnit: `2`, + Software: `"00.00.1240a"`, + DateTime: `"2008:06:13 06:16:19"`, + ShutterSpeedValue: `"5907/1000"`, + ApertureValue: `"2970/1000"`, + LightSource: `4`, + FocalLength: `"6200/1000"`, + FlashpixVersion: `"0100"`, + YResolution: `"288/3"`, + DateTimeOriginal: `"2008:06:02 10:03:57"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `3620`, + ExposureMode: `0`, + Make: `"Polaroid"`, + }, + "2008-06-06-13-29-29-sep-2008-06-06-13-29-29a.jpg": map[FieldName]string{ + PixelXDimension: `1600`, + InteroperabilityIFDPointer: `3334`, + DigitalZoomRatio: `"3072/3072"`, + InteroperabilityIndex: `"R98"`, + Make: `"Canon"`, + DateTimeOriginal: `"2008:06:06 13:29:29"`, + DateTimeDigitized: `"2008:06:06 13:29:29"`, + FocalPlaneResolutionUnit: `2`, + ExifVersion: `"0220"`, + MeteringMode: `5`, + Flash: `16`, + FocalPlaneYResolution: `"1200000/169"`, + Orientation: `6`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + MaxApertureValue: `"116/32"`, + FlashpixVersion: `"0100"`, + WhiteBalance: `0`, + YResolution: `"180/1"`, + ThumbJPEGInterchangeFormat: `5108`, + ApertureValue: `"116/32"`, + CustomRendered: `0`, + ExposureMode: `0`, + SceneCaptureType: `0`, + MakerNote: `""`, + UserComment: `""`, + SensingMethod: `2`, + DateTime: `"2008:06:06 13:29:29"`, + FNumber: `"35/10"`, + ISOSpeedRatings: `80`, + ComponentsConfiguration: `""`, + CompressedBitsPerPixel: `"5/1"`, + XResolution: `"180/1"`, + ExifIFDPointer: `196`, + ExposureTime: `"1/320"`, + ExposureBiasValue: `"0/3"`, + FocalLength: `"8462/1000"`, + ColorSpace: `1`, + PixelYDimension: `1200`, + FocalPlaneXResolution: `"1600000/225"`, + Model: `"Canon DIGITAL IXUS 75"`, + ThumbJPEGInterchangeFormatLength: `6594`, + ShutterSpeedValue: `"266/32"`, + FileSource: `""`, + }, + "2008-06-17-01-21-30-sep-2008-06-17-01-21-30a.jpg": map[FieldName]string{ + MaxApertureValue: `"30/10"`, + ThumbJPEGInterchangeFormat: `1041`, + ThumbJPEGInterchangeFormatLength: `13506`, + ExposureProgram: `2`, + ExposureTime: `"10/326"`, + SceneType: `""`, + InteroperabilityIndex: `"R98"`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `253`, + LightSource: `0`, + FocalLength: `"645/100"`, + FlashpixVersion: `"0100"`, + YResolution: `"72/1"`, + Software: `"A520_CT019"`, + DateTime: `"2008:06:17 01:22:13"`, + InteroperabilityIFDPointer: `1011`, + Make: `"Polaroid"`, + DateTimeOriginal: `"2008:06:17 01:21:30"`, + ComponentsConfiguration: `""`, + FNumber: `"28/10"`, + ColorSpace: `1`, + FileSource: `""`, + UserComment: `""`, + PixelXDimension: `2592`, + DateTimeDigitized: `"2008:06:17 01:21:30"`, + CompressedBitsPerPixel: `"2/1"`, + MeteringMode: `2`, + PixelYDimension: `1944`, + ImageDescription: `"DCFC1247.JPG "`, + Model: `"5MP Digital Camera"`, + ExposureBiasValue: `"0/10"`, + ExifVersion: `"0220"`, + Flash: `0`, + Orientation: `1`, + XResolution: `"72/1"`, + ISOSpeedRatings: `100`, + }, + "2008-09-02-17-43-48-sep-2008-09-02-17-43-48a.jpg": map[FieldName]string{ + ImageDescription: `" "`, + Model: `"Z550a"`, + YResolution: `"72/1"`, + Software: `"R6GA004 prgCXC1250583_GENERIC_M 2.0"`, + DateTime: `"2008:09:02 17:43:48"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `1024`, + Make: `"Sony Ericsson"`, + Orientation: `1`, + XResolution: `"72/1"`, + ExifVersion: `"0220"`, + DateTimeOriginal: `"2008:09:02 17:43:48"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `612`, + ThumbJPEGInterchangeFormat: `748`, + ThumbJPEGInterchangeFormatLength: `4641`, + ColorSpace: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `302`, + DateTimeDigitized: `"2008:09:02 17:43:48"`, + PixelXDimension: `1280`, + InteroperabilityIndex: `"R98"`, + }, + "2009-03-26-09-23-20-sep-2009-03-26-09-23-20a.jpg": map[FieldName]string{ + FocalLength: `"5800/1000"`, + ColorSpace: `1`, + PixelYDimension: `2304`, + FocalPlaneXResolution: `"3072000/225"`, + Model: `"Canon PowerShot SD750"`, + ThumbJPEGInterchangeFormatLength: `5513`, + ShutterSpeedValue: `"287/32"`, + ExposureBiasValue: `"0/3"`, + FileSource: `""`, + InteroperabilityIFDPointer: `3334`, + DigitalZoomRatio: `"3072/3072"`, + InteroperabilityIndex: `"R98"`, + Make: `"Canon"`, + DateTimeOriginal: `"2009:03:26 09:23:20"`, + DateTimeDigitized: `"2009:03:26 09:23:20"`, + PixelXDimension: `3072`, + FocalPlaneResolutionUnit: `2`, + MeteringMode: `5`, + Flash: `24`, + FocalPlaneYResolution: `"2304000/169"`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifVersion: `"0220"`, + FlashpixVersion: `"0100"`, + WhiteBalance: `0`, + YResolution: `"180/1"`, + ThumbJPEGInterchangeFormat: `5108`, + ApertureValue: `"95/32"`, + MaxApertureValue: `"95/32"`, + ExposureMode: `0`, + SceneCaptureType: `0`, + MakerNote: `""`, + UserComment: `""`, + SensingMethod: `2`, + CustomRendered: `0`, + DateTime: `"2009:03:26 09:23:20"`, + FNumber: `"28/10"`, + ComponentsConfiguration: `""`, + CompressedBitsPerPixel: `"5/1"`, + XResolution: `"180/1"`, + ExifIFDPointer: `196`, + ExposureTime: `"1/500"`, + ISOSpeedRatings: `160`, + }, + "2009-04-11-03-01-38-sep-2009-04-11-03-01-38a.jpg": map[FieldName]string{ + ThumbJPEGInterchangeFormat: `33660`, + MaxApertureValue: `"30/10"`, + FlashpixVersion: `"0100"`, + WhiteBalance: `0`, + YResolution: `"300/1"`, + UserComment: `" "`, + CustomRendered: `0`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Contrast: `0`, + MakerNote: `""`, + DateTime: `"2009:04:11 03:01:38"`, + FNumber: `"28/10"`, + ExposureProgram: `2`, + Software: `"COOLPIX L18 V1.1"`, + ExifIFDPointer: `230`, + ExposureTime: `"1/250"`, + ISOSpeedRatings: `227`, + ComponentsConfiguration: `""`, + CompressedBitsPerPixel: `"4/1"`, + Saturation: `0`, + XResolution: `"300/1"`, + ThumbJPEGInterchangeFormatLength: `9697`, + ExposureBiasValue: `"0/10"`, + LightSource: `0`, + FocalLength: `"5700/1000"`, + ColorSpace: `1`, + PixelYDimension: `2448`, + FileSource: `""`, + Model: `"COOLPIX L18"`, + DateTimeOriginal: `"2009:04:11 03:01:38"`, + DateTimeDigitized: `"2009:04:11 03:01:38"`, + PixelXDimension: `3264`, + InteroperabilityIFDPointer: `33536`, + SceneType: `""`, + DigitalZoomRatio: `"0/100"`, + GainControl: `1`, + Make: `"NIKON"`, + InteroperabilityIndex: `"R98"`, + Sharpness: `1`, + ImageDescription: `" "`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifVersion: `"0220"`, + MeteringMode: `5`, + Flash: `24`, + FocalLengthIn35mmFilm: `35`, + SubjectDistanceRange: `0`, + Orientation: `1`, + }, + "2009-04-23-07-21-35-sep-2009-04-23-07-21-35a.jpg": map[FieldName]string{ + Sharpness: `0`, + MeteringMode: `5`, + Flash: `9`, + FocalLengthIn35mmFilm: `35`, + SubjectDistanceRange: `3`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifVersion: `"0220"`, + WhiteBalance: `0`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormat: `31176`, + MaxApertureValue: `"28/10"`, + FlashpixVersion: `"0100"`, + Contrast: `0`, + MakerNote: `""`, + CustomRendered: `0`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Software: `"Optio S50 Ver 1.00"`, + DateTime: `"2009:04:23 07:21:35"`, + FNumber: `"26/10"`, + ComponentsConfiguration: `""`, + CompressedBitsPerPixel: `"13301888/4915200"`, + Saturation: `0`, + XResolution: `"72/1"`, + ExifIFDPointer: `590`, + ExposureTime: `"1/40"`, + ISOSpeedRatings: `100`, + ColorSpace: `1`, + PixelYDimension: `1920`, + Model: `"PENTAX Optio S50"`, + ThumbJPEGInterchangeFormatLength: `6015`, + ExposureBiasValue: `"0/10"`, + FocalLength: `"58/10"`, + InteroperabilityIFDPointer: `31040`, + DigitalZoomRatio: `"0/100"`, + InteroperabilityIndex: `"R98"`, + Make: `"PENTAX Corporation"`, + DateTimeOriginal: `"2009:04:23 07:21:35"`, + DateTimeDigitized: `"2009:04:23 07:21:35"`, + PixelXDimension: `2560`, + }, + "2009-06-11-19-23-18-sep-2009-06-11-19-23-18a.jpg": map[FieldName]string{ + ThumbJPEGInterchangeFormat: `606`, + ThumbJPEGInterchangeFormatLength: `7150`, + ExposureProgram: `1`, + ColorSpace: `65535`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `264`, + ExposureTime: `"1/4"`, + DateTimeDigitized: `"2009:06:11 19:23:18"`, + MeteringMode: `1`, + PixelXDimension: `1400`, + PixelYDimension: `2100`, + Model: `"Canon EOS DIGITAL REBEL XTi"`, + YResolution: `"3500000/10000"`, + Software: `"Adobe Photoshop CS3 Macintosh"`, + DateTime: `"2009:06:23 18:42:05"`, + ApertureValue: `"11257/1627"`, + ExposureBiasValue: `"0/1"`, + FocalLength: `"47/1"`, + Make: `"Canon"`, + Orientation: `1`, + XResolution: `"3500000/10000"`, + ISOSpeedRatings: `200`, + ExifVersion: `"0220"`, + DateTimeOriginal: `"2009:06:11 19:23:18"`, + Flash: `16`, + }, + "2009-06-20-07-59-05-sep-2009-06-20-07-59-05a.jpg": map[FieldName]string{ + DateTimeOriginal: `"2009:06:20 07:59:05"`, + Flash: `89`, + ExposureMode: `0`, + ExifVersion: `"0221"`, + ThumbJPEGInterchangeFormatLength: `4569`, + MaxApertureValue: `"36/10"`, + ColorSpace: `1`, + ExposureIndex: `"160/1"`, + WhiteBalance: `0`, + Sharpness: `0`, + ThumbJPEGInterchangeFormat: `9032`, + ExposureTime: `"1/500"`, + MeteringMode: `5`, + GainControl: `2`, + Saturation: `0`, + SubjectDistanceRange: `0`, + InteroperabilityIndex: `"R98"`, + ExifIFDPointer: `514`, + ShutterSpeedValue: `"9/1"`, + Model: `"KODAK EASYSHARE Z710 ZOOM DIGITAL CAMERA"`, + Orientation: `1`, + XResolution: `"480/1"`, + ISOSpeedRatings: `160`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `8728`, + CustomRendered: `0`, + DigitalZoomRatio: `"0/100"`, + Make: `"EASTMAN KODAK COMPANY"`, + SceneCaptureType: `0`, + Contrast: `0`, + FocalLengthIn35mmFilm: `337`, + ExposureProgram: `2`, + FileSource: `""`, + FNumber: `"35/10"`, + YCbCrPositioning: `1`, + DateTimeDigitized: `"2009:06:20 07:59:05"`, + MakerNote: `""`, + PixelXDimension: `3072`, + SensingMethod: `2`, + SceneType: `""`, + ResolutionUnit: `2`, + ApertureValue: `"36/10"`, + ExposureBiasValue: `"0/3"`, + LightSource: `0`, + FocalLength: `"559/10"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `2304`, + YResolution: `"480/1"`, + }, + "2009-08-05-08-11-31-sep-2009-08-05-08-11-31a.jpg": map[FieldName]string{ + XResolution: `"72/1"`, + ISOSpeedRatings: `100`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `1158`, + FocalPlaneYResolution: `"5292/1"`, + CustomRendered: `0`, + Make: `"FUJIFILM"`, + Orientation: `1`, + SceneCaptureType: `0`, + FileSource: `""`, + FNumber: `"400/100"`, + ExposureProgram: `2`, + DateTimeDigitized: `"2009:08:05 08:11:31"`, + MakerNote: `"FUJIFILM0130" !"#,012NORMAL d"`, + PixelXDimension: `2848`, + SensingMethod: `2`, + SceneType: `""`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + Copyright: `" "`, + ApertureValue: `"400/100"`, + ExposureBiasValue: `"0/100"`, + LightSource: `0`, + FocalLength: `"720/100"`, + FlashpixVersion: `"0100"`, + YResolution: `"72/1"`, + DateTime: `"2009:08:05 08:11:31"`, + PixelYDimension: `2136`, + FocalPlaneXResolution: `"5292/1"`, + Flash: `16`, + ExposureMode: `0`, + ExifVersion: `"0220"`, + DateTimeOriginal: `"2009:08:05 08:11:31"`, + MaxApertureValue: `"300/100"`, + ColorSpace: `1`, + WhiteBalance: `1`, + Sharpness: `0`, + ThumbJPEGInterchangeFormat: `1306`, + ThumbJPEGInterchangeFormatLength: `8596`, + CompressedBitsPerPixel: `"20/10"`, + BrightnessValue: `"719/100"`, + MeteringMode: `5`, + SubjectDistanceRange: `0`, + InteroperabilityIndex: `"R98"`, + ExifIFDPointer: `294`, + ExposureTime: `"10/3000"`, + ShutterSpeedValue: `"820/100"`, + FocalPlaneResolutionUnit: `3`, + Model: `"FinePix E550 "`, + Software: `"Digital Camera FinePix E550 Ver1.00"`, + }, + "2010-06-08-04-44-24-sep-2010-06-08-04-44-24a.jpg": map[FieldName]string{ + CompressedBitsPerPixel: `"8/1"`, + MeteringMode: `5`, + MakerNote: `""`, + PixelXDimension: `2816`, + Saturation: `0`, + DateTimeDigitized: `"2010:06:08 04:44:24"`, + Model: `"DSC-S600"`, + ExposureBiasValue: `"0/10"`, + PixelYDimension: `2112`, + ImageDescription: `" "`, + XResolution: `"72/1"`, + ISOSpeedRatings: `80`, + ExifVersion: `"0221"`, + Flash: `31`, + CustomRendered: `0`, + Orientation: `1`, + ThumbJPEGInterchangeFormatLength: `4029`, + ExposureProgram: `2`, + MaxApertureValue: `"48/16"`, + ThumbJPEGInterchangeFormat: `6892`, + YCbCrPositioning: `2`, + ExifIFDPointer: `2314`, + ExposureTime: `"10/400"`, + SceneType: `""`, + ResolutionUnit: `2`, + DateTime: `"2010:06:08 04:44:24"`, + LightSource: `0`, + FocalLength: `"51/10"`, + FlashpixVersion: `"0100"`, + YResolution: `"72/1"`, + DateTimeOriginal: `"2010:06:08 04:44:24"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `6640`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Contrast: `0`, + Make: `"SONY"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + Sharpness: `0`, + FNumber: `"28/10"`, + }, + "2010-06-20-20-07-39-sep-2010-06-20-20-07-39a.jpg": map[FieldName]string{ + FocalPlaneYResolution: `"2736000/181"`, + CustomRendered: `0`, + DigitalZoomRatio: `"3648/3648"`, + Orientation: `1`, + XResolution: `"4718592/65536"`, + ISOSpeedRatings: `800`, + ExifVersion: `"0220"`, + Flash: `16`, + ThumbJPEGInterchangeFormat: `3408`, + ThumbJPEGInterchangeFormatLength: `5126`, + MaxApertureValue: `"116/32"`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `302`, + ExposureTime: `"1/10"`, + FocalLength: `"9681/1000"`, + FlashpixVersion: `"0100"`, + FocalPlaneXResolution: `"3648000/241"`, + YResolution: `"4718592/65536"`, + Software: `"QuickTime 7.6.6"`, + DateTime: `"2010:10:31 22:39:25"`, + ShutterSpeedValue: `"106/32"`, + ApertureValue: `"116/32"`, + Make: `"Canon"`, + DateTimeOriginal: `"2010:06:20 20:07:39"`, + ComponentsConfiguration: `""`, + ExposureMode: `0`, + SceneCaptureType: `0`, + FNumber: `"35/10"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + PixelXDimension: `3648`, + SensingMethod: `2`, + DateTimeDigitized: `"2010:06:20 20:07:39"`, + CompressedBitsPerPixel: `"3/1"`, + MeteringMode: `5`, + MakerNote: `""`, + UserComment: `""`, + ImageDescription: `" "`, + Model: `"Canon PowerShot SD1200 IS"`, + ExposureBiasValue: `"0/3"`, + PixelYDimension: `2736`, + FocalPlaneResolutionUnit: `2`, + }, + "2010-09-02-08-43-02-sep-2010-09-02-08-43-02a.jpg": map[FieldName]string{ + DateTime: `"2010:09:02 08:43:02"`, + ExposureProgram: `5`, + ExifVersion: `"0221"`, + CompressedBitsPerPixel: `"1/1"`, + Flash: `65`, + Saturation: `0`, + XResolution: `"72/1"`, + ISOSpeedRatings: `800`, + MaxApertureValue: `"362/100"`, + LightSource: `0`, + FocalLength: `"210/10"`, + ColorSpace: `1`, + Model: `"FE370,X880,C575 "`, + ThumbJPEGInterchangeFormat: `9204`, + SceneType: `""`, + DigitalZoomRatio: `"0/100"`, + SceneCaptureType: `3`, + GainControl: `2`, + Contrast: `0`, + DateTimeOriginal: `"2010:09:02 08:43:02"`, + MakerNote: `""`, + FNumber: `"53/10"`, + Sharpness: `0`, + ImageDescription: `"OLYMPUS DIGITAL CAMERA "`, + Software: `"Version 1.0 "`, + YCbCrPositioning: `2`, + ExifIFDPointer: `996`, + ExposureTime: `"10/500"`, + ComponentsConfiguration: `""`, + MeteringMode: `5`, + Orientation: `1`, + ResolutionUnit: `2`, + ExposureBiasValue: `"0/10"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `2448`, + FileSource: `""`, + WhiteBalance: `0`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormatLength: `3562`, + UserComment: `" "`, + PixelXDimension: `3264`, + InteroperabilityIFDPointer: `1714`, + CustomRendered: `0`, + ExposureMode: `0`, + InteroperabilityIndex: `"R98"`, + Make: `"OLYMPUS IMAGING CORP. "`, + DateTimeDigitized: `"2010:09:02 08:43:02"`, + }, + "2011-01-24-22-06-02-sep-2011-01-24-22-06-02a.jpg": map[FieldName]string{ + ColorSpace: `1`, + WhiteBalance: `0`, + ThumbJPEGInterchangeFormat: `25601`, + ThumbJPEGInterchangeFormatLength: `3385`, + ExifIFDPointer: `157`, + PixelXDimension: `1200`, + MakerNote: `""`, + DateTimeDigitized: `"2011:01:24 22:06:02"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + Software: `"V 12.40"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `1600`, + Model: `"6350"`, + YResolution: `"300/1"`, + XResolution: `"300/1"`, + ExifVersion: `"0220"`, + ComponentsConfiguration: `""`, + CustomRendered: `0`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Make: `"Nokia"`, + Orientation: `1`, + DigitalZoomRatio: `"1024/1024"`, + DateTimeOriginal: `"2011:01:24 22:06:02"`, + }, + "2011-03-07-09-28-03-sep-2011-03-07-09-28-03a.jpg": map[FieldName]string{ + Model: `"GU295"`, + Software: `"GU295-MSM1530032L-V10i-APR-22-2010-ATT-US"`, + Make: `"LG Elec."`, + Orientation: `1`, + XResolution: `"72/1"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `538`, + CustomRendered: `1`, + DigitalZoomRatio: `"0/0"`, + Contrast: `0`, + ExposureProgram: `2`, + FileSource: `""`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + DateTimeDigitized: `"2011:03:07 09:28:03"`, + PixelXDimension: `1280`, + SceneType: `""`, + YResolution: `"72/1"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `960`, + ExifVersion: `"0220"`, + DateTimeOriginal: `"2011:03:07 09:28:03"`, + ExposureMode: `0`, + ThumbJPEGInterchangeFormat: `662`, + ThumbJPEGInterchangeFormatLength: `9850`, + ColorSpace: `1`, + WhiteBalance: `0`, + Sharpness: `0`, + ExifIFDPointer: `224`, + BrightnessValue: `"0/1024"`, + MeteringMode: `2`, + Saturation: `0`, + InteroperabilityIndex: `"R98"`, + }, + "2011-05-07-13-02-49-sep-2011-05-07-13-02-49a.jpg": map[FieldName]string{ + DateTimeOriginal: `"2011:05:07 13:02:49"`, + SceneType: `""`, + Contrast: `0`, + Software: `"M7500BSAAAAAAD3050"`, + GPSVersionID: `[2,2,0,0]`, + GPSLatitudeRef: `"N"`, + GPSAltitude: `"0/1"`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `218`, + GPSInfoIFDPointer: `502`, + ComponentsConfiguration: `""`, + GPSLongitudeRef: `"E"`, + GPSTimeStamp: `["19/1","3/1","43/1"]`, + GPSDateStamp: `"2011:05:07 "`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormatLength: `22806`, + FlashpixVersion: `"0100"`, + PixelYDimension: `1536`, + FileSource: `""`, + WhiteBalance: `0`, + GPSAltitudeRef: `0`, + Make: `"HTC"`, + DateTimeDigitized: `"2011:05:07 13:02:49"`, + PixelXDimension: `2048`, + InteroperabilityIFDPointer: `472`, + GPSLatitude: `["0/1","0/1","0/100"]`, + InteroperabilityIndex: `"R98"`, + GPSLongitude: `["0/1","0/1","0/100"]`, + XResolution: `"72/1"`, + ExifVersion: `"0220"`, + GPSProcessingMethod: `"ASCIIHYBRID-FIX"`, + Model: `"RAPH800"`, + ThumbJPEGInterchangeFormat: `920`, + ColorSpace: `1`, + GPSMapDatum: `"WGS-84"`, + }, + "2011-08-07-19-22-57-sep-2011-08-07-19-22-57a.jpg": map[FieldName]string{ + ResolutionUnit: `2`, + DateTimeDigitized: `"2011:08:07 19:22:57"`, + SensingMethod: `2`, + SceneType: `""`, + YResolution: `"300/1"`, + DateTime: `"2011:08:11 09:46:32"`, + ApertureValue: `"433985/100000"`, + ExposureBiasValue: `"2/6"`, + LightSource: `0`, + FocalLength: `"620/10"`, + ExifVersion: `"0221"`, + DateTimeOriginal: `"2011:08:07 19:22:57"`, + Flash: `7`, + ExposureMode: `0`, + ThumbJPEGInterchangeFormat: `802`, + ThumbJPEGInterchangeFormatLength: `9117`, + MaxApertureValue: `"43/10"`, + WhiteBalance: `0`, + Sharpness: `0`, + ExifIFDPointer: `186`, + ExposureTime: `"1/30"`, + MeteringMode: `2`, + GainControl: `1`, + Saturation: `0`, + SubjectDistanceRange: `0`, + Model: `"NIKON D200"`, + Software: `"Ver.1.00"`, + ShutterSpeedValue: `"4906891/1000000"`, + SubSecTimeOriginal: `"65"`, + SubSecTimeDigitized: `"65"`, + FocalLengthIn35mmFilm: `93`, + SceneCaptureType: `0`, + Make: `"NIKON CORPORATION"`, + XResolution: `"300/1"`, + ISOSpeedRatings: `400`, + CFAPattern: `""`, + CustomRendered: `0`, + DigitalZoomRatio: `"1/1"`, + Contrast: `0`, + FNumber: `"45/10"`, + ExposureProgram: `3`, + SubjectDistance: `"63/100"`, + FileSource: `""`, + }, + "2011-10-28-17-50-18-sep-2011-10-28-17-50-18a.jpg": map[FieldName]string{ + SubSecTime: `"92"`, + CustomRendered: `0`, + ExposureMode: `0`, + SceneCaptureType: `0`, + UserComment: `""`, + DateTime: `"2011:11:08 07:27:55"`, + FNumber: `"4/1"`, + ExposureProgram: `2`, + SubSecTimeOriginal: `"92"`, + GPSVersionID: `[2,2,0,0]`, + Software: `"Adobe Photoshop CS4 Macintosh"`, + ExifIFDPointer: `364`, + ExposureTime: `"1/60"`, + ISOSpeedRatings: `800`, + ComponentsConfiguration: `""`, + XResolution: `"720000/10000"`, + ThumbJPEGInterchangeFormatLength: `6186`, + ShutterSpeedValue: `"393216/65536"`, + ExposureBiasValue: `"0/1"`, + FocalLength: `"34/1"`, + ColorSpace: `65535`, + PixelYDimension: `864`, + FocalPlaneXResolution: `"5616000/1459"`, + Model: `"Canon EOS 5D Mark II"`, + DateTimeOriginal: `"2011:10:28 17:50:18"`, + DateTimeDigitized: `"2011:10:28 17:50:18"`, + PixelXDimension: `576`, + InteroperabilityIFDPointer: `1120`, + InteroperabilityIndex: `"R03"`, + Make: `"Canon"`, + FocalPlaneResolutionUnit: `2`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + GPSInfoIFDPointer: `1152`, + ExifVersion: `"0221"`, + MeteringMode: `5`, + Flash: `9`, + FocalPlaneYResolution: `"3744000/958"`, + Orientation: `1`, + ThumbJPEGInterchangeFormat: `1266`, + ApertureValue: `"262144/65536"`, + SubSecTimeDigitized: `"92"`, + FlashpixVersion: `"0100"`, + WhiteBalance: `1`, + YResolution: `"720000/10000"`, + }, + "2011-10-28-18-25-43-sep-2011-10-28-18-25-43.jpg": map[FieldName]string{ + SubSecTimeOriginal: `"50"`, + DateTime: `"2011:10:28 18:25:43"`, + ISOSpeedRatings: `1250`, + ComponentsConfiguration: `""`, + Saturation: `0`, + XResolution: `"300/1"`, + ExposureTime: `"10/600"`, + ColorSpace: `1`, + ThumbJPEGInterchangeFormatLength: `3670`, + LightSource: `0`, + FocalLength: `"800/10"`, + CFAPattern: `""`, + DigitalZoomRatio: `"1/1"`, + InteroperabilityIndex: `"R98"`, + Make: `"NIKON CORPORATION"`, + DateTimeOriginal: `"2011:10:28 18:25:43"`, + InteroperabilityIFDPointer: `3604`, + Sharpness: `0`, + ExifVersion: `"0221"`, + MeteringMode: `5`, + Flash: `31`, + FocalLengthIn35mmFilm: `120`, + Orientation: `1`, + YCbCrPositioning: `2`, + SubSecTimeDigitized: `"50"`, + WhiteBalance: `0`, + ThumbJPEGInterchangeFormat: `3728`, + SensingMethod: `2`, + CustomRendered: `0`, + SubSecTime: `"50"`, + ExposureProgram: `0`, + Software: `"Ver.1.11 "`, + FNumber: `"56/10"`, + CompressedBitsPerPixel: `"2/1"`, + ExifIFDPointer: `208`, + FileSource: `""`, + Model: `"NIKON D80"`, + ExposureBiasValue: `"0/6"`, + PixelYDimension: `537`, + GainControl: `2`, + DateTimeDigitized: `"2011:10:28 18:25:43"`, + PixelXDimension: `800`, + SceneType: `""`, + ImageUniqueID: `"7fa4f6d028df5f2fc1bad8102be81064"`, + SubjectDistanceRange: `0`, + ResolutionUnit: `2`, + FlashpixVersion: `"0100"`, + YResolution: `"300/1"`, + MaxApertureValue: `"50/10"`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Contrast: `0`, + MakerNote: `""`, + UserComment: `"ASCII "`, + }, + "2011-11-18-15-38-34-sep-Photo11181538.jpg": map[FieldName]string{ + WhiteBalance: `0`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormat: `642`, + FlashpixVersion: `"0100"`, + CustomRendered: `1`, + ExposureMode: `0`, + Contrast: `1`, + Software: `"M6290A-KPVMZL-2.6.0140T"`, + ExposureProgram: `2`, + Saturation: `0`, + XResolution: `"72/1"`, + ExifIFDPointer: `204`, + ComponentsConfiguration: `""`, + PixelYDimension: `1200`, + FileSource: `""`, + Model: `"P2020"`, + ThumbJPEGInterchangeFormatLength: `12226`, + ColorSpace: `1`, + BrightnessValue: `"0/1024"`, + PixelXDimension: `1600`, + InteroperabilityIFDPointer: `518`, + SceneType: `""`, + DigitalZoomRatio: `"0/0"`, + Make: `"PANTECH"`, + DateTimeOriginal: `"2011:11:18 15:38:34"`, + DateTimeDigitized: `"2011:11:18 15:38:34"`, + InteroperabilityIndex: `"R98"`, + Sharpness: `0`, + ExifVersion: `"0220"`, + MeteringMode: `2`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + }, + "2012-06-02-10-12-28-sep-2012-06-02-10-12-28.jpg": map[FieldName]string{ + YResolution: `"180/1"`, + Software: `"Ver.1.0 "`, + DateTime: `"2012:06:02 10:12:28"`, + LightSource: `0`, + FocalLength: `"50/10"`, + FlashpixVersion: `"0100"`, + SceneCaptureType: `0`, + Contrast: `0`, + Make: `"Panasonic"`, + DateTimeOriginal: `"2012:06:02 10:12:28"`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `10506`, + ExposureMode: `0`, + FocalLengthIn35mmFilm: `28`, + FNumber: `"33/10"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + Sharpness: `0`, + GainControl: `0`, + Saturation: `0`, + DateTimeDigitized: `"2012:06:02 10:12:28"`, + CompressedBitsPerPixel: `"4/1"`, + MeteringMode: `5`, + MakerNote: `""`, + PixelXDimension: `4608`, + SensingMethod: `2`, + Model: `"DMC-FH25"`, + ExposureBiasValue: `"0/100"`, + PixelYDimension: `3456`, + DigitalZoomRatio: `"0/10"`, + Orientation: `1`, + XResolution: `"180/1"`, + ISOSpeedRatings: `100`, + ExifVersion: `"0230"`, + Flash: `16`, + CustomRendered: `0`, + ThumbJPEGInterchangeFormat: `11764`, + ThumbJPEGInterchangeFormatLength: `7486`, + ExposureProgram: `2`, + MaxApertureValue: `"441/128"`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `636`, + ExposureTime: `"10/4000"`, + SceneType: `""`, + InteroperabilityIndex: `"R98"`, + }, + "2012-09-21-22-07-34-sep-2012-09-21-22-07-34.jpg": map[FieldName]string{ + ThumbJPEGInterchangeFormat: `5108`, + ThumbJPEGInterchangeFormatLength: `4855`, + MaxApertureValue: `"95/32"`, + ColorSpace: `1`, + WhiteBalance: `0`, + ExifIFDPointer: `240`, + ExposureTime: `"1/60"`, + CompressedBitsPerPixel: `"3/1"`, + MeteringMode: `5`, + UserComment: `""`, + InteroperabilityIndex: `"R98"`, + ImageDescription: `" "`, + Model: `"Canon PowerShot SD940 IS"`, + ShutterSpeedValue: `"189/32"`, + FocalPlaneResolutionUnit: `2`, + CustomRendered: `0`, + Make: `"Canon"`, + Orientation: `1`, + XResolution: `"180/1"`, + ISOSpeedRatings: `500`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `3288`, + FocalPlaneYResolution: `"2448000/183"`, + DigitalZoomRatio: `"4000/4000"`, + SceneCaptureType: `2`, + FNumber: `"28/10"`, + FileSource: `""`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + DateTimeDigitized: `"2012:09:21 22:07:34"`, + MakerNote: `""`, + PixelXDimension: `3264`, + SensingMethod: `2`, + FocalPlaneXResolution: `"3264000/244"`, + YResolution: `"180/1"`, + DateTime: `"2012:09:21 22:07:34"`, + ApertureValue: `"95/32"`, + ExposureBiasValue: `"0/3"`, + FocalLength: `"5000/1000"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `2448`, + ExifVersion: `"0221"`, + DateTimeOriginal: `"2012:09:21 22:07:34"`, + Flash: `25`, + ExposureMode: `0`, + }, + "2012-12-19-21-38-40-sep-temple_square1.jpg": map[FieldName]string{ + InteroperabilityIFDPointer: `322`, + GPSLatitude: `["40/1","46/1","1322/100"]`, + InteroperabilityIndex: `"R98"`, + Make: `"HTC"`, + DateTimeOriginal: `"2012:12:19 21:38:40"`, + DateTimeDigitized: `"2012:12:19 21:38:40"`, + PixelXDimension: `3264`, + GPSLatitudeRef: `"N"`, + GPSLongitude: `["111/1","53/1","2840/100"]`, + GPSLongitudeRef: `"W"`, + GPSProcessingMethod: `"ASCIIGPS"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + GPSInfoIFDPointer: `352`, + ExifVersion: `"0220"`, + GPSMapDatum: `"WGS-84"`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormat: `696`, + FlashpixVersion: `"0100"`, + GPSAltitudeRef: `0`, + GPSVersionID: `[2,2,0]`, + GPSAltitude: `"1334/1"`, + GPSTimeStamp: `["4/1","38/1","40/1"]`, + XResolution: `"72/1"`, + ExifIFDPointer: `136`, + ISOSpeedRatings: `801`, + ComponentsConfiguration: `""`, + PixelYDimension: `1952`, + GPSDateStamp: `"2012:12:20"`, + Model: `"ADR6400L"`, + ThumbJPEGInterchangeFormatLength: `38469`, + FocalLength: `"457/100"`, + ColorSpace: `1`, + }, + "2012-12-21-11-15-19-sep-IMG_0001.jpg": map[FieldName]string{ + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `360`, + ExposureTime: `"1/30"`, + InteroperabilityIndex: `"R98"`, + SubSecTimeDigitized: `"00"`, + FlashpixVersion: `"0100"`, + FocalPlaneXResolution: `"5184000/894"`, + YResolution: `"72/1"`, + DateTime: `"2012:12:21 11:15:19"`, + ShutterSpeedValue: `"327680/65536"`, + ApertureValue: `"286720/65536"`, + FocalLength: `"24/1"`, + LensModel: `"EF-S18-55mm f/3.5-5.6 IS II"`, + GPSVersionID: `[2,3,0,0]`, + InteroperabilityIFDPointer: `8806`, + ExposureMode: `0`, + SceneCaptureType: `0`, + Make: `"Canon"`, + GPSInfoIFDPointer: `9034`, + DateTimeOriginal: `"2012:12:21 11:15:19"`, + ComponentsConfiguration: `""`, + SubSecTime: `"00"`, + Artist: `""`, + FNumber: `"45/10"`, + ColorSpace: `1`, + WhiteBalance: `0`, + DateTimeDigitized: `"2012:12:21 11:15:19"`, + MeteringMode: `5`, + MakerNote: `""`, + UserComment: `""`, + PixelXDimension: `5184`, + FocalPlaneResolutionUnit: `2`, + Model: `"Canon EOS REBEL T4i"`, + Copyright: `""`, + ExposureBiasValue: `"0/1"`, + SubSecTimeOriginal: `"00"`, + PixelYDimension: `3456`, + FocalPlaneYResolution: `"3456000/597"`, + CustomRendered: `0`, + Orientation: `1`, + XResolution: `"72/1"`, + ISOSpeedRatings: `1600`, + ExifVersion: `"0230"`, + Flash: `16`, + ThumbJPEGInterchangeFormat: `10924`, + ThumbJPEGInterchangeFormatLength: `14327`, + ExposureProgram: `0`, + }, + "2013-02-05-23-12-09-sep-DSCI0001.jpg": map[FieldName]string{ + ApertureValue: `"3072/1000"`, + ExposureBiasValue: `"0/10"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `1200`, + FileSource: `""`, + YResolution: `"288/3"`, + ThumbJPEGInterchangeFormatLength: `5863`, + ShutterSpeedValue: `"5907/1000"`, + WhiteBalance: `0`, + InteroperabilityIFDPointer: `4838`, + ExposureMode: `0`, + InteroperabilityIndex: `"R98"`, + Make: `"Polaroid"`, + DateTimeDigitized: `"2013:02:05 23:12:09"`, + PixelXDimension: `1600`, + DateTime: `"2013:02:05 23:12:09"`, + ExposureProgram: `2`, + CompressedBitsPerPixel: `"3766184/1920000"`, + Flash: `1`, + FocalLengthIn35mmFilm: `35`, + XResolution: `"288/3"`, + ISOSpeedRatings: `100`, + ExifVersion: `"0210"`, + MaxApertureValue: `"3072/1000"`, + LightSource: `0`, + FocalLength: `"5954/1000"`, + ColorSpace: `1`, + Model: `"Polaroid i532"`, + Copyright: `"Copyright 2005"`, + ThumbJPEGInterchangeFormat: `4974`, + SceneType: `""`, + DigitalZoomRatio: `"100/100"`, + SceneCaptureType: `0`, + DateTimeOriginal: `"2013:02:05 23:12:09"`, + MakerNote: `" BARCODE:A265KS008000; ZP:812; FP:124; AWB:235,679; PWB:476,304; PMF:12,11610; LV:493; LUM:3-8-9-8-1-11;20;26;19;10;A:1,F1:6,F2:18;ET:145, W:2, F:3 ;FV: 41FV: 36FV: 43FV: 223FV: 258FV: 9FV: 466FV: 216FP: 10FP: 8FP: 6FP: 6FP: 6FP: 0FP: 8FP: 8AFS: 110"`, + SensingMethod: `2`, + Sharpness: `0`, + ImageDescription: `""`, + Software: `" 1.0"`, + FNumber: `"28/10"`, + ExifIFDPointer: `240`, + ExposureTime: `"1/60"`, + ComponentsConfiguration: `""`, + MeteringMode: `3`, + Orientation: `1`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + }, + "2099-08-12-19-59-29-sep-2099-08-12-19-59-29a.jpg": map[FieldName]string{ + Model: `"NIKON D70s"`, + ExposureBiasValue: `"0/6"`, + SubSecTimeOriginal: `"00"`, + PixelYDimension: `2000`, + CFAPattern: `""`, + CustomRendered: `0`, + DigitalZoomRatio: `"1/1"`, + Orientation: `1`, + XResolution: `"300/1"`, + ExifVersion: `"0221"`, + Flash: `31`, + ThumbJPEGInterchangeFormat: `28588`, + ThumbJPEGInterchangeFormatLength: `8886`, + ExposureProgram: `0`, + MaxApertureValue: `"36/10"`, + SceneType: `""`, + InteroperabilityIndex: `"R98"`, + ResolutionUnit: `2`, + YCbCrPositioning: `2`, + ExifIFDPointer: `216`, + ExposureTime: `"10/600"`, + FocalLength: `"180/10"`, + SubSecTimeDigitized: `"00"`, + FlashpixVersion: `"0100"`, + YResolution: `"300/1"`, + Software: `"Ver.1.00 "`, + DateTime: `"2099:08:12 19:59:29"`, + LightSource: `0`, + InteroperabilityIFDPointer: `28448`, + ExposureMode: `0`, + FocalLengthIn35mmFilm: `27`, + SceneCaptureType: `0`, + Make: `"NIKON CORPORATION"`, + DateTimeOriginal: `"2099:08:12 19:59:29"`, + ComponentsConfiguration: `""`, + SubSecTime: `"00"`, + Contrast: `1`, + Sharpness: `0`, + FNumber: `"35/10"`, + ColorSpace: `1`, + FileSource: `""`, + WhiteBalance: `0`, + UserComment: `"ASCII "`, + PixelXDimension: `3008`, + SensingMethod: `2`, + GainControl: `0`, + DateTimeDigitized: `"2099:08:12 19:59:29"`, + CompressedBitsPerPixel: `"2/1"`, + MeteringMode: `5`, + MakerNote: `""`, + Saturation: `0`, + SubjectDistanceRange: `0`, + }, + "2216-11-15-11-46-51-sep-2216-11-15-11-46-51a.jpg": map[FieldName]string{ + ExposureProgram: `2`, + FileSource: `""`, + FNumber: `"480/100"`, + YCbCrPositioning: `2`, + DateTimeDigitized: `"2216:11:15 11:46:51"`, + MakerNote: `""`, + PixelXDimension: `3296`, + SensingMethod: `2`, + SceneType: `""`, + ResolutionUnit: `2`, + ApertureValue: `"452/100"`, + ExposureBiasValue: `"0/10"`, + LightSource: `0`, + FocalLength: `"60/10"`, + FlashpixVersion: `"0100"`, + PixelYDimension: `2472`, + YResolution: `"480/1"`, + DateTimeOriginal: `"2216:11:15 11:46:51"`, + Flash: `24`, + ExposureMode: `0`, + ExifVersion: `"0221"`, + ThumbJPEGInterchangeFormatLength: `5175`, + MaxApertureValue: `"286/100"`, + ColorSpace: `1`, + ExposureIndex: `"80/1"`, + WhiteBalance: `0`, + Sharpness: `0`, + ThumbJPEGInterchangeFormat: `17818`, + ExposureTime: `"1016/1000000"`, + MeteringMode: `5`, + GainControl: `0`, + Saturation: `0`, + SubjectDistanceRange: `0`, + ExifIFDPointer: `2316`, + Software: `"KODAK EASYSHARE C813 ZOOM DIGITAL CAMERA"`, + ShutterSpeedValue: `"994/100"`, + Model: `"KODAK EASYSHARE C813 ZOOM DIGITAL CAMERA"`, + Orientation: `1`, + XResolution: `"480/1"`, + ISOSpeedRatings: `80`, + ComponentsConfiguration: `""`, + InteroperabilityIFDPointer: `17674`, + CustomRendered: `0`, + DigitalZoomRatio: `"0/10"`, + Make: `"EASTMAN KODAK COMPANY"`, + SceneCaptureType: `0`, + Contrast: `0`, + FocalLengthIn35mmFilm: `36`, + }, + "FailedHash-NoDate-sep-remembory.jpg": map[FieldName]string{ + Model: `"MFC-7840W"`, + YResolution: `"150/1"`, + Software: `"Apple Image Capture"`, + PixelYDimension: `1626`, + ExifIFDPointer: `192`, + PixelXDimension: `1232`, + Make: `"Brother"`, + Orientation: `1`, + XResolution: `"150/1"`, + ResolutionUnit: `2`, + }, + "f1-exif.jpg": map[FieldName]string{ + PixelXDimension: `0`, + Orientation: `1`, + XResolution: `"72/1"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `134`, + ExifVersion: `"0210"`, + ComponentsConfiguration: `""`, + YResolution: `"72/1"`, + DateTime: `"2012:11:04 05:42:02"`, + FlashpixVersion: `"0100"`, + ColorSpace: `65535`, + PixelYDimension: `0`, + }, + "f2-exif.jpg": map[FieldName]string{ + FlashpixVersion: `"0100"`, + ColorSpace: `65535`, + PixelYDimension: `0`, + YResolution: `"72/1"`, + DateTime: `"2012:11:04 05:42:32"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `134`, + ExifVersion: `"0210"`, + ComponentsConfiguration: `""`, + PixelXDimension: `0`, + Orientation: `2`, + XResolution: `"72/1"`, + }, + "f3-exif.jpg": map[FieldName]string{ + FlashpixVersion: `"0100"`, + ColorSpace: `65535`, + PixelYDimension: `0`, + YResolution: `"72/1"`, + DateTime: `"2012:11:04 05:42:32"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `134`, + ExifVersion: `"0210"`, + ComponentsConfiguration: `""`, + PixelXDimension: `0`, + Orientation: `3`, + XResolution: `"72/1"`, + }, + "f4-exif.jpg": map[FieldName]string{ + ExifVersion: `"0210"`, + ComponentsConfiguration: `""`, + PixelXDimension: `0`, + Orientation: `4`, + XResolution: `"72/1"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `134`, + YResolution: `"72/1"`, + DateTime: `"2012:11:04 05:42:32"`, + FlashpixVersion: `"0100"`, + ColorSpace: `65535`, + PixelYDimension: `0`, + }, + "f5-exif.jpg": map[FieldName]string{ + ExifIFDPointer: `134`, + ExifVersion: `"0210"`, + ComponentsConfiguration: `""`, + PixelXDimension: `0`, + Orientation: `5`, + XResolution: `"72/1"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + PixelYDimension: `0`, + YResolution: `"72/1"`, + DateTime: `"2012:11:04 05:42:32"`, + FlashpixVersion: `"0100"`, + ColorSpace: `65535`, + }, + "f6-exif.jpg": map[FieldName]string{ + YResolution: `"72/1"`, + DateTime: `"2012:11:04 05:42:32"`, + FlashpixVersion: `"0100"`, + ColorSpace: `65535`, + PixelYDimension: `0`, + ExifVersion: `"0210"`, + ComponentsConfiguration: `""`, + PixelXDimension: `0`, + Orientation: `6`, + XResolution: `"72/1"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `134`, + }, + "f7-exif.jpg": map[FieldName]string{ + ExifIFDPointer: `134`, + ExifVersion: `"0210"`, + ComponentsConfiguration: `""`, + PixelXDimension: `0`, + Orientation: `7`, + XResolution: `"72/1"`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + PixelYDimension: `0`, + YResolution: `"72/1"`, + DateTime: `"2012:11:04 05:42:32"`, + FlashpixVersion: `"0100"`, + ColorSpace: `65535`, + }, + "f8-exif.jpg": map[FieldName]string{ + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + ExifIFDPointer: `134`, + ExifVersion: `"0210"`, + ComponentsConfiguration: `""`, + PixelXDimension: `0`, + Orientation: `8`, + XResolution: `"72/1"`, + FlashpixVersion: `"0100"`, + ColorSpace: `65535`, + PixelYDimension: `0`, + YResolution: `"72/1"`, + DateTime: `"2012:11:04 05:42:32"`, + }, + "geodegrees_as_string.jpg": map[FieldName]string{ + GPSAltitudeRef: `0`, + ThumbJPEGInterchangeFormat: `539`, + ThumbJPEGInterchangeFormatLength: `13132`, + WhiteBalance: `0`, + ExposureProgram: `0`, + Sharpness: `2`, + ExifIFDPointer: `114`, + ExposureTime: `"0/1024"`, + Saturation: `0`, + GPSLatitude: `"52,00000,50,00000,34,01180"`, + GPSTimeStamp: `"17,00000,8,00000,29,00000"`, + Model: `"HTC One_M8"`, + ApertureValue: `"2048/1024"`, + FocalLength: `"3072/1024"`, + GPSLatitudeRef: `"N"`, + GPSLongitude: `"11,00000,10,00000,58,28360"`, + GPSAltitude: `"0/1024"`, + GPSLongitudeRef: `"E"`, + GPSProcessingMethod: `"ASCII"`, + GPSInfoIFDPointer: `317`, + Make: `"HTC"`, + DateTimeOriginal: `"2014:04:26 19:09:19"`, + ISOSpeedRatings: `125`, + Contrast: `0`, + }, + "has-lens-info.jpg": map[FieldName]string{ + LensModel: `"iPhone 4S back camera 4.28mm f/2.4"`, + Model: `"iPhone 4S"`, + ThumbJPEGInterchangeFormatLength: `10875`, + ShutterSpeedValue: `"106906/10353"`, + FocalLength: `"107/25"`, + SubjectArea: `[1631,1223,881,881]`, + ColorSpace: `1`, + PixelYDimension: `2448`, + GPSLatitude: `["59/1","19/1","5717/100"]`, + Make: `"Apple"`, + DateTimeOriginal: `"2014:09:01 15:03:47"`, + DateTimeDigitized: `"2014:09:01 15:03:47"`, + BrightnessValue: `"3927/419"`, + PixelXDimension: `3264`, + SceneType: `""`, + LensMake: `"Apple"`, + GPSLatitudeRef: `"N"`, + GPSLongitude: `["18/1","3/1","5379/100"]`, + FocalLengthIn35mmFilm: `35`, + Orientation: `6`, + ResolutionUnit: `2`, + YCbCrPositioning: `1`, + GPSInfoIFDPointer: `948`, + ExifVersion: `"0221"`, + MeteringMode: `5`, + Flash: `16`, + GPSLongitudeRef: `"E"`, + YResolution: `"72/1"`, + ThumbJPEGInterchangeFormat: `1244`, + ApertureValue: `"4845/1918"`, + SubSecTimeDigitized: `"880"`, + FlashpixVersion: `"0100"`, + WhiteBalance: `0`, + GPSAltitudeRef: `0`, + MakerNote: `""`, + SensingMethod: `2`, + ExposureMode: `0`, + SceneCaptureType: `0`, + GPSImgDirection: `"18329/175"`, + Software: `"7.1.1"`, + DateTime: `"2014:09:01 15:03:47"`, + FNumber: `"12/5"`, + ExposureProgram: `2`, + SubSecTimeOriginal: `"880"`, + GPSImgDirectionRef: `"T"`, + XResolution: `"72/1"`, + ExifIFDPointer: `204`, + ExposureTime: `"1/1284"`, + ISOSpeedRatings: `50`, + ComponentsConfiguration: `""`, + GPSAltitude: `"29/1"`, + GPSTimeStamp: `["13/1","3/1","4279/100"]`, + }, +} diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/tiff/tag.go b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/tiff/tag.go new file mode 100644 index 000000000..66b68e334 --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/tiff/tag.go @@ -0,0 +1,438 @@ +package tiff + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "math/big" + "strings" + "unicode" + "unicode/utf8" +) + +// Format specifies the Go type equivalent used to represent the basic +// tiff data types. +type Format int + +const ( + IntVal Format = iota + FloatVal + RatVal + StringVal + UndefVal + OtherVal +) + +var ErrShortReadTagValue = errors.New("tiff: short read of tag value") + +var formatNames = map[Format]string{ + IntVal: "int", + FloatVal: "float", + RatVal: "rational", + StringVal: "string", + UndefVal: "undefined", + OtherVal: "other", +} + +// DataType represents the basic tiff tag data types. +type DataType uint16 + +const ( + DTByte DataType = 1 + DTAscii = 2 + DTShort = 3 + DTLong = 4 + DTRational = 5 + DTSByte = 6 + DTUndefined = 7 + DTSShort = 8 + DTSLong = 9 + DTSRational = 10 + DTFloat = 11 + DTDouble = 12 +) + +var typeNames = map[DataType]string{ + DTByte: "byte", + DTAscii: "ascii", + DTShort: "short", + DTLong: "long", + DTRational: "rational", + DTSByte: "signed byte", + DTUndefined: "undefined", + DTSShort: "signed short", + DTSLong: "signed long", + DTSRational: "signed rational", + DTFloat: "float", + DTDouble: "double", +} + +// typeSize specifies the size in bytes of each type. +var typeSize = map[DataType]uint32{ + DTByte: 1, + DTAscii: 1, + DTShort: 2, + DTLong: 4, + DTRational: 8, + DTSByte: 1, + DTUndefined: 1, + DTSShort: 2, + DTSLong: 4, + DTSRational: 8, + DTFloat: 4, + DTDouble: 8, +} + +// Tag reflects the parsed content of a tiff IFD tag. +type Tag struct { + // Id is the 2-byte tiff tag identifier. + Id uint16 + // Type is an integer (1 through 12) indicating the tag value's data type. + Type DataType + // Count is the number of type Type stored in the tag's value (i.e. the + // tag's value is an array of type Type and length Count). + Count uint32 + // Val holds the bytes that represent the tag's value. + Val []byte + // ValOffset holds byte offset of the tag value w.r.t. the beginning of the + // reader it was decoded from. Zero if the tag value fit inside the offset + // field. + ValOffset uint32 + + order binary.ByteOrder + intVals []int64 + floatVals []float64 + ratVals [][]int64 + strVal string + format Format +} + +// DecodeTag parses a tiff-encoded IFD tag from r and returns a Tag object. The +// first read from r should be the first byte of the tag. ReadAt offsets should +// generally be relative to the beginning of the tiff structure (not relative +// to the beginning of the tag). +func DecodeTag(r ReadAtReader, order binary.ByteOrder) (*Tag, error) { + t := new(Tag) + t.order = order + + err := binary.Read(r, order, &t.Id) + if err != nil { + return nil, errors.New("tiff: tag id read failed: " + err.Error()) + } + + err = binary.Read(r, order, &t.Type) + if err != nil { + return nil, errors.New("tiff: tag type read failed: " + err.Error()) + } + + err = binary.Read(r, order, &t.Count) + if err != nil { + return nil, errors.New("tiff: tag component count read failed: " + err.Error()) + } + + // There seems to be a relatively common corrupt tag which has a Count of + // MaxUint32. This is probably not a valid value, so return early. + if t.Count == 1<<32-1 { + return t, errors.New("invalid Count offset in tag") + } + + valLen := typeSize[t.Type] * t.Count + if valLen == 0 { + return t, errors.New("zero length tag value") + } + + if valLen > 4 { + binary.Read(r, order, &t.ValOffset) + + // Use a bytes.Buffer so we don't allocate a huge slice if the tag + // is corrupt. + var buff bytes.Buffer + sr := io.NewSectionReader(r, int64(t.ValOffset), int64(valLen)) + n, err := io.Copy(&buff, sr) + if err != nil { + return t, errors.New("tiff: tag value read failed: " + err.Error()) + } else if n != int64(valLen) { + return t, ErrShortReadTagValue + } + t.Val = buff.Bytes() + + } else { + val := make([]byte, valLen) + if _, err = io.ReadFull(r, val); err != nil { + return t, errors.New("tiff: tag offset read failed: " + err.Error()) + } + // ignore padding. + if _, err = io.ReadFull(r, make([]byte, 4-valLen)); err != nil { + return t, errors.New("tiff: tag offset read failed: " + err.Error()) + } + + t.Val = val + } + + return t, t.convertVals() +} + +func (t *Tag) convertVals() error { + r := bytes.NewReader(t.Val) + + switch t.Type { + case DTAscii: + if len(t.Val) > 0 { + t.strVal = string(t.Val[:len(t.Val)-1]) // ignore the last byte (NULL). + } + case DTByte: + var v uint8 + t.intVals = make([]int64, int(t.Count)) + for i := range t.intVals { + err := binary.Read(r, t.order, &v) + if err != nil { + return err + } + t.intVals[i] = int64(v) + } + case DTShort: + var v uint16 + t.intVals = make([]int64, int(t.Count)) + for i := range t.intVals { + err := binary.Read(r, t.order, &v) + if err != nil { + return err + } + t.intVals[i] = int64(v) + } + case DTLong: + var v uint32 + t.intVals = make([]int64, int(t.Count)) + for i := range t.intVals { + err := binary.Read(r, t.order, &v) + if err != nil { + return err + } + t.intVals[i] = int64(v) + } + case DTSByte: + var v int8 + t.intVals = make([]int64, int(t.Count)) + for i := range t.intVals { + err := binary.Read(r, t.order, &v) + if err != nil { + return err + } + t.intVals[i] = int64(v) + } + case DTSShort: + var v int16 + t.intVals = make([]int64, int(t.Count)) + for i := range t.intVals { + err := binary.Read(r, t.order, &v) + if err != nil { + return err + } + t.intVals[i] = int64(v) + } + case DTSLong: + var v int32 + t.intVals = make([]int64, int(t.Count)) + for i := range t.intVals { + err := binary.Read(r, t.order, &v) + if err != nil { + return err + } + t.intVals[i] = int64(v) + } + case DTRational: + t.ratVals = make([][]int64, int(t.Count)) + for i := range t.ratVals { + var n, d uint32 + err := binary.Read(r, t.order, &n) + if err != nil { + return err + } + err = binary.Read(r, t.order, &d) + if err != nil { + return err + } + t.ratVals[i] = []int64{int64(n), int64(d)} + } + case DTSRational: + t.ratVals = make([][]int64, int(t.Count)) + for i := range t.ratVals { + var n, d int32 + err := binary.Read(r, t.order, &n) + if err != nil { + return err + } + err = binary.Read(r, t.order, &d) + if err != nil { + return err + } + t.ratVals[i] = []int64{int64(n), int64(d)} + } + case DTFloat: // float32 + t.floatVals = make([]float64, int(t.Count)) + for i := range t.floatVals { + var v float32 + err := binary.Read(r, t.order, &v) + if err != nil { + return err + } + t.floatVals[i] = float64(v) + } + case DTDouble: + t.floatVals = make([]float64, int(t.Count)) + for i := range t.floatVals { + var u float64 + err := binary.Read(r, t.order, &u) + if err != nil { + return err + } + t.floatVals[i] = u + } + } + + switch t.Type { + case DTByte, DTShort, DTLong, DTSByte, DTSShort, DTSLong: + t.format = IntVal + case DTRational, DTSRational: + t.format = RatVal + case DTFloat, DTDouble: + t.format = FloatVal + case DTAscii: + t.format = StringVal + case DTUndefined: + t.format = UndefVal + default: + t.format = OtherVal + } + + return nil +} + +// Format returns a value indicating which method can be called to retrieve the +// tag's value properly typed (e.g. integer, rational, etc.). +func (t *Tag) Format() Format { return t.format } + +func (t *Tag) typeErr(to Format) error { + return &wrongFmtErr{typeNames[t.Type], formatNames[to]} +} + +// Rat returns the tag's i'th value as a rational number. It returns a nil and +// an error if this tag's Format is not RatVal. It panics for zero deminators +// or if i is out of range. +func (t *Tag) Rat(i int) (*big.Rat, error) { + n, d, err := t.Rat2(i) + if err != nil { + return nil, err + } + return big.NewRat(n, d), nil +} + +// Rat2 returns the tag's i'th value as a rational number represented by a +// numerator-denominator pair. It returns an error if the tag's Format is not +// RatVal. It panics if i is out of range. +func (t *Tag) Rat2(i int) (num, den int64, err error) { + if t.format != RatVal { + return 0, 0, t.typeErr(RatVal) + } + return t.ratVals[i][0], t.ratVals[i][1], nil +} + +// Int64 returns the tag's i'th value as an integer. It returns an error if the +// tag's Format is not IntVal. It panics if i is out of range. +func (t *Tag) Int64(i int) (int64, error) { + if t.format != IntVal { + return 0, t.typeErr(IntVal) + } + return t.intVals[i], nil +} + +// Int returns the tag's i'th value as an integer. It returns an error if the +// tag's Format is not IntVal. It panics if i is out of range. +func (t *Tag) Int(i int) (int, error) { + if t.format != IntVal { + return 0, t.typeErr(IntVal) + } + return int(t.intVals[i]), nil +} + +// Float returns the tag's i'th value as a float. It returns an error if the +// tag's Format is not IntVal. It panics if i is out of range. +func (t *Tag) Float(i int) (float64, error) { + if t.format != FloatVal { + return 0, t.typeErr(FloatVal) + } + return t.floatVals[i], nil +} + +// StringVal returns the tag's value as a string. It returns an error if the +// tag's Format is not StringVal. It panics if i is out of range. +func (t *Tag) StringVal() (string, error) { + if t.format != StringVal { + return "", t.typeErr(StringVal) + } + return t.strVal, nil +} + +// String returns a nicely formatted version of the tag. +func (t *Tag) String() string { + data, err := t.MarshalJSON() + if err != nil { + return "ERROR: " + err.Error() + } + + if t.Count == 1 { + return strings.Trim(fmt.Sprintf("%s", data), "[]") + } + return fmt.Sprintf("%s", data) +} + +func (t *Tag) MarshalJSON() ([]byte, error) { + switch t.format { + case StringVal, UndefVal: + return nullString(t.Val), nil + case OtherVal: + return []byte(fmt.Sprintf("unknown tag type '%v'", t.Type)), nil + } + + rv := []string{} + for i := 0; i < int(t.Count); i++ { + switch t.format { + case RatVal: + n, d, _ := t.Rat2(i) + rv = append(rv, fmt.Sprintf(`"%v/%v"`, n, d)) + case FloatVal: + v, _ := t.Float(i) + rv = append(rv, fmt.Sprintf("%v", v)) + case IntVal: + v, _ := t.Int(i) + rv = append(rv, fmt.Sprintf("%v", v)) + } + } + return []byte(fmt.Sprintf(`[%s]`, strings.Join(rv, ","))), nil +} + +func nullString(in []byte) []byte { + rv := bytes.Buffer{} + rv.WriteByte('"') + for _, b := range in { + if unicode.IsPrint(rune(b)) { + rv.WriteByte(b) + } + } + rv.WriteByte('"') + rvb := rv.Bytes() + if utf8.Valid(rvb) { + return rvb + } + return []byte(`""`) +} + +type wrongFmtErr struct { + From, To string +} + +func (e *wrongFmtErr) Error() string { + return fmt.Sprintf("cannot convert tag type '%v' into '%v'", e.From, e.To) +} diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/tiff/tiff.go b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/tiff/tiff.go new file mode 100644 index 000000000..771e91878 --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/tiff/tiff.go @@ -0,0 +1,153 @@ +// Package tiff implements TIFF decoding as defined in TIFF 6.0 specification at +// http://partners.adobe.com/public/developer/en/tiff/TIFF6.pdf +package tiff + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "io/ioutil" +) + +// ReadAtReader is used when decoding Tiff tags and directories +type ReadAtReader interface { + io.Reader + io.ReaderAt +} + +// Tiff provides access to a decoded tiff data structure. +type Tiff struct { + // Dirs is an ordered slice of the tiff's Image File Directories (IFDs). + // The IFD at index 0 is IFD0. + Dirs []*Dir + // The tiff's byte-encoding (i.e. big/little endian). + Order binary.ByteOrder +} + +// Decode parses tiff-encoded data from r and returns a Tiff struct that +// reflects the structure and content of the tiff data. The first read from r +// should be the first byte of the tiff-encoded data and not necessarily the +// first byte of an os.File object. +func Decode(r io.Reader) (*Tiff, error) { + data, err := ioutil.ReadAll(r) + if err != nil { + return nil, errors.New("tiff: could not read data") + } + buf := bytes.NewReader(data) + + t := new(Tiff) + + // read byte order + bo := make([]byte, 2) + if _, err = io.ReadFull(buf, bo); err != nil { + return nil, errors.New("tiff: could not read tiff byte order") + } + if string(bo) == "II" { + t.Order = binary.LittleEndian + } else if string(bo) == "MM" { + t.Order = binary.BigEndian + } else { + return nil, errors.New("tiff: could not read tiff byte order") + } + + // check for special tiff marker + var sp int16 + err = binary.Read(buf, t.Order, &sp) + if err != nil || 42 != sp { + return nil, errors.New("tiff: could not find special tiff marker") + } + + // load offset to first IFD + var offset int32 + err = binary.Read(buf, t.Order, &offset) + if err != nil { + return nil, errors.New("tiff: could not read offset to first IFD") + } + + // load IFD's + var d *Dir + prev := offset + for offset != 0 { + // seek to offset + _, err := buf.Seek(int64(offset), 0) + if err != nil { + return nil, errors.New("tiff: seek to IFD failed") + } + + if buf.Len() == 0 { + return nil, errors.New("tiff: seek offset after EOF") + } + + // load the dir + d, offset, err = DecodeDir(buf, t.Order) + if err != nil { + return nil, err + } + + if offset == prev { + return nil, errors.New("tiff: recursive IFD") + } + prev = offset + + t.Dirs = append(t.Dirs, d) + } + + return t, nil +} + +func (tf *Tiff) String() string { + var buf bytes.Buffer + fmt.Fprint(&buf, "Tiff{") + for _, d := range tf.Dirs { + fmt.Fprintf(&buf, "%s, ", d.String()) + } + fmt.Fprintf(&buf, "}") + return buf.String() +} + +// Dir provides access to the parsed content of a tiff Image File Directory (IFD). +type Dir struct { + Tags []*Tag +} + +// DecodeDir parses a tiff-encoded IFD from r and returns a Dir object. offset +// is the offset to the next IFD. The first read from r should be at the first +// byte of the IFD. ReadAt offsets should generally be relative to the +// beginning of the tiff structure (not relative to the beginning of the IFD). +func DecodeDir(r ReadAtReader, order binary.ByteOrder) (d *Dir, offset int32, err error) { + d = new(Dir) + + // get num of tags in ifd + var nTags int16 + err = binary.Read(r, order, &nTags) + if err != nil { + return nil, 0, errors.New("tiff: failed to read IFD tag count: " + err.Error()) + } + + // load tags + for n := 0; n < int(nTags); n++ { + t, err := DecodeTag(r, order) + if err != nil { + return nil, 0, err + } + d.Tags = append(d.Tags, t) + } + + // get offset to next ifd + err = binary.Read(r, order, &offset) + if err != nil { + return nil, 0, errors.New("tiff: falied to read offset to next IFD: " + err.Error()) + } + + return d, offset, nil +} + +func (d *Dir) String() string { + s := "Dir{" + for _, t := range d.Tags { + s += t.String() + ", " + } + return s + "}" +} diff --git a/Godeps/_workspace/src/github.com/rwcarlsen/goexif/tiff/tiff_test.go b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/tiff/tiff_test.go new file mode 100644 index 000000000..5db348dc8 --- /dev/null +++ b/Godeps/_workspace/src/github.com/rwcarlsen/goexif/tiff/tiff_test.go @@ -0,0 +1,235 @@ +package tiff + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "flag" + "os" + "path/filepath" + "testing" +) + +var dataDir = flag.String("test_data_dir", ".", "Directory where the data files for testing are located") + +type input struct { + tgId string + tpe string + nVals string + offset string + val string +} + +type output struct { + id uint16 + typ DataType + count uint32 + val []byte +} + +type tagTest struct { + big input // big endian + little input // little endian + out output +} + +/////////////////////////////////////////////// +//// Big endian Tests ///////////////////////// +/////////////////////////////////////////////// +var set1 = []tagTest{ + //////////// string type ////////////// + tagTest{ + // {"TgId", "TYPE", "N-VALUES", "OFFSET--", "VAL..."}, + input{"0003", "0002", "00000002", "11000000", ""}, + input{"0300", "0200", "02000000", "11000000", ""}, + output{0x0003, DataType(0x0002), 0x0002, []byte{0x11, 0x00}}, + }, + tagTest{ + input{"0001", "0002", "00000006", "00000012", "111213141516"}, + input{"0100", "0200", "06000000", "12000000", "111213141516"}, + output{0x0001, DataType(0x0002), 0x0006, []byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16}}, + }, + //////////// int (1-byte) type //////////////// + tagTest{ + input{"0001", "0001", "00000001", "11000000", ""}, + input{"0100", "0100", "01000000", "11000000", ""}, + output{0x0001, DataType(0x0001), 0x0001, []byte{0x11}}, + }, + tagTest{ + input{"0001", "0001", "00000005", "00000010", "1112131415"}, + input{"0100", "0100", "05000000", "10000000", "1112131415"}, + output{0x0001, DataType(0x0001), 0x0005, []byte{0x11, 0x12, 0x13, 0x14, 0x15}}, + }, + tagTest{ + input{"0001", "0006", "00000001", "11000000", ""}, + input{"0100", "0600", "01000000", "11000000", ""}, + output{0x0001, DataType(0x0006), 0x0001, []byte{0x11}}, + }, + tagTest{ + input{"0001", "0006", "00000005", "00000010", "1112131415"}, + input{"0100", "0600", "05000000", "10000000", "1112131415"}, + output{0x0001, DataType(0x0006), 0x0005, []byte{0x11, 0x12, 0x13, 0x14, 0x15}}, + }, + //////////// int (2-byte) types //////////////// + tagTest{ + input{"0001", "0003", "00000002", "11111212", ""}, + input{"0100", "0300", "02000000", "11111212", ""}, + output{0x0001, DataType(0x0003), 0x0002, []byte{0x11, 0x11, 0x12, 0x12}}, + }, + tagTest{ + input{"0001", "0003", "00000003", "00000010", "111213141516"}, + input{"0100", "0300", "03000000", "10000000", "111213141516"}, + output{0x0001, DataType(0x0003), 0x0003, []byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16}}, + }, + tagTest{ + input{"0001", "0008", "00000001", "11120000", ""}, + input{"0100", "0800", "01000000", "11120000", ""}, + output{0x0001, DataType(0x0008), 0x0001, []byte{0x11, 0x12}}, + }, + tagTest{ + input{"0001", "0008", "00000003", "00000100", "111213141516"}, + input{"0100", "0800", "03000000", "00100000", "111213141516"}, + output{0x0001, DataType(0x0008), 0x0003, []byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16}}, + }, + //////////// int (4-byte) types //////////////// + tagTest{ + input{"0001", "0004", "00000001", "11121314", ""}, + input{"0100", "0400", "01000000", "11121314", ""}, + output{0x0001, DataType(0x0004), 0x0001, []byte{0x11, 0x12, 0x13, 0x14}}, + }, + tagTest{ + input{"0001", "0004", "00000002", "00000010", "1112131415161718"}, + input{"0100", "0400", "02000000", "10000000", "1112131415161718"}, + output{0x0001, DataType(0x0004), 0x0002, []byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}}, + }, + tagTest{ + input{"0001", "0009", "00000001", "11121314", ""}, + input{"0100", "0900", "01000000", "11121314", ""}, + output{0x0001, DataType(0x0009), 0x0001, []byte{0x11, 0x12, 0x13, 0x14}}, + }, + tagTest{ + input{"0001", "0009", "00000002", "00000011", "1112131415161819"}, + input{"0100", "0900", "02000000", "11000000", "1112131415161819"}, + output{0x0001, DataType(0x0009), 0x0002, []byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19}}, + }, + //////////// rational types //////////////////// + tagTest{ + input{"0001", "0005", "00000001", "00000010", "1112131415161718"}, + input{"0100", "0500", "01000000", "10000000", "1112131415161718"}, + output{0x0001, DataType(0x0005), 0x0001, []byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}}, + }, + tagTest{ + input{"0001", "000A", "00000001", "00000011", "1112131415161819"}, + input{"0100", "0A00", "01000000", "11000000", "1112131415161819"}, + output{0x0001, DataType(0x000A), 0x0001, []byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19}}, + }, + //////////// float types /////////////////////// + tagTest{ + input{"0001", "0005", "00000001", "00000010", "1112131415161718"}, + input{"0100", "0500", "01000000", "10000000", "1112131415161718"}, + output{0x0001, DataType(0x0005), 0x0001, []byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}}, + }, + tagTest{ + input{"0101", "000A", "00000001", "00000011", "1112131415161819"}, + input{"0101", "0A00", "01000000", "11000000", "1112131415161819"}, + output{0x0101, DataType(0x000A), 0x0001, []byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19}}, + }, +} + +func TestDecodeTag(t *testing.T) { + for i, tst := range set1 { + testSingle(t, binary.BigEndian, tst.big, tst.out, i) + testSingle(t, binary.LittleEndian, tst.little, tst.out, i) + } +} + +func testSingle(t *testing.T, order binary.ByteOrder, in input, out output, i int) { + data := buildInput(in, order) + buf := bytes.NewReader(data) + tg, err := DecodeTag(buf, order) + if err != nil { + t.Errorf("(%v) tag %v%+v decode failed: %v", order, i, in, err) + return + } + + if tg.Id != out.id { + t.Errorf("(%v) tag %v id decode: expected %v, got %v", order, i, out.id, tg.Id) + } + if tg.Type != out.typ { + t.Errorf("(%v) tag %v type decode: expected %v, got %v", order, i, out.typ, tg.Type) + } + if tg.Count != out.count { + t.Errorf("(%v) tag %v component count decode: expected %v, got %v", order, i, out.count, tg.Count) + } + if !bytes.Equal(tg.Val, out.val) { + t.Errorf("(%v) tag %v value decode: expected %v, got %v", order, i, out.val, tg.Val) + } +} + +// buildInputBig creates a byte-slice based on big-endian ordered input +func buildInput(in input, order binary.ByteOrder) []byte { + data := make([]byte, 0) + d, _ := hex.DecodeString(in.tgId) + data = append(data, d...) + d, _ = hex.DecodeString(in.tpe) + data = append(data, d...) + d, _ = hex.DecodeString(in.nVals) + data = append(data, d...) + d, _ = hex.DecodeString(in.offset) + data = append(data, d...) + + if in.val != "" { + off := order.Uint32(d) + for i := 0; i < int(off)-12; i++ { + data = append(data, 0xFF) + } + + d, _ = hex.DecodeString(in.val) + data = append(data, d...) + } + + return data +} + +func TestDecode(t *testing.T) { + name := filepath.Join(*dataDir, "sample1.tif") + f, err := os.Open(name) + if err != nil { + t.Fatalf("%v\n", err) + } + + tif, err := Decode(f) + if err != nil { + t.Fatal(err) + } + + t.Log(tif) +} + +func TestDecodeTag_blob(t *testing.T) { + buf := bytes.NewReader(data()) + buf.Seek(10, 1) + tg, err := DecodeTag(buf, binary.LittleEndian) + if err != nil { + t.Fatalf("tag decode failed: %v", err) + } + + t.Logf("tag: %v+\n", tg) + n, d, err := tg.Rat2(0) + if err != nil { + t.Fatalf("tag decoded wrong type: %v", err) + } + t.Logf("tag rat val: %v/%v\n", n, d) +} + +func data() []byte { + s1 := "49492A000800000002001A0105000100" + s1 += "00002600000069870400010000001102" + s1 += "0000000000004800000001000000" + + dat, err := hex.DecodeString(s1) + if err != nil { + panic("invalid string fixture") + } + return dat +} @@ -3,6 +3,8 @@ GOPATH ?= $(GOPATH:) GOFLAGS ?= $(GOFLAGS:) BUILD_NUMBER ?= $(BUILD_NUMBER:) +BUILD_DATE = $(shell date -u) +BUILD_HASH = $(shell git rev-parse HEAD) GO=$(GOPATH)/bin/godep go ESLINT=node_modules/eslint/bin/eslint.js @@ -32,6 +34,11 @@ all: travis travis: @echo building for travis + if [ "$(TRAVIS_DB)" = "postgres" ]; then \ + sed -i'.bak' 's|mysql|postgres|g' config/config.json; \ + sed -i'.bak' 's|mmuser:mostest@tcp(dockerhost:3306)/mattermost_test?charset=utf8mb4,utf8|postgres://mmuser:mostest@dockerhost:5432/mattermost_test?sslmode=disable\&connect_timeout=10|g' config/config.json; \ + fi + rm -Rf $(DIST_ROOT) @$(GO) clean $(GOFLAGS) -i ./... @@ -49,6 +56,10 @@ travis: exit 1; \ fi + @sed -i'.bak' 's|_BUILD_NUMBER_|$(BUILD_NUMBER)|g' ./model/version.go + @sed -i'.bak' 's|_BUILD_DATE_|$(BUILD_DATE)|g' ./model/version.go + @sed -i'.bak' 's|_BUILD_HASH_|$(BUILD_HASH)|g' ./model/version.go + @$(GO) build $(GOFLAGS) ./... @$(GO) install $(GOFLAGS) ./... @@ -222,6 +233,10 @@ cleandb: fi dist: install + @sed -i'.bak' 's|_BUILD_NUMBER_|$(BUILD_NUMBER)|g' ./model/version.go + @sed -i'.bak' 's|_BUILD_DATE_|$(BUILD_DATE)|g' ./model/version.go + @sed -i'.bak' 's|_BUILD_HASH_|$(BUILD_HASH)|g' ./model/version.go + @$(GO) build $(GOFLAGS) -i ./... @$(GO) install $(GOFLAGS) ./... @@ -48,8 +48,10 @@ There are multiple ways to install Mattermost depending on your needs. #### Development Install -- [Developer Machine Setup](doc/install/dev-setup.md) - Setup your local machine development environment using Docker on Mac OSX or Ubuntu. - +- [Developer Machine Setup](doc/install/dev-setup.md) - Setup your local machine development environment using Docker on Mac OSX or Ubuntu. Pull the latest stable release or pull the latest code from our development build. + +[![Build Status](https://travis-ci.org/mattermost/platform.svg?branch=master)](https://travis-ci.org/mattermost/platform) + #### Production Deployment (for Beta2 and later) Prior to production installation, please review [Mattermost system requirements](doc/install/requirements.md). diff --git a/api/admin.go b/api/admin.go index 6d7a9028f..646597755 100644 --- a/api/admin.go +++ b/api/admin.go @@ -7,6 +7,7 @@ import ( "bufio" "net/http" "os" + "strings" l4g "code.google.com/p/log4go" "github.com/gorilla/mux" @@ -20,6 +21,8 @@ func InitAdmin(r *mux.Router) { sr := r.PathPrefix("/admin").Subrouter() sr.Handle("/logs", ApiUserRequired(getLogs)).Methods("GET") + sr.Handle("/config", ApiUserRequired(getConfig)).Methods("GET") + sr.Handle("/save_config", ApiUserRequired(saveConfig)).Methods("POST") sr.Handle("/client_props", ApiAppHandler(getClientProperties)).Methods("GET") } @@ -33,7 +36,7 @@ func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { if utils.Cfg.LogSettings.FileEnable { - file, err := os.Open(utils.Cfg.LogSettings.FileLocation) + file, err := os.Open(utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation)) if err != nil { c.Err = model.NewAppError("getLogs", "Error reading log file", err.Error()) } @@ -54,3 +57,44 @@ func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { func getClientProperties(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(utils.ClientProperties))) } + +func getConfig(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasSystemAdminPermissions("getConfig") { + return + } + + json := utils.Cfg.ToJson() + cfg := model.ConfigFromJson(strings.NewReader(json)) + json = cfg.ToJson() + + w.Write([]byte(json)) +} + +func saveConfig(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasSystemAdminPermissions("getConfig") { + return + } + + cfg := model.ConfigFromJson(r.Body) + if cfg == nil { + c.SetInvalidParam("saveConfig", "config") + return + } + + if len(cfg.ServiceSettings.Port) == 0 { + c.SetInvalidParam("saveConfig", "config") + return + } + + if cfg.TeamSettings.MaxUsersPerTeam == 0 { + c.SetInvalidParam("saveConfig", "config") + return + } + + // TODO run some cleanup validators + + utils.SaveConfig(utils.CfgFileName, cfg) + utils.LoadConfig(utils.CfgFileName) + json := utils.Cfg.ToJson() + w.Write([]byte(json)) +} diff --git a/api/admin_test.go b/api/admin_test.go index e67077c55..e1778b5ac 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -8,6 +8,7 @@ import ( "github.com/mattermost/platform/model" "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" ) func TestGetLogs(t *testing.T) { @@ -20,6 +21,12 @@ func TestGetLogs(t *testing.T) { user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) store.Must(Srv.Store.User().VerifyEmail(user.Id)) + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if _, err := Client.GetLogs(); err == nil { + t.Fatal("Shouldn't have permissions") + } + c := &Context{} c.RequestId = model.NewId() c.IpAddress = "cmd_line" @@ -37,8 +44,81 @@ func TestGetLogs(t *testing.T) { func TestGetClientProperties(t *testing.T) { Setup() - if _, err := Client.GetClientProperties(); err != nil { + if result, err := Client.GetClientProperties(); err != nil { + t.Fatal(err) + } else { + props := result.Data.(map[string]string) + + if len(props["Version"]) == 0 { + t.Fatal() + } + } +} + +func TestGetConfig(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if _, err := Client.GetConfig(); err == nil { + t.Fatal("Shouldn't have permissions") + } + + c := &Context{} + c.RequestId = model.NewId() + c.IpAddress = "cmd_line" + UpdateRoles(c, user, model.ROLE_SYSTEM_ADMIN) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if result, err := Client.GetConfig(); err != nil { + t.Fatal(err) + } else { + cfg := result.Data.(*model.Config) + + if len(cfg.ServiceSettings.SiteName) == 0 { + t.Fatal() + } + } +} + +func TestSaveConfig(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if _, err := Client.SaveConfig(utils.Cfg); err == nil { + t.Fatal("Shouldn't have permissions") + } + + c := &Context{} + c.RequestId = model.NewId() + c.IpAddress = "cmd_line" + UpdateRoles(c, user, model.ROLE_SYSTEM_ADMIN) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if result, err := Client.SaveConfig(utils.Cfg); err != nil { t.Fatal(err) + } else { + cfg := result.Data.(*model.Config) + + if len(cfg.ServiceSettings.SiteName) == 0 { + t.Fatal() + } } } diff --git a/api/channel_test.go b/api/channel_test.go index 7e9267192..14bfe1cf7 100644 --- a/api/channel_test.go +++ b/api/channel_test.go @@ -57,7 +57,7 @@ func TestCreateChannel(t *testing.T) { rchannel.Data.(*model.Channel).Id = "" if _, err := Client.CreateChannel(rchannel.Data.(*model.Channel)); err != nil { - if err.Message != "A channel with that handle already exists" { + if err.Message != "A channel with that URL already exists" { t.Fatal(err) } } @@ -68,7 +68,7 @@ func TestCreateChannel(t *testing.T) { Client.DeleteChannel(savedId) if _, err := Client.CreateChannel(rchannel.Data.(*model.Channel)); err != nil { - if err.Message != "A channel with that handle was previously created" { + if err.Message != "A channel with that URL was previously created" { t.Fatal(err) } } diff --git a/api/command.go b/api/command.go index be1d3229b..bc55f206b 100644 --- a/api/command.go +++ b/api/command.go @@ -341,7 +341,7 @@ func loadTestSetupCommand(c *Context, command *model.Command) bool { } } } else { - client.MockSession(c.Session.Id) + client.MockSession(c.Session.Token) CreateTestEnviromentInTeam( client, c.Session.TeamId, @@ -406,7 +406,7 @@ func loadTestChannelsCommand(c *Context, command *model.Command) bool { channelsr = utils.Range{20, 30} } client := model.NewClient(c.GetSiteURL()) - client.MockSession(c.Session.Id) + client.MockSession(c.Session.Token) channelCreator := NewAutoChannelCreator(client, c.Session.TeamId) channelCreator.Fuzzy = doFuzz channelCreator.CreateTestChannels(channelsr) @@ -458,7 +458,7 @@ func loadTestPostsCommand(c *Context, command *model.Command) bool { } client := model.NewClient(c.GetSiteURL()) - client.MockSession(c.Session.Id) + client.MockSession(c.Session.Token) testPoster := NewAutoPostCreator(client, command.ChannelId) testPoster.Fuzzy = doFuzz testPoster.Users = usernames diff --git a/api/context.go b/api/context.go index 5925c817f..02716bb33 100644 --- a/api/context.go +++ b/api/context.go @@ -125,7 +125,7 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.setSiteURL(protocol + "://" + r.Host) w.Header().Set(model.HEADER_REQUEST_ID, c.RequestId) - w.Header().Set(model.HEADER_VERSION_ID, utils.Cfg.ServiceSettings.Version+fmt.Sprintf(".%v", utils.CfgLastModified)) + w.Header().Set(model.HEADER_VERSION_ID, fmt.Sprintf("%v.%v", model.CurrentVersion, utils.CfgLastModified)) // Instruct the browser not to display us in an iframe for anti-clickjacking if !h.isApi { diff --git a/api/file.go b/api/file.go index f249b3d9e..3602b5ed4 100644 --- a/api/file.go +++ b/api/file.go @@ -5,6 +5,7 @@ package api import ( "bytes" + "code.google.com/p/graphics-go/graphics" l4g "code.google.com/p/log4go" "fmt" "github.com/goamz/goamz/aws" @@ -13,6 +14,7 @@ import ( "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" "github.com/nfnt/resize" + "github.com/rwcarlsen/goexif/exif" _ "golang.org/x/image/bmp" "image" "image/color" @@ -21,6 +23,7 @@ import ( "image/jpeg" "io" "io/ioutil" + "math" "mime" "net/http" "net/url" @@ -31,6 +34,27 @@ import ( "time" ) +const ( + /* + EXIF Image Orientations + 1 2 3 4 5 6 7 8 + + 888888 888888 88 88 8888888888 88 88 8888888888 + 88 88 88 88 88 88 88 88 88 88 88 88 + 8888 8888 8888 8888 88 8888888888 8888888888 88 + 88 88 88 88 + 88 88 888888 888888 + */ + Upright = 1 + UprightMirrored = 2 + UpsideDown = 3 + UpsideDownMirrored = 4 + RotatedCWMirrored = 5 + RotatedCCW = 6 + RotatedCCWMirrored = 7 + RotatedCW = 8 +) + var fileInfoCache *utils.Cache = utils.NewLru(1000) func InitFile(r *mux.Router) { @@ -144,25 +168,59 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch return } - // Decode image config - imgConfig, _, err := image.DecodeConfig(bytes.NewReader(fileData[i])) - if err != nil { - l4g.Error("Unable to decode image config channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) - return + width := img.Bounds().Dx() + height := img.Bounds().Dy() + + // Get the image's orientation and ignore any errors since not all images will have orientation data + orientation, _ := getImageOrientation(fileData[i]) + + // Create a temporary image that will be manipulated and then used to make the thumbnail and preview image + var temp *image.RGBA + switch orientation { + case Upright, UprightMirrored, UpsideDown, UpsideDownMirrored: + temp = image.NewRGBA(img.Bounds()) + case RotatedCCW, RotatedCCWMirrored, RotatedCW, RotatedCWMirrored: + bounds := img.Bounds() + temp = image.NewRGBA(image.Rect(bounds.Min.Y, bounds.Min.X, bounds.Max.Y, bounds.Max.X)) + + width, height = height, width } - // Remove transparency due to JPEG's lack of support of it - temp := image.NewRGBA(img.Bounds()) + // Draw a white background since JPEGs lack transparency draw.Draw(temp, temp.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) - draw.Draw(temp, temp.Bounds(), img, img.Bounds().Min, draw.Over) + + // Copy the original image onto the temporary one while rotating it as necessary + switch orientation { + case UpsideDown, UpsideDownMirrored: + // rotate 180 degrees + err := graphics.Rotate(temp, img, &graphics.RotateOptions{Angle: math.Pi}) + if err != nil { + l4g.Error("Unable to rotate image") + } + case RotatedCW, RotatedCWMirrored: + // rotate 90 degrees CCW + graphics.Rotate(temp, img, &graphics.RotateOptions{Angle: 3 * math.Pi / 2}) + if err != nil { + l4g.Error("Unable to rotate image") + } + case RotatedCCW, RotatedCCWMirrored: + // rotate 90 degrees CW + graphics.Rotate(temp, img, &graphics.RotateOptions{Angle: math.Pi / 2}) + if err != nil { + l4g.Error("Unable to rotate image") + } + case Upright, UprightMirrored: + draw.Draw(temp, temp.Bounds(), img, img.Bounds().Min, draw.Over) + } + img = temp // Create thumbnail go func() { thumbWidth := float64(utils.Cfg.ImageSettings.ThumbnailWidth) thumbHeight := float64(utils.Cfg.ImageSettings.ThumbnailHeight) - imgWidth := float64(imgConfig.Width) - imgHeight := float64(imgConfig.Height) + imgWidth := float64(width) + imgHeight := float64(height) var thumbnail image.Image if imgHeight < thumbHeight && imgWidth < thumbWidth { @@ -189,7 +247,7 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch // Create preview go func() { var preview image.Image - if imgConfig.Width > int(utils.Cfg.ImageSettings.PreviewWidth) { + if width > int(utils.Cfg.ImageSettings.PreviewWidth) { preview = resize.Resize(utils.Cfg.ImageSettings.PreviewWidth, utils.Cfg.ImageSettings.PreviewHeight, img, resize.Lanczos3) } else { preview = img @@ -213,6 +271,23 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch }() } +func getImageOrientation(imageData []byte) (int, error) { + if exifData, err := exif.Decode(bytes.NewReader(imageData)); err != nil { + return Upright, err + } else { + if tag, err := exifData.Get("Orientation"); err != nil { + return Upright, err + } else { + orientation, err := tag.Int(0) + if err != nil { + return Upright, err + } else { + return orientation, nil + } + } + } +} + type ImageGetResult struct { Error error ImageData []byte diff --git a/config/config.json b/config/config.json index 4c4fbb255..38948641c 100644 --- a/config/config.json +++ b/config/config.json @@ -9,13 +9,11 @@ }, "ServiceSettings": { "SiteName": "Mattermost", - "Mode" : "dev", - "AllowTesting" : false, + "Mode": "dev", + "AllowTesting": false, "UseSSL": false, "Port": "8065", "Version": "developer", - "Shards": { - }, "InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6", "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", @@ -26,21 +24,12 @@ "DisableEmailSignUp": false, "EnableOAuthServiceProvider": false }, - "SSOSettings": { - "gitlab": { - "Allow": false, - "Secret" : "", - "Id": "", - "Scope": "", - "AuthEndpoint": "", - "TokenEndpoint": "", - "UserApiEndpoint": "" - } - }, "SqlSettings": { "DriverName": "mysql", "DataSource": "mmuser:mostest@tcp(dockerhost:3306)/mattermost_test?charset=utf8mb4,utf8", - "DataSourceReplicas": ["mmuser:mostest@tcp(dockerhost:3306)/mattermost_test?charset=utf8mb4,utf8"], + "DataSourceReplicas": [ + "mmuser:mostest@tcp(dockerhost:3306)/mattermost_test?charset=utf8mb4,utf8" + ], "MaxIdleConns": 10, "MaxOpenConns": 10, "Trace": false, @@ -62,7 +51,7 @@ "InitialFont": "luximbi.ttf" }, "EmailSettings": { - "ByPassEmail" : true, + "ByPassEmail": true, "SMTPUsername": "", "SMTPPassword": "", "SMTPServer": "", @@ -95,8 +84,20 @@ "MaxUsersPerTeam": 150, "AllowPublicLink": true, "AllowValetDefault": false, + "TourLink": "", "DefaultThemeColor": "#2389D7", "DisableTeamCreation": false, "RestrictCreationToDomains": "" + }, + "SSOSettings": { + "gitlab": { + "Allow": false, + "Secret": "", + "Id": "", + "Scope": "", + "AuthEndpoint": "", + "TokenEndpoint": "", + "UserApiEndpoint": "" + } } -} +}
\ No newline at end of file diff --git a/doc/developer/code-contribution.md b/doc/developer/code-contribution.md index c796a82a5..708b324a0 100644 --- a/doc/developer/code-contribution.md +++ b/doc/developer/code-contribution.md @@ -31,7 +31,26 @@ git checkout -b <branch name> ## Submitting a Pull Request -1. Please add yourself to the contributor list prior to submitting by completing the [contributor license agreement](http://www.mattermost.org/mattermost-contributor-agreement/). +1. Please add yourself to the Mattermost [approved contributor list](https://docs.google.com/spreadsheets/d/1NTCeG-iL_VS9bFqtmHSfwETo5f-8MQ7oMDE5IUYJi_Y/pubhtml?gid=0&single=true) prior to submitting by completing the [contributor license agreement](http://www.mattermost.org/mattermost-contributor-agreement/). + + For pull requests made by contributors not yet added to the approved contributor list, a reviewer may respond: + + ``` +Thanks @[GITHUB_USERNAME] for the pull request! + +Before we can review, we need to add you to the list of approved contributors for the Mattermost project. + +**Please help complete the Mattermost [contribution license agreement](http://www.mattermost.org/mattermost-contributor-agreement/)?** + +This is a standard procedure for many open source projects. You can view a list of past contributors who have completed the form [here](https://docs.google.com/spreadsheets/d/1NTCeG-iL_VS9bFqtmHSfwETo5f-8MQ7oMDE5IUYJi_Y/pubhtml?gid=0&single=true). + +After completing the form, it should be processed within 24 hours and reviewers for your pull request will be able to proceed. + +Please let us know if you have any questions. + +We are very happy to have you join our growing community! +``` + 2. When you submit your pull request please include the Ticket ID at the beginning of your pull request comment, followed by a colon. diff --git a/docker/0.6/config_docker.json b/docker/0.6/config_docker.json index 157120b99..b1c72c4bd 100644 --- a/docker/0.6/config_docker.json +++ b/docker/0.6/config_docker.json @@ -10,7 +10,7 @@ "ServiceSettings": { "SiteName": "Mattermost", "Mode" : "dev", - "AllowTesting" : false, + "AllowTesting" : false, "UseSSL": false, "Port": "80", "Version": "developer", @@ -63,8 +63,8 @@ "SMTPUsername": "", "SMTPPassword": "", "SMTPServer": "", - "UseTLS": false, - "UseStartTLS": false, + "UseTLS": false, + "UseStartTLS": false, "FeedbackEmail": "", "FeedbackName": "", "ApplePushServer": "", diff --git a/docker/0.7/config_docker.json b/docker/0.7/config_docker.json index 794ac95ae..cbac2ea69 100644 --- a/docker/0.7/config_docker.json +++ b/docker/0.7/config_docker.json @@ -10,7 +10,7 @@ "ServiceSettings": { "SiteName": "Mattermost", "Mode" : "dev", - "AllowTesting" : true, + "AllowTesting" : true, "UseSSL": false, "Port": "80", "Version": "developer", @@ -65,8 +65,8 @@ "SMTPUsername": "", "SMTPPassword": "", "SMTPServer": "", - "UseTLS": false, - "UseStartTLS": false, + "UseTLS": false, + "UseStartTLS": false, "FeedbackEmail": "", "FeedbackName": "", "ApplePushServer": "", diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json index bc42951b8..aceeb95b4 100644 --- a/docker/dev/config_docker.json +++ b/docker/dev/config_docker.json @@ -10,7 +10,7 @@ "ServiceSettings": { "SiteName": "Mattermost", "Mode" : "dev", - "AllowTesting" : true, + "AllowTesting" : true, "UseSSL": false, "Port": "80", "Version": "developer", @@ -66,8 +66,8 @@ "SMTPUsername": "", "SMTPPassword": "", "SMTPServer": "", - "UseTLS": false, - "UseStartTLS": false, + "UseTLS": false, + "UseStartTLS": false, "FeedbackEmail": "", "FeedbackName": "", "ApplePushServer": "", diff --git a/mattermost.go b/mattermost.go index 0bdb90424..f54bcf15f 100644 --- a/mattermost.go +++ b/mattermost.go @@ -23,6 +23,7 @@ import ( var flagCmdCreateTeam bool var flagCmdCreateUser bool var flagCmdAssignRole bool +var flagCmdVersion bool var flagCmdResetPassword bool var flagConfigFile string var flagEmail string @@ -42,6 +43,7 @@ func main() { } pwd, _ := os.Getwd() + l4g.Info("Current version is %v (%v/%v/%v)", model.CurrentVersion, model.BuildNumber, model.BuildDate, model.BuildHash) l4g.Info("Current working directory is %v", pwd) l4g.Info("Loaded config file from %v", utils.FindConfigFile(flagConfigFile)) @@ -83,14 +85,16 @@ func parseCmds() { flag.BoolVar(&flagCmdCreateTeam, "create_team", false, "") flag.BoolVar(&flagCmdCreateUser, "create_user", false, "") flag.BoolVar(&flagCmdAssignRole, "assign_role", false, "") + flag.BoolVar(&flagCmdVersion, "version", false, "") flag.BoolVar(&flagCmdResetPassword, "reset_password", false, "") flag.Parse() - flagRunCmds = flagCmdCreateTeam || flagCmdCreateUser || flagCmdAssignRole || flagCmdResetPassword + flagRunCmds = flagCmdCreateTeam || flagCmdCreateUser || flagCmdAssignRole || flagCmdResetPassword || flagCmdVersion } func runCmds() { + cmdVersion() cmdCreateTeam() cmdCreateUser() cmdAssignRole() @@ -184,6 +188,17 @@ func cmdCreateUser() { } } +func cmdVersion() { + if flagCmdVersion { + fmt.Fprintln(os.Stderr, "Version: "+model.CurrentVersion) + fmt.Fprintln(os.Stderr, "Build Number: "+model.BuildNumber) + fmt.Fprintln(os.Stderr, "Build Date: "+model.BuildDate) + fmt.Fprintln(os.Stderr, "Build Hash: "+model.BuildHash) + + os.Exit(0) + } +} + func cmdAssignRole() { if flagCmdAssignRole { if len(flagTeamName) == 0 { @@ -298,6 +313,8 @@ Usage: platform [options] + -version Display the current version + -config="config.json" Path to the config file -email="user@example.com" Email address used in other commands diff --git a/model/client.go b/model/client.go index 9a89e8208..f9127719f 100644 --- a/model/client.go +++ b/model/client.go @@ -385,6 +385,24 @@ func (c *Client) GetClientProperties() (*Result, *AppError) { } } +func (c *Client) GetConfig() (*Result, *AppError) { + if r, err := c.DoApiGet("/admin/config", "", ""); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), ConfigFromJson(r.Body)}, nil + } +} + +func (c *Client) SaveConfig(config *Config) (*Result, *AppError) { + if r, err := c.DoApiPost("/admin/save_config", config.ToJson()); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), ConfigFromJson(r.Body)}, nil + } +} + func (c *Client) CreateChannel(channel *Channel) (*Result, *AppError) { if r, err := c.DoApiPost("/channels/create", channel.ToJson()); err != nil { return nil, err @@ -790,4 +808,5 @@ func (c *Client) GetAccessToken(data url.Values) (*Result, *AppError) { func (c *Client) MockSession(sessionToken string) { c.AuthToken = sessionToken + c.AuthType = HEADER_BEARER } diff --git a/model/config.go b/model/config.go new file mode 100644 index 000000000..3b333dbe1 --- /dev/null +++ b/model/config.go @@ -0,0 +1,151 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type ServiceSettings struct { + SiteName string + Mode string + AllowTesting bool + UseSSL bool + Port string + Version string + InviteSalt string + PublicLinkSalt string + ResetSalt string + AnalyticsUrl string + UseLocalStorage bool + StorageDirectory string + AllowedLoginAttempts int + DisableEmailSignUp bool + EnableOAuthServiceProvider bool +} + +type SSOSetting struct { + Allow bool + Secret string + Id string + Scope string + AuthEndpoint string + TokenEndpoint string + UserApiEndpoint string +} + +type SqlSettings struct { + DriverName string + DataSource string + DataSourceReplicas []string + MaxIdleConns int + MaxOpenConns int + Trace bool + AtRestEncryptKey string +} + +type LogSettings struct { + ConsoleEnable bool + ConsoleLevel string + FileEnable bool + FileLevel string + FileFormat string + FileLocation string +} + +type AWSSettings struct { + S3AccessKeyId string + S3SecretAccessKey string + S3Bucket string + S3Region string +} + +type ImageSettings struct { + ThumbnailWidth uint + ThumbnailHeight uint + PreviewWidth uint + PreviewHeight uint + ProfileWidth uint + ProfileHeight uint + InitialFont string +} + +type EmailSettings struct { + ByPassEmail bool + SMTPUsername string + SMTPPassword string + SMTPServer string + UseTLS bool + UseStartTLS bool + FeedbackEmail string + FeedbackName string + ApplePushServer string + ApplePushCertPublic string + ApplePushCertPrivate string +} + +type RateLimitSettings struct { + UseRateLimiter bool + PerSec int + MemoryStoreSize int + VaryByRemoteAddr bool + VaryByHeader string +} + +type PrivacySettings struct { + ShowEmailAddress bool + ShowPhoneNumber bool + ShowSkypeId bool + ShowFullName bool +} + +type ClientSettings struct { + SegmentDeveloperKey string + GoogleDeveloperKey string +} + +type TeamSettings struct { + MaxUsersPerTeam int + AllowPublicLink bool + AllowValetDefault bool + TourLink string + DefaultThemeColor string + DisableTeamCreation bool + RestrictCreationToDomains string +} + +type Config struct { + LogSettings LogSettings + ServiceSettings ServiceSettings + SqlSettings SqlSettings + AWSSettings AWSSettings + ImageSettings ImageSettings + EmailSettings EmailSettings + RateLimitSettings RateLimitSettings + PrivacySettings PrivacySettings + ClientSettings ClientSettings + TeamSettings TeamSettings + SSOSettings map[string]SSOSetting +} + +func (o *Config) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func ConfigFromJson(data io.Reader) *Config { + decoder := json.NewDecoder(data) + var o Config + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/system.go b/model/system.go new file mode 100644 index 000000000..c79391cca --- /dev/null +++ b/model/system.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type System struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func (o *System) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func SystemFromJson(data io.Reader) *System { + decoder := json.NewDecoder(data) + var o System + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/system_test.go b/model/system_test.go new file mode 100644 index 000000000..14ba0db2e --- /dev/null +++ b/model/system_test.go @@ -0,0 +1,19 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestSystemJson(t *testing.T) { + system := System{Name: "test", Value: NewId()} + json := system.ToJson() + result := SystemFromJson(strings.NewReader(json)) + + if result.Name != "test" { + t.Fatal("Ids do not match") + } +} diff --git a/model/utils.go b/model/utils.go index 04b92947b..e19cceba5 100644 --- a/model/utils.go +++ b/model/utils.go @@ -16,11 +16,6 @@ import ( "time" ) -const ( - // Also change web/react/stores/browser_store.jsx BROWSER_STORE_VERSION - ETAG_ROOT_VERSION = "12" -) - type StringMap map[string]string type StringArray []string type EncryptStringMap map[string]string @@ -235,7 +230,7 @@ func IsValidAlphaNum(s string, allowUnderscores bool) bool { func Etag(parts ...interface{}) string { - etag := ETAG_ROOT_VERSION + etag := CurrentVersion for _, part := range parts { etag += fmt.Sprintf(".%v", part) diff --git a/model/version.go b/model/version.go new file mode 100644 index 000000000..8f0c76ebe --- /dev/null +++ b/model/version.go @@ -0,0 +1,90 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strconv" + "strings" +) + +// This is a list of all the current viersions including any patches. +// It should be maitained in chronological order with most current +// release at the front of the list. +var versions = []string{ + "0.8.0", + "0.7.1", + "0.7.0", + "0.6.0", + "0.5.0", +} + +var CurrentVersion string = versions[0] +var BuildNumber = "_BUILD_NUMBER_" +var BuildDate = "_BUILD_DATE_" +var BuildHash = "_BUILD_HASH_" + +func SplitVersion(version string) (int64, int64, int64) { + parts := strings.Split(version, ".") + + major := int64(0) + minor := int64(0) + patch := int64(0) + + if len(parts) > 0 { + major, _ = strconv.ParseInt(parts[0], 10, 64) + } + + if len(parts) > 1 { + minor, _ = strconv.ParseInt(parts[1], 10, 64) + } + + if len(parts) > 2 { + patch, _ = strconv.ParseInt(parts[2], 10, 64) + } + + return major, minor, patch +} + +func GetPreviousVersion(currentVersion string) (int64, int64) { + currentIndex := -1 + currentMajor, currentMinor, _ := SplitVersion(currentVersion) + + for index, version := range versions { + major, minor, _ := SplitVersion(version) + + if currentMajor == major && currentMinor == minor { + currentIndex = index + } + + if currentIndex >= 0 { + if currentMajor != major || currentMinor != minor { + return major, minor + } + } + } + + return 0, 0 +} + +func IsCurrentVersion(versionToCheck string) bool { + currentMajor, currentMinor, _ := SplitVersion(CurrentVersion) + toCheckMajor, toCheckMinor, _ := SplitVersion(versionToCheck) + + if toCheckMajor == currentMajor && toCheckMinor == currentMinor { + return true + } else { + return false + } +} + +func IsPreviousVersion(versionToCheck string) bool { + toCheckMajor, toCheckMinor, _ := SplitVersion(versionToCheck) + prevMajor, prevMinor := GetPreviousVersion(CurrentVersion) + + if toCheckMajor == prevMajor && toCheckMinor == prevMinor { + return true + } else { + return false + } +} diff --git a/model/version_test.go b/model/version_test.go new file mode 100644 index 000000000..da40006be --- /dev/null +++ b/model/version_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "fmt" + "testing" +) + +func TestSplitVersion(t *testing.T) { + major1, minor1, patch1 := SplitVersion("junk") + if major1 != 0 || minor1 != 0 || patch1 != 0 { + t.Fatal() + } + + major2, minor2, patch2 := SplitVersion("1.2.3") + if major2 != 1 || minor2 != 2 || patch2 != 3 { + t.Fatal() + } + + major3, minor3, patch3 := SplitVersion("1.2") + if major3 != 1 || minor3 != 2 || patch3 != 0 { + t.Fatal() + } + + major4, minor4, patch4 := SplitVersion("1") + if major4 != 1 || minor4 != 0 || patch4 != 0 { + t.Fatal() + } + + major5, minor5, patch5 := SplitVersion("1.2.3.junkgoeswhere") + if major5 != 1 || minor5 != 2 || patch5 != 3 { + t.Fatal() + } +} + +func TestGetPreviousVersion(t *testing.T) { + if major, minor := GetPreviousVersion("0.8.0"); major != 0 || minor != 7 { + t.Fatal(major, minor) + } + + if major, minor := GetPreviousVersion("0.7.0"); major != 0 || minor != 6 { + t.Fatal(major, minor) + } + + if major, minor := GetPreviousVersion("0.7.1"); major != 0 || minor != 6 { + t.Fatal(major, minor) + } + + if major, minor := GetPreviousVersion("0.7111.1"); major != 0 || minor != 0 { + t.Fatal(major, minor) + } +} + +func TestIsCurrentVersion(t *testing.T) { + major, minor, patch := SplitVersion(CurrentVersion) + + if !IsCurrentVersion(CurrentVersion) { + t.Fatal() + } + + if !IsCurrentVersion(fmt.Sprintf("%v.%v.%v", major, minor, patch+100)) { + t.Fatal() + } + + if IsCurrentVersion(fmt.Sprintf("%v.%v.%v", major, minor+1, patch)) { + t.Fatal() + } + + if IsCurrentVersion(fmt.Sprintf("%v.%v.%v", major+1, minor, patch)) { + t.Fatal() + } +} diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go index 3d1007874..877246fc3 100644 --- a/store/sql_channel_store.go +++ b/store/sql_channel_store.go @@ -37,7 +37,6 @@ func NewSqlChannelStore(sqlStore *SqlStore) ChannelStore { } func (s SqlChannelStore) UpgradeSchemaIfNeeded() { - s.CreateColumnIfNotExists("Channels", "CreatorId", "varchar(26)", "character varying(26)", "") } func (s SqlChannelStore) CreateIndexesIfNotExists() { @@ -86,9 +85,9 @@ func (s SqlChannelStore) Save(channel *model.Channel) StoreChannel { dupChannel := model.Channel{} s.GetReplica().SelectOne(&dupChannel, "SELECT * FROM Channels WHERE TeamId = :TeamId AND Name = :Name AND DeleteAt > 0", map[string]interface{}{"TeamId": channel.TeamId, "Name": channel.Name}) if dupChannel.DeleteAt > 0 { - result.Err = model.NewAppError("SqlChannelStore.Update", "A channel with that handle was previously created", "id="+channel.Id+", "+err.Error()) + result.Err = model.NewAppError("SqlChannelStore.Update", "A channel with that URL was previously created", "id="+channel.Id+", "+err.Error()) } else { - result.Err = model.NewAppError("SqlChannelStore.Update", "A channel with that handle already exists", "id="+channel.Id+", "+err.Error()) + result.Err = model.NewAppError("SqlChannelStore.Update", "A channel with that URL already exists", "id="+channel.Id+", "+err.Error()) } } else { result.Err = model.NewAppError("SqlChannelStore.Save", "We couldn't save the channel", "id="+channel.Id+", "+err.Error()) diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 20de23eb7..21e8e9d00 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -196,9 +196,9 @@ func (s SqlPostStore) GetEtag(channelId string) StoreChannel { var et etagPosts err := s.GetReplica().SelectOne(&et, "SELECT Id, UpdateAt FROM Posts WHERE ChannelId = :ChannelId ORDER BY UpdateAt DESC LIMIT 1", map[string]interface{}{"ChannelId": channelId}) if err != nil { - result.Data = fmt.Sprintf("%v.0.%v", model.ETAG_ROOT_VERSION, model.GetMillis()) + result.Data = fmt.Sprintf("%v.0.%v", model.CurrentVersion, model.GetMillis()) } else { - result.Data = fmt.Sprintf("%v.%v.%v", model.ETAG_ROOT_VERSION, et.Id, et.UpdateAt) + result.Data = fmt.Sprintf("%v.%v.%v", model.CurrentVersion, et.Id, et.UpdateAt) } storeChannel <- result diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go index d48dea51c..bc1cb2c2c 100644 --- a/store/sql_post_store_test.go +++ b/store/sql_post_store_test.go @@ -37,14 +37,14 @@ func TestPostStoreGet(t *testing.T) { o1.Message = "a" + model.NewId() + "b" etag1 := (<-store.Post().GetEtag(o1.ChannelId)).Data.(string) - if strings.Index(etag1, model.ETAG_ROOT_VERSION+".0.") != 0 { + if strings.Index(etag1, model.CurrentVersion+".0.") != 0 { t.Fatal("Invalid Etag") } o1 = (<-store.Post().Save(o1)).Data.(*model.Post) etag2 := (<-store.Post().GetEtag(o1.ChannelId)).Data.(string) - if strings.Index(etag2, model.ETAG_ROOT_VERSION+"."+o1.Id) != 0 { + if strings.Index(etag2, model.CurrentVersion+"."+o1.Id) != 0 { t.Fatal("Invalid Etag") } @@ -136,7 +136,7 @@ func TestPostStoreDelete(t *testing.T) { o1.Message = "a" + model.NewId() + "b" etag1 := (<-store.Post().GetEtag(o1.ChannelId)).Data.(string) - if strings.Index(etag1, model.ETAG_ROOT_VERSION+".0.") != 0 { + if strings.Index(etag1, model.CurrentVersion+".0.") != 0 { t.Fatal("Invalid Etag") } @@ -160,7 +160,7 @@ func TestPostStoreDelete(t *testing.T) { } etag2 := (<-store.Post().GetEtag(o1.ChannelId)).Data.(string) - if strings.Index(etag2, model.ETAG_ROOT_VERSION+"."+o1.Id) != 0 { + if strings.Index(etag2, model.CurrentVersion+"."+o1.Id) != 0 { t.Fatal("Invalid Etag") } } diff --git a/store/sql_store.go b/store/sql_store.go index c0b3c2021..adac47b4d 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -39,6 +39,7 @@ type SqlStore struct { audit AuditStore session SessionStore oauth OAuthStore + system SystemStore } func NewSqlStore() Store { @@ -56,9 +57,30 @@ func NewSqlStore() Store { utils.Cfg.SqlSettings.Trace) } + schemaVersion := sqlStore.GetCurrentSchemaVersion() + + // If the version is already set then we are potentially in an 'upgrade needed' state + if schemaVersion != "" { + // Check to see if it's the most current database schema version + if !model.IsCurrentVersion(schemaVersion) { + // If we are upgrading from the previous version then print a warning and continue + if model.IsPreviousVersion(schemaVersion) { + l4g.Warn("The database schema version of " + schemaVersion + " appears to be out of date") + l4g.Warn("Attempting to upgrade the database schema version to " + model.CurrentVersion) + } else { + // If this is an 'upgrade needed' state but the user is attempting to skip a version then halt the world + l4g.Critical("The database schema version of " + schemaVersion + " cannot be upgraded. You must not skip a version.") + time.Sleep(time.Second) + panic("The database schema version of " + schemaVersion + " cannot be upgraded. You must not skip a version.") + } + } + } + // Temporary upgrade code, remove after 0.8.0 release - if sqlStore.DoesColumnExist("Sessions", "AltId") { - sqlStore.GetMaster().Exec("DROP TABLE IF EXISTS Sessions") + if sqlStore.DoesTableExist("Sessions") { + if sqlStore.DoesColumnExist("Sessions", "AltId") { + sqlStore.GetMaster().Exec("DROP TABLE IF EXISTS Sessions") + } } sqlStore.team = NewSqlTeamStore(sqlStore) @@ -68,6 +90,7 @@ func NewSqlStore() Store { sqlStore.audit = NewSqlAuditStore(sqlStore) sqlStore.session = NewSqlSessionStore(sqlStore) sqlStore.oauth = NewSqlOAuthStore(sqlStore) + sqlStore.system = NewSqlSystemStore(sqlStore) sqlStore.master.CreateTablesIfNotExists() @@ -78,6 +101,7 @@ func NewSqlStore() Store { sqlStore.audit.(*SqlAuditStore).UpgradeSchemaIfNeeded() sqlStore.session.(*SqlSessionStore).UpgradeSchemaIfNeeded() sqlStore.oauth.(*SqlOAuthStore).UpgradeSchemaIfNeeded() + sqlStore.system.(*SqlSystemStore).UpgradeSchemaIfNeeded() sqlStore.team.(*SqlTeamStore).CreateIndexesIfNotExists() sqlStore.channel.(*SqlChannelStore).CreateIndexesIfNotExists() @@ -86,6 +110,17 @@ func NewSqlStore() Store { sqlStore.audit.(*SqlAuditStore).CreateIndexesIfNotExists() sqlStore.session.(*SqlSessionStore).CreateIndexesIfNotExists() sqlStore.oauth.(*SqlOAuthStore).CreateIndexesIfNotExists() + sqlStore.system.(*SqlSystemStore).CreateIndexesIfNotExists() + + if model.IsPreviousVersion(schemaVersion) { + sqlStore.system.Update(&model.System{Name: "Version", Value: model.CurrentVersion}) + l4g.Warn("The database schema has been upgraded to version " + model.CurrentVersion) + } + + if schemaVersion == "" { + sqlStore.system.Save(&model.System{Name: "Version", Value: model.CurrentVersion}) + l4g.Info("The database schema has been set to version " + model.CurrentVersion) + } return sqlStore } @@ -94,12 +129,12 @@ func setupConnection(con_type string, driver string, dataSource string, maxIdle db, err := dbsql.Open(driver, dataSource) if err != nil { - l4g.Critical("Failed to open sql connection to '%v' err:%v", dataSource, err) + l4g.Critical("Failed to open sql connection to err:%v", err) time.Sleep(time.Second) panic("Failed to open sql connection" + err.Error()) } - l4g.Info("Pinging sql %v database at '%v'", con_type, dataSource) + l4g.Info("Pinging sql %v database", con_type) err = db.Ping() if err != nil { l4g.Critical("Failed to ping db err:%v", err) @@ -131,6 +166,56 @@ func setupConnection(con_type string, driver string, dataSource string, maxIdle return dbmap } +func (ss SqlStore) GetCurrentSchemaVersion() string { + version, _ := ss.GetMaster().SelectStr("SELECT Value FROM Systems WHERE Name='Version'") + return version +} + +func (ss SqlStore) DoesTableExist(tableName string) bool { + if utils.Cfg.SqlSettings.DriverName == "postgres" { + count, err := ss.GetMaster().SelectInt( + `SELECT count(relname) FROM pg_class WHERE relname=$1`, + strings.ToLower(tableName), + ) + + if err != nil { + l4g.Critical("Failed to check if table exists %v", err) + time.Sleep(time.Second) + panic("Failed to check if table exists " + err.Error()) + } + + return count > 0 + + } else if utils.Cfg.SqlSettings.DriverName == "mysql" { + + count, err := ss.GetMaster().SelectInt( + `SELECT + COUNT(0) AS table_exists + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + `, + tableName, + ) + + if err != nil { + l4g.Critical("Failed to check if table exists %v", err) + time.Sleep(time.Second) + panic("Failed to check if table exists " + err.Error()) + } + + return count > 0 + + } else { + l4g.Critical("Failed to check if column exists because of missing driver") + time.Sleep(time.Second) + panic("Failed to check if column exists because of missing driver") + } + +} + func (ss SqlStore) DoesColumnExist(tableName string, columnName string) bool { if utils.Cfg.SqlSettings.DriverName == "postgres" { count, err := ss.GetMaster().SelectInt( @@ -144,6 +229,10 @@ func (ss SqlStore) DoesColumnExist(tableName string, columnName string) bool { ) if err != nil { + if err.Error() == "pq: relation \""+strings.ToLower(tableName)+"\" does not exist" { + return false + } + l4g.Critical("Failed to check if column exists %v", err) time.Sleep(time.Second) panic("Failed to check if column exists " + err.Error()) @@ -376,6 +465,10 @@ func (ss SqlStore) OAuth() OAuthStore { return ss.oauth } +func (ss SqlStore) System() SystemStore { + return ss.system +} + type mattermConverter struct{} func (me mattermConverter) ToDb(val interface{}) (interface{}, error) { diff --git a/store/sql_system_store.go b/store/sql_system_store.go new file mode 100644 index 000000000..ca22de2a6 --- /dev/null +++ b/store/sql_system_store.go @@ -0,0 +1,92 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" +) + +type SqlSystemStore struct { + *SqlStore +} + +func NewSqlSystemStore(sqlStore *SqlStore) SystemStore { + s := &SqlSystemStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.System{}, "Systems").SetKeys(false, "Name") + table.ColMap("Name").SetMaxSize(64) + table.ColMap("Value").SetMaxSize(1024) + } + + return s +} + +func (s SqlSystemStore) UpgradeSchemaIfNeeded() { +} + +func (s SqlSystemStore) CreateIndexesIfNotExists() { +} + +func (s SqlSystemStore) Save(system *model.System) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if err := s.GetMaster().Insert(system); err != nil { + result.Err = model.NewAppError("SqlSystemStore.Save", "We encounted an error saving the system property", "") + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlSystemStore) Update(system *model.System) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := s.GetMaster().Update(system); err != nil { + result.Err = model.NewAppError("SqlSystemStore.Save", "We encounted an error updating the system property", "") + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlSystemStore) Get() StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var systems []model.System + props := make(model.StringMap) + if _, err := s.GetReplica().Select(&systems, "SELECT * FROM Systems"); err != nil { + result.Err = model.NewAppError("SqlSystemStore.Get", "We encounted an error finding the system properties", "") + } else { + for _, prop := range systems { + props[prop.Name] = prop.Value + } + + result.Data = props + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_system_store_test.go b/store/sql_system_store_test.go new file mode 100644 index 000000000..0f03b8f0e --- /dev/null +++ b/store/sql_system_store_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "testing" +) + +func TestSqlSystemStore(t *testing.T) { + Setup() + + system := &model.System{Name: model.NewId(), Value: "value"} + Must(store.System().Save(system)) + + result := <-store.System().Get() + systems := result.Data.(model.StringMap) + + if systems[system.Name] != system.Value { + t.Fatal() + } + + system.Value = "value2" + Must(store.System().Update(system)) + + result2 := <-store.System().Get() + systems2 := result2.Data.(model.StringMap) + + if systems2[system.Name] != system.Value { + t.Fatal() + } +} diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 52d670d56..778df367e 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -325,9 +325,9 @@ func (s SqlUserStore) GetEtagForProfiles(teamId string) StoreChannel { updateAt, err := s.GetReplica().SelectInt("SELECT UpdateAt FROM Users WHERE TeamId = :TeamId ORDER BY UpdateAt DESC LIMIT 1", map[string]interface{}{"TeamId": teamId}) if err != nil { - result.Data = fmt.Sprintf("%v.%v", model.ETAG_ROOT_VERSION, model.GetMillis()) + result.Data = fmt.Sprintf("%v.%v", model.CurrentVersion, model.GetMillis()) } else { - result.Data = fmt.Sprintf("%v.%v", model.ETAG_ROOT_VERSION, updateAt) + result.Data = fmt.Sprintf("%v.%v", model.CurrentVersion, updateAt) } storeChannel <- result diff --git a/store/store.go b/store/store.go index 0218bc757..1344c4ebe 100644 --- a/store/store.go +++ b/store/store.go @@ -35,6 +35,7 @@ type Store interface { Audit() AuditStore Session() SessionStore OAuth() OAuthStore + System() SystemStore Close() } @@ -130,3 +131,9 @@ type OAuthStore interface { GetAccessDataByAuthCode(authCode string) StoreChannel RemoveAccessData(token string) StoreChannel } + +type SystemStore interface { + Save(system *model.System) StoreChannel + Update(system *model.System) StoreChannel + Get() StoreChannel +} diff --git a/utils/config.go b/utils/config.go index 0eb8329d1..dd2c17977 100644 --- a/utils/config.go +++ b/utils/config.go @@ -6,11 +6,14 @@ package utils import ( "encoding/json" "fmt" + "io/ioutil" "os" "path/filepath" "strconv" l4g "code.google.com/p/log4go" + + "github.com/mattermost/platform/model" ) const ( @@ -20,139 +23,9 @@ const ( LOG_ROTATE_SIZE = 10000 ) -type ServiceSettings struct { - SiteName string - Mode string - AllowTesting bool - UseSSL bool - Port string - Version string - InviteSalt string - PublicLinkSalt string - ResetSalt string - AnalyticsUrl string - UseLocalStorage bool - StorageDirectory string - AllowedLoginAttempts int - DisableEmailSignUp bool - EnableOAuthServiceProvider bool -} - -type SSOSetting struct { - Allow bool - Secret string - Id string - Scope string - AuthEndpoint string - TokenEndpoint string - UserApiEndpoint string -} - -type SqlSettings struct { - DriverName string - DataSource string - DataSourceReplicas []string - MaxIdleConns int - MaxOpenConns int - Trace bool - AtRestEncryptKey string -} - -type LogSettings struct { - ConsoleEnable bool - ConsoleLevel string - FileEnable bool - FileLevel string - FileFormat string - FileLocation string -} - -type AWSSettings struct { - S3AccessKeyId string - S3SecretAccessKey string - S3Bucket string - S3Region string -} - -type ImageSettings struct { - ThumbnailWidth uint - ThumbnailHeight uint - PreviewWidth uint - PreviewHeight uint - ProfileWidth uint - ProfileHeight uint - InitialFont string -} - -type EmailSettings struct { - ByPassEmail bool - SMTPUsername string - SMTPPassword string - SMTPServer string - UseTLS bool - UseStartTLS bool - FeedbackEmail string - FeedbackName string - ApplePushServer string - ApplePushCertPublic string - ApplePushCertPrivate string -} - -type RateLimitSettings struct { - UseRateLimiter bool - PerSec int - MemoryStoreSize int - VaryByRemoteAddr bool - VaryByHeader string -} - -type PrivacySettings struct { - ShowEmailAddress bool - ShowPhoneNumber bool - ShowSkypeId bool - ShowFullName bool -} - -type ClientSettings struct { - SegmentDeveloperKey string - GoogleDeveloperKey string -} - -type TeamSettings struct { - MaxUsersPerTeam int - AllowPublicLink bool - AllowValetDefault bool - TourLink string - DefaultThemeColor string - DisableTeamCreation bool - RestrictCreationToDomains string -} - -type Config struct { - LogSettings LogSettings - ServiceSettings ServiceSettings - SqlSettings SqlSettings - AWSSettings AWSSettings - ImageSettings ImageSettings - EmailSettings EmailSettings - RateLimitSettings RateLimitSettings - PrivacySettings PrivacySettings - ClientSettings ClientSettings - TeamSettings TeamSettings - SSOSettings map[string]SSOSetting -} - -func (o *Config) ToJson() string { - b, err := json.Marshal(o) - if err != nil { - return "" - } else { - return string(b) - } -} - -var Cfg *Config = &Config{} +var Cfg *model.Config = &model.Config{} var CfgLastModified int64 = 0 +var CfgFileName string = "" var ClientProperties map[string]string = map[string]string{} var SanitizeOptions map[string]bool = map[string]bool{} @@ -184,14 +57,14 @@ func FindDir(dir string) string { } func ConfigureCmdLineLog() { - ls := LogSettings{} + ls := model.LogSettings{} ls.ConsoleEnable = true ls.ConsoleLevel = "ERROR" ls.FileEnable = false configureLog(&ls) } -func configureLog(s *LogSettings) { +func configureLog(s *model.LogSettings) { l4g.Close() @@ -207,12 +80,11 @@ func configureLog(s *LogSettings) { } if s.FileEnable { - if s.FileFormat == "" { - s.FileFormat = "[%D %T] [%L] %M" - } - if s.FileLocation == "" { - s.FileLocation = FindDir("logs") + "mattermost.log" + var fileFormat = s.FileFormat + + if fileFormat == "" { + fileFormat = "[%D %T] [%L] %M" } level := l4g.DEBUG @@ -222,14 +94,36 @@ func configureLog(s *LogSettings) { level = l4g.ERROR } - flw := l4g.NewFileLogWriter(s.FileLocation, false) - flw.SetFormat(s.FileFormat) + flw := l4g.NewFileLogWriter(GetLogFileLocation(s.FileLocation), false) + flw.SetFormat(fileFormat) flw.SetRotate(true) flw.SetRotateLines(LOG_ROTATE_SIZE) l4g.AddFilter("file", level, flw) } } +func GetLogFileLocation(fileLocation string) string { + if fileLocation == "" { + return FindDir("logs") + "mattermost.log" + } else { + return fileLocation + } +} + +func SaveConfig(fileName string, config *model.Config) *model.AppError { + b, err := json.MarshalIndent(config, "", " ") + if err != nil { + return model.NewAppError("SaveConfig", "An error occurred while saving the file to "+fileName, err.Error()) + } + + err = ioutil.WriteFile(fileName, b, 0644) + if err != nil { + return model.NewAppError("SaveConfig", "An error occurred while saving the file to "+fileName, err.Error()) + } + + return nil +} + // LoadConfig will try to search around for the corresponding config file. // It will search /tmp/fileName then attempt ./config/fileName, // then ../config/fileName and last it will look at fileName @@ -243,7 +137,7 @@ func LoadConfig(fileName string) { } decoder := json.NewDecoder(file) - config := Config{} + config := model.Config{} err = decoder.Decode(&config) if err != nil { panic("Error decoding config file=" + fileName + ", err=" + err.Error()) @@ -253,6 +147,7 @@ func LoadConfig(fileName string) { panic("Error getting config info file=" + fileName + ", err=" + err.Error()) } else { CfgLastModified = info.ModTime().Unix() + CfgFileName = fileName } configureLog(&config.LogSettings) @@ -262,7 +157,7 @@ func LoadConfig(fileName string) { ClientProperties = getClientProperties(Cfg) } -func getSanitizeOptions(c *Config) map[string]bool { +func getSanitizeOptions(c *model.Config) map[string]bool { options := map[string]bool{} options["fullname"] = c.PrivacySettings.ShowFullName options["email"] = c.PrivacySettings.ShowEmailAddress @@ -272,10 +167,14 @@ func getSanitizeOptions(c *Config) map[string]bool { return options } -func getClientProperties(c *Config) map[string]string { +func getClientProperties(c *model.Config) map[string]string { props := make(map[string]string) - props["Version"] = c.ServiceSettings.Version + props["Version"] = model.CurrentVersion + props["BuildNumber"] = model.BuildNumber + props["BuildDate"] = model.BuildDate + props["BuildHash"] = model.BuildHash + props["SiteName"] = c.ServiceSettings.SiteName props["ByPassEmail"] = strconv.FormatBool(c.EmailSettings.ByPassEmail) props["FeedbackEmail"] = c.EmailSettings.FeedbackEmail diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index 68984c9e0..e82fe1b76 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -2,35 +2,58 @@ // See License.txt for license information. var AdminSidebar = require('./admin_sidebar.jsx'); -var EmailTab = require('./email_settings.jsx'); -var JobsTab = require('./jobs_settings.jsx'); +var AdminStore = require('../../stores/admin_store.jsx'); +var AsyncClient = require('../../utils/async_client.jsx'); +var LoadingScreen = require('../loading_screen.jsx'); + +var EmailSettingsTab = require('./email_settings.jsx'); +var LogSettingsTab = require('./log_settings.jsx'); var LogsTab = require('./logs.jsx'); -var Navbar = require('../../components/navbar.jsx'); export default class AdminController extends React.Component { constructor(props) { super(props); this.selectTab = this.selectTab.bind(this); + this.onConfigListenerChange = this.onConfigListenerChange.bind(this); this.state = { + config: null, selected: 'email_settings' }; } + componentDidMount() { + AdminStore.addConfigChangeListener(this.onConfigListenerChange); + AsyncClient.getConfig(); + } + + componentWillUnmount() { + AdminStore.removeConfigChangeListener(this.onConfigListenerChange); + } + + onConfigListenerChange() { + this.setState({ + config: AdminStore.getConfig(), + selected: this.state.selected + }); + } + selectTab(tab) { this.setState({selected: tab}); } render() { - var tab = ''; - - if (this.state.selected === 'email_settings') { - tab = <EmailTab />; - } else if (this.state.selected === 'job_settings') { - tab = <JobsTab />; - } else if (this.state.selected === 'logs') { - tab = <LogsTab />; + var tab = <LoadingScreen />; + + if (this.state.config != null) { + if (this.state.selected === 'email_settings') { + tab = <EmailSettingsTab config={this.state.config} />; + } else if (this.state.selected === 'log_settings') { + tab = <LogSettingsTab config={this.state.config} />; + } else if (this.state.selected === 'logs') { + tab = <LogsTab />; + } } return ( @@ -45,7 +68,6 @@ export default class AdminController extends React.Component { /> <div className='inner__wrap channel__wrap'> <div className='row header'> - <Navbar teamDisplayName='Admin Console' /> </div> <div className='row main'> <div diff --git a/web/react/components/admin_console/admin_navbar_dropdown.jsx b/web/react/components/admin_console/admin_navbar_dropdown.jsx new file mode 100644 index 000000000..a3ab81079 --- /dev/null +++ b/web/react/components/admin_console/admin_navbar_dropdown.jsx @@ -0,0 +1,102 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Utils = require('../../utils/utils.jsx'); +var Client = require('../../utils/client.jsx'); +var TeamStore = require('../../stores/team_store.jsx'); + +var Constants = require('../../utils/constants.jsx'); + +function getStateFromStores() { + return {currentTeam: TeamStore.getCurrent()}; +} + +export default class AdminNavbarDropdown extends React.Component { + constructor(props) { + super(props); + this.blockToggle = false; + + this.handleLogoutClick = this.handleLogoutClick.bind(this); + + this.state = getStateFromStores(); + } + + handleLogoutClick(e) { + e.preventDefault(); + Client.logout(); + } + + componentDidMount() { + $(React.findDOMNode(this.refs.dropdown)).on('hide.bs.dropdown', () => { + this.blockToggle = true; + setTimeout(() => { + this.blockToggle = false; + }, 100); + }); + } + + componentWillUnmount() { + $(React.findDOMNode(this.refs.dropdown)).off('hide.bs.dropdown'); + } + + render() { + return ( + <ul className='nav navbar-nav navbar-right'> + <li + ref='dropdown' + className='dropdown' + > + <a + href='#' + className='dropdown-toggle' + data-toggle='dropdown' + role='button' + aria-expanded='false' + > + <span + className='dropdown__icon' + dangerouslySetInnerHTML={{__html: Constants.MENU_ICON}} + /> + </a> + <ul + className='dropdown-menu' + role='menu' + > + <li> + <a + href={Utils.getWindowLocationOrigin() + '/' + this.state.currentTeam.name} + > + {'Switch to ' + this.state.currentTeam.display_name} + </a> + </li> + <li> + <a + href='#' + onClick={this.handleLogoutClick} + > + {'Logout'} + </a> + </li> + <li className='divider'></li> + <li> + <a + target='_blank' + href='/static/help/help.html' + > + {'Help'} + </a> + </li> + <li> + <a + target='_blank' + href='/static/help/report_problem.html' + > + {'Report a Problem'} + </a> + </li> + </ul> + </li> + </ul> + ); + } +}
\ No newline at end of file diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index a04bceef5..a6e689490 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. // See License.txt for license information. -var SidebarHeader = require('../sidebar_header.jsx'); +var AdminSidebarHeader = require('./admin_sidebar_header.jsx'); export default class AdminSidebar extends React.Component { constructor(props) { @@ -14,7 +14,8 @@ export default class AdminSidebar extends React.Component { }; } - handleClick(name) { + handleClick(name, e) { + e.preventDefault(); this.props.selectTab(name); } @@ -27,58 +28,21 @@ export default class AdminSidebar extends React.Component { } componentDidMount() { - $('.nav__menu-item').on('click', function clickme(e) { - e.preventDefault(); - $(this).closest('.sidebar--collapsable').find('.nav__menu-item').removeClass('active'); - $(this).addClass('active'); - $(this).closest('.sidebar--collapsable').find('.nav__sub-menu').addClass('hide'); - $(this).next('.nav__sub-menu').removeClass('hide'); - }); - - $('.nav__sub-menu a').on('click', function clickme(e) { - e.preventDefault(); - $(this).closest('.nav__sub-menu').find('a').removeClass('active'); - $(this).addClass('active'); - }); - - $('.nav__sub-menu-item').on('click', function clickme(e) { - e.preventDefault(); - $(this).closest('.sidebar--collapsable').find('.nav__inner-menu').addClass('hide'); - $(this).closest('li').next('li').find('.nav__inner-menu').removeClass('hide'); - $(this).closest('li').next('li').find('.nav__inner-menu li:first a').addClass('active'); - }); - - $('.nav__inner-menu a').on('click', function clickme() { - $(this).closest('.nav__inner-menu').closest('li').prev('li').find('a').addClass('active'); - }); - - $('.nav__sub-menu .menu__close').on('click', function close() { - var menuItem = $(this).closest('li'); - menuItem.next('li').remove(); - menuItem.remove(); - }); } render() { return ( <div className='sidebar--left sidebar--collapsable'> <div> - <SidebarHeader - teamDisplayName='Admin Console' - teamType='I' - /> + <AdminSidebarHeader /> <ul className='nav nav-pills nav-stacked'> <li> - <a href='#' - className='nav__menu-item active' - > - <span className='icon fa fa-gear'></span> <span>{'Basic Settings'}</span></a> <ul className='nav nav__sub-menu'> <li> <a href='#' className={this.isSelected('email_settings')} - onClick={this.handleClick.bind(null, 'email_settings')} + onClick={this.handleClick.bind(this, 'email_settings')} > {'Email Settings'} </a> @@ -86,110 +50,21 @@ export default class AdminSidebar extends React.Component { <li> <a href='#' - className={this.isSelected('logs')} - onClick={this.handleClick.bind(null, 'logs')} - > - {'Logs'} - </a> - </li> - </ul> - </li> - <li> - <a - href='#' - className='nav__menu-item' - > - <span className='icon fa fa-gear'></span> <span>{'Jobs'}</span> - </a> - <ul className='nav nav__sub-menu hide'> - <li> - <a - href='#' - className={this.isSelected('job_settings')} - onClick={this.handleClick.bind(null, 'job_settings')} + className={this.isSelected('log_settings')} + onClick={this.handleClick.bind(this, 'log_settings')} > - {'Job Settings'} + {'Log Settings'} </a> </li> - </ul> - </li> - <li> - <a - href='#' - className='nav__menu-item' - > - <span className='icon fa fa-gear'></span> - <span>{'Team Settings (306)'}</span> - <span className='menu-icon--right'> - <i className='fa fa-plus'></i> - </span> - </a> - <ul className='nav nav__sub-menu hide'> - <li> - <a - href='#' - className='nav__sub-menu-item active' - > - {'Adal '} - <span className='menu-icon--right menu__close'>{'x'}</span> - </a> - </li> - <li> - <ul className='nav nav__inner-menu'> - <li> - <a - href='#' - className='active' - > - {'- Users'} - </a> - </li> - <li><a href='#'>{'- View Statistics'}</a></li> - <li> - <a href='#'> - {'- View Audit Log'} - <span className='badge pull-right small'>{'1'}</span> - </a> - </li> - </ul> - </li> <li> <a href='#' - className='nav__sub-menu-item' + className={this.isSelected('logs')} + onClick={this.handleClick.bind(this, 'logs')} > - {'Boole '} - <span className='menu-icon--right menu__close'>{'x'}</span> + {'Logs'} </a> </li> - <li> - <ul className='nav nav__inner-menu hide'> - <li> - <a - href='#' - className='active' - > - {'- Users'} - </a> - </li> - <li><a href='#'>{'- View Statistics'}</a></li> - <li> - <a href='#'> - {'- View Audit Log'} - <span className='badge pull-right small'>{'1'}</span> - </a> - </li> - </ul> - </li> - <li> - <span - data-toggle='modal' - data-target='#select-team' - className='nav-more' - > - {'Select a team'} - </span> - </li> </ul> </li> </ul> diff --git a/web/react/components/admin_console/admin_sidebar_header.jsx b/web/react/components/admin_console/admin_sidebar_header.jsx new file mode 100644 index 000000000..81798da45 --- /dev/null +++ b/web/react/components/admin_console/admin_sidebar_header.jsx @@ -0,0 +1,60 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var AdminNavbarDropdown = require('./admin_navbar_dropdown.jsx'); +var UserStore = require('../../stores/user_store.jsx'); + +export default class SidebarHeader extends React.Component { + constructor(props) { + super(props); + + this.toggleDropdown = this.toggleDropdown.bind(this); + + this.state = {}; + } + + toggleDropdown(e) { + e.preventDefault(); + + if (this.refs.dropdown.blockToggle) { + this.refs.dropdown.blockToggle = false; + return; + } + + $('.team__header').find('.dropdown-toggle').dropdown('toggle'); + } + + render() { + var me = UserStore.getCurrentUser(); + var profilePicture = null; + + if (!me) { + return null; + } + + if (me.last_picture_update) { + profilePicture = ( + <img + className='user__picture' + src={'/api/v1/users/' + me.id + '/image?time=' + me.update_at} + /> + ); + } + + return ( + <div className='team__header theme'> + <a + href='#' + onClick={this.toggleDropdown} + > + {profilePicture} + <div className='header__info'> + <div className='user__name'>{'@' + me.username}</div> + <div className='team__name'>{'System Console'}</div> + </div> + </a> + <AdminNavbarDropdown ref='dropdown' /> + </div> + ); + } +}
\ No newline at end of file diff --git a/web/react/components/admin_console/jobs_settings.jsx b/web/react/components/admin_console/jobs_settings.jsx deleted file mode 100644 index 0b4fc4185..000000000 --- a/web/react/components/admin_console/jobs_settings.jsx +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. -// See License.txt for license information. - -export default class Jobs extends React.Component { - constructor(props) { - super(props); - - this.state = { - }; - } - - render() { - return ( - <div className='wrapper--fixed'> - <h3>{' ************** JOB Settings'}</h3> - <form - className='form-horizontal' - role='form' - > - <div className='form-group'> - <label - className='control-label col-sm-4' - htmlFor='email' - > - {'Bypass Email: '} - <a - href='#' - data-trigger='hover click' - data-toggle='popover' - data-position='bottom' - data-content={'Here\'s some more help text inside a popover for the Bypass Email field just to show how popovers look.'} - > - {'(?)'} - </a> - </label> - <div className='col-sm-8'> - <label className='radio-inline'> - <input - type='radio' - name='byPassEmail' - value='option1' - /> - {'True'} - </label> - <label className='radio-inline'> - <input - type='radio' - name='byPassEmail' - value='option2' - /> - {'False'} - </label> - <p className='help-text'>{'This is some sample help text for the Bypass Email field'}</p> - </div> - </div> - <div className='form-group'> - <label - className='control-label col-sm-4' - htmlFor='smtpUsername' - > - {'SMTP Username:'} - </label> - <div className='col-sm-8'> - <input - type='email' - className='form-control' - id='smtpUsername' - placeholder='Enter your SMTP username' - value='' - /> - <div className='help-text'> - <div className='alert alert-warning'><i className='fa fa-warning'></i>{' This is some error text for the Bypass Email field'}</div> - </div> - <p className='help-text'>{'This is some sample help text for the SMTP username field'}</p> - </div> - </div> - <div - className='panel-group' - id='accordion' - role='tablist' - aria-multiselectable='true' - > - <div className='panel panel-default'> - <div - className='panel-heading' - role='tab' - id='headingOne' - > - <h3 className='panel-title'> - <a - className='collapsed' - role='button' - data-toggle='collapse' - data-parent='#accordion' - href='#collapseOne' - aria-expanded='true' - aria-controls='collapseOne' - > - {'Advanced Settings '} - <i className='fa fa-plus'></i> - <i className='fa fa-minus'></i> - </a> - </h3> - </div> - <div - id='collapseOne' - className='panel-collapse collapse' - role='tabpanel' - aria-labelledby='headingOne' - > - <div className='panel-body'> - <div className='form-group'> - <label - className='control-label col-sm-4' - htmlFor='feedbackUsername' - > - {'Apple push server:'} - </label> - <div className='col-sm-8'> - <input - type='text' - className='form-control' - id='feedbackUsername' - placeholder='Enter your Apple push server' - value='' - /> - <p className='help-text'>{'This is some sample help text for the Apple push server field'}</p> - </div> - </div> - <div className='form-group'> - <label - className='control-label col-sm-4' - htmlFor='feedbackUsername' - > - {'Apple push certificate public:'} - </label> - <div className='col-sm-8'> - <input - type='text' - className='form-control' - id='feedbackUsername' - placeholder='Enter your public apple push certificate' - value='' - /> - </div> - </div> - <div className='form-group'> - <label - className='control-label col-sm-4' - htmlFor='feedbackUsername' - > - {'Apple push certificate private:'} - </label> - <div className='col-sm-8'> - <input - type='text' - className='form-control' - id='feedbackUsername' - placeholder='Enter your private apple push certificate' - value='' - /> - </div> - </div> - </div> - </div> - </div> - </div> - - <div className='form-group'> - <div className='col-sm-12'> - <button - type='submit' - className='btn btn-primary' - > - {'Save'} - </button> - </div> - </div> - </form> - </div> - ); - } -}
\ No newline at end of file diff --git a/web/react/components/admin_console/log_settings.jsx b/web/react/components/admin_console/log_settings.jsx new file mode 100644 index 000000000..4e3db8f68 --- /dev/null +++ b/web/react/components/admin_console/log_settings.jsx @@ -0,0 +1,261 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../../utils/client.jsx'); +var AsyncClient = require('../../utils/async_client.jsx'); + +export default class LogSettings extends React.Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + + this.state = { + saveNeeded: false, + serverError: null + }; + } + + handleChange() { + this.setState({saveNeeded: true, serverError: this.state.serverError}); + } + + handleSubmit(e) { + e.preventDefault(); + $('#save-button').button('loading'); + + var config = this.props.config; + config.LogSettings.ConsoleEnable = React.findDOMNode(this.refs.consoleEnable).checked; + config.LogSettings.ConsoleLevel = React.findDOMNode(this.refs.consoleLevel).value; + config.LogSettings.FileEnable = React.findDOMNode(this.refs.fileEnable).checked; + config.LogSettings.FileLevel = React.findDOMNode(this.refs.fileLevel).value; + config.LogSettings.FileLocation = React.findDOMNode(this.refs.fileLocation).value.trim(); + config.LogSettings.FileFormat = React.findDOMNode(this.refs.fileFormat).value.trim(); + + Client.saveConfig( + config, + () => { + AsyncClient.getConfig(); + this.setState({serverError: null, saveNeeded: false}); + $('#save-button').button('reset'); + }, + (err) => { + this.setState({serverError: err.message, saveNeeded: true}); + $('#save-button').button('reset'); + } + ); + } + + render() { + var serverError = ''; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; + } + + var saveClass = 'btn'; + if (this.state.saveNeeded) { + saveClass = 'btn btn-primary'; + } + + return ( + <div className='wrapper--fixed'> + <h3>{'Log Settings'}</h3> + <form + className='form-horizontal' + role='form' + > + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='consoleEnable' + > + {'Log To the Console: '} + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='consoleEnable' + value='true' + ref='consoleEnable' + defaultChecked={this.props.config.LogSettings.ConsoleEnable} + onChange={this.handleChange} + /> + {'true'} + </label> + <label className='radio-inline'> + <input + type='radio' + name='consoleEnable' + value='false' + defaultChecked={!this.props.config.LogSettings.ConsoleEnable} + onChange={this.handleChange} + /> + {'false'} + </label> + <p className='help-text'>{'Typically set to false in production. Developers may set this field to true to output log messages to console based on the console level option. If true then the server will output messages to the standard output stream (stdout).'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='consoleLevel' + > + {'Console Log Level:'} + </label> + <div className='col-sm-8'> + <select + className='form-control' + id='consoleLevel' + ref='consoleLevel' + defaultValue={this.props.config.LogSettings.consoleLevel} + onChange={this.handleChange} + > + <option value='DEBUG'>{'DEBUG'}</option> + <option value='INFO'>{'INFO'}</option> + <option value='ERROR'>{'ERROR'}</option> + </select> + <p className='help-text'>{'This setting determines the level of detail at which log events are written to the console. ERROR: Outputs only error messages. INFO: Outputs error messages and information around startup and initialization. DEBUG: Prints high detail for developers debugging issues working on debugging issues.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + > + {'Log To File: '} + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='fileEnable' + ref='fileEnable' + value='true' + defaultChecked={this.props.config.LogSettings.FileEnable} + onChange={this.handleChange} + /> + {'true'} + </label> + <label className='radio-inline'> + <input + type='radio' + name='fileEnable' + value='false' + defaultChecked={!this.props.config.LogSettings.FileEnable} + onChange={this.handleChange} + /> + {'false'} + </label> + <p className='help-text'>{'Typically set to true in production. When true log files are written to the file specified in file location field below.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='fileLevel' + > + {'File Log Level:'} + </label> + <div className='col-sm-8'> + <select + className='form-control' + id='fileLevel' + ref='fileLevel' + defaultValue={this.props.config.LogSettings.FileLevel} + onChange={this.handleChange} + > + <option value='DEBUG'>{'DEBUG'}</option> + <option value='INFO'>{'INFO'}</option> + <option value='ERROR'>{'ERROR'}</option> + </select> + <p className='help-text'>{'This setting determines the level of detail at which log events are written to the file. ERROR: Outputs only error messages. INFO: Outputs error messages and information around startup and initialization. DEBUG: Prints high detail for developers debugging issues working on debugging issues.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='fileLocation' + > + {'File Location:'} + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='fileLocation' + ref='fileLocation' + placeholder='Enter your file location' + defaultValue={this.props.config.LogSettings.FileLocation} + onChange={this.handleChange} + /> + <p className='help-text'>{'File to which log files are written. If blank, will be set to ./logs/mattermost.log. Log rotation is enabled and new files may be created in the same directory.'}</p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='fileFormat' + > + {'File Format:'} + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='fileFormat' + ref='fileFormat' + placeholder='Enter your file format' + defaultValue={this.props.config.LogSettings.FileFormat} + onChange={this.handleChange} + /> + <p className='help-text'> + {'Format of log message output. If blank will be set to "[%D %T] [%L] %M", where:'} + <div className='help-text'> + <table + className='table-bordered' + cellPadding='5' + > + <tr><td className='help-text'>{'%T'}</td><td className='help-text'>{'Time (15:04:05 MST)'}</td></tr> + <tr><td className='help-text'>{'%D'}</td><td className='help-text'>{'Date (2006/01/02)'}</td></tr> + <tr><td className='help-text'>{'%d'}</td><td className='help-text'>{'Date (01/02/06)'}</td></tr> + <tr><td className='help-text'>{'%L'}</td><td className='help-text'>{'Level (DEBG, INFO, EROR)'}</td></tr> + <tr><td className='help-text'>{'%S'}</td><td className='help-text'>{'Source'}</td></tr> + <tr><td className='help-text'>{'%M'}</td><td className='help-text'>{'Message'}</td></tr> + </table> + </div> + </p> + </div> + </div> + + <div className='form-group'> + <div className='col-sm-12'> + {serverError} + <button + disabled={!this.state.saveNeeded} + type='submit' + className={saveClass} + onClick={this.handleSubmit} + id='save-button' + data-loading-text={'<span class=\'glyphicon glyphicon-refresh glyphicon-refresh-animate\'></span> Saving Config...'} + > + {'Save'} + </button> + </div> + </div> + + </form> + </div> + ); + } +} + +LogSettings.propTypes = { + config: React.PropTypes.object +}; diff --git a/web/react/components/admin_console/logs.jsx b/web/react/components/admin_console/logs.jsx index d7de76a94..0bb749bbd 100644 --- a/web/react/components/admin_console/logs.jsx +++ b/web/react/components/admin_console/logs.jsx @@ -21,9 +21,11 @@ export default class Logs extends React.Component { AdminStore.addLogChangeListener(this.onLogListenerChange); AsyncClient.getLogs(); } + componentWillUnmount() { AdminStore.removeLogChangeListener(this.onLogListenerChange); } + onLogListenerChange() { this.setState({ logs: AdminStore.getLogs() diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index 0dbbc20d4..8d23ec646 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -111,7 +111,7 @@ export default class ChannelHeader extends React.Component { const popoverContent = React.renderToString(<MessageWrapper message={channel.description}/>); let channelTitle = channel.display_name; const currentId = UserStore.getCurrentId(); - const isAdmin = this.state.memberChannel.roles.indexOf('admin') > -1 || this.state.memberTeam.roles.indexOf('admin') > -1; + const isAdmin = Utils.isAdmin(this.state.memberChannel.roles) || Utils.isAdmin(this.state.memberTeam.roles); const isDirect = (this.state.channel.type === 'D'); if (isDirect) { diff --git a/web/react/components/channel_invite_modal.jsx b/web/react/components/channel_invite_modal.jsx index 5feeb4e88..82fc51184 100644 --- a/web/react/components/channel_invite_modal.jsx +++ b/web/react/components/channel_invite_modal.jsx @@ -121,7 +121,7 @@ export default class ChannelInviteModal extends React.Component { var currentMember = ChannelStore.getCurrentMember(); var isAdmin = false; if (currentMember) { - isAdmin = currentMember.roles.indexOf('admin') > -1 || UserStore.getCurrentUser().roles.indexOf('admin') > -1; + isAdmin = utils.isAdmin(currentMember.roles) || utils.isAdmin(UserStore.getCurrentUser().roles); } var content; diff --git a/web/react/components/channel_members.jsx b/web/react/components/channel_members.jsx index 04fa2c7a2..1eda6a104 100644 --- a/web/react/components/channel_members.jsx +++ b/web/react/components/channel_members.jsx @@ -130,7 +130,7 @@ export default class ChannelMembers extends React.Component { const currentMember = ChannelStore.getCurrentMember(); let isAdmin = false; if (currentMember) { - isAdmin = currentMember.roles.indexOf('admin') > -1 || UserStore.getCurrentUser().roles.indexOf('admin') > -1; + isAdmin = Utils.isAdmin(currentMember.roles) || Utils.isAdmin(UserStore.getCurrentUser().roles); } var memberList = null; diff --git a/web/react/components/member_list_item.jsx b/web/react/components/member_list_item.jsx index 9a31a2e30..158ff65be 100644 --- a/web/react/components/member_list_item.jsx +++ b/web/react/components/member_list_item.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. var UserStore = require('../stores/user_store.jsx'); +const Utils = require('../utils/utils.jsx'); export default class MemberListItem extends React.Component { constructor(props) { @@ -26,7 +27,7 @@ export default class MemberListItem extends React.Component { render() { var member = this.props.member; var isAdmin = this.props.isAdmin; - var isMemberAdmin = member.roles.indexOf('admin') > -1; + var isMemberAdmin = Utils.isAdmin(member.roles); var timestamp = UserStore.getCurrentUser().update_at; var invite; diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx index ba8be12b2..65cd40975 100644 --- a/web/react/components/more_channels.jsx +++ b/web/react/components/more_channels.jsx @@ -6,6 +6,7 @@ var client = require('../utils/client.jsx'); var asyncClient = require('../utils/async_client.jsx'); var ChannelStore = require('../stores/channel_store.jsx'); var LoadingScreen = require('./loading_screen.jsx'); +var NewChannelFlow = require('./new_channel_flow.jsx'); function getStateFromStores() { return { @@ -25,6 +26,7 @@ export default class MoreChannels extends React.Component { var initState = getStateFromStores(); initState.channelType = ''; initState.joiningChannel = -1; + initState.showNewChannelModal = false; this.state = initState; } componentDidMount() { @@ -66,6 +68,7 @@ export default class MoreChannels extends React.Component { } handleNewChannel() { $(React.findDOMNode(this.refs.modal)).modal('hide'); + this.setState({showNewChannelModal: true}); } render() { var serverError; @@ -148,20 +151,22 @@ export default class MoreChannels extends React.Component { className='close' data-dismiss='modal' > - <span aria-hidden='true'>×</span> - <span className='sr-only'>Close</span> + <span aria-hidden='true'>{'×'}</span> + <span className='sr-only'>{'Close'}</span> </button> - <h4 className='modal-title'>More Channels</h4> + <h4 className='modal-title'>{'More Channels'}</h4> <button - data-toggle='modal' - data-target='#new_channel' - data-channeltype={this.state.channelType} type='button' className='btn btn-primary channel-create-btn' onClick={this.handleNewChannel} > - Create New Channel + {'Create New Channel'} </button> + <NewChannelFlow + show={this.state.showNewChannelModal} + channelType={this.state.channelType} + onModalDismissed={() => this.setState({showNewChannelModal: false})} + /> </div> <div className='modal-body'> {moreChannels} @@ -173,7 +178,7 @@ export default class MoreChannels extends React.Component { className='btn btn-default' data-dismiss='modal' > - Close + {'Close'} </button> </div> </div> diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx index cae9f12e4..da9874b0b 100644 --- a/web/react/components/navbar.jsx +++ b/web/react/components/navbar.jsx @@ -8,6 +8,7 @@ var ChannelStore = require('../stores/channel_store.jsx'); var TeamStore = require('../stores/team_store.jsx'); var MessageWrapper = require('./message_wrapper.jsx'); var NotifyCounts = require('./notify_counts.jsx'); +const Utils = require('../utils/utils.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; @@ -335,7 +336,7 @@ export default class Navbar extends React.Component { options={{singleline: true, mentionHighlight: false}} /> ); - isAdmin = this.state.member.roles.indexOf('admin') > -1; + isAdmin = Utils.isAdmin(this.state.member.roles); if (channel.type === 'O') { channelTitle = channel.display_name; diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index b7566cfb9..4c01d2c43 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -30,12 +30,12 @@ export default class NavbarDropdown extends React.Component { UserStore.addTeamsChangeListener(this.onListenerChange); TeamStore.addChangeListener(this.onListenerChange); - $(React.findDOMNode(this.refs.dropdown)).on('hide.bs.dropdown', function resetDropdown() { + $(React.findDOMNode(this.refs.dropdown)).on('hide.bs.dropdown', () => { this.blockToggle = true; - setTimeout(function blockTimeout() { + setTimeout(() => { this.blockToggle = false; - }.bind(this), 100); - }.bind(this)); + }, 100); + }); } componentWillUnmount() { UserStore.removeTeamsChangeListener(this.onListenerChange); @@ -53,12 +53,16 @@ export default class NavbarDropdown extends React.Component { var teamLink = ''; var inviteLink = ''; var manageLink = ''; + var sysAdminLink = ''; + var adminDivider = ''; var currentUser = UserStore.getCurrentUser(); var isAdmin = false; + var isSystemAdmin = false; var teamSettings = null; if (currentUser != null) { - isAdmin = currentUser.roles.indexOf('admin') > -1; + isAdmin = Utils.isAdmin(currentUser.roles); + isSystemAdmin = Utils.isInRole(currentUser.roles, 'system_admin'); inviteLink = ( <li> @@ -67,7 +71,7 @@ export default class NavbarDropdown extends React.Component { data-toggle='modal' data-target='#invite_member' > - Invite New Member + {'Invite New Member'} </a> </li> ); @@ -82,7 +86,7 @@ export default class NavbarDropdown extends React.Component { data-title='Team Invite' data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + currentUser.team_id} > - Get Team Invite Link + {'Get Team Invite Link'} </a> </li> ); @@ -97,19 +101,36 @@ export default class NavbarDropdown extends React.Component { data-toggle='modal' data-target='#team_members' > - Manage Team + {'Manage Team'} + </a> + </li> + ); + + adminDivider = (<li className='divider'></li>); + + teamSettings = ( + <li> + <a + href='#' + data-toggle='modal' + data-target='#team_settings' + > + {'Team Settings'} + </a> + </li> + ); + } + + if (isSystemAdmin) { + sysAdminLink = ( + <li> + <a + href='/admin_console' + > + {'System Console'} </a> </li> ); - teamSettings = (<li> - <a - href='#' - data-toggle='modal' - data-target='#team_settings' - > - Team Settings - </a> - </li>); } var teams = []; @@ -123,9 +144,9 @@ export default class NavbarDropdown extends React.Component { ); if (this.state.teams.length > 1 && this.state.currentTeam) { var curTeamName = this.state.currentTeam.name; - this.state.teams.forEach(function listTeams(teamName) { + this.state.teams.forEach((teamName) => { if (teamName !== curTeamName) { - teams.push(<li key={teamName}><a href={Utils.getWindowLocationOrigin() + '/' + teamName}>Switch to {teamName}</a></li>); + teams.push(<li key={teamName}><a href={Utils.getWindowLocationOrigin() + '/' + teamName}>{'Switch to ' + teamName}</a></li>); } }); } @@ -135,7 +156,7 @@ export default class NavbarDropdown extends React.Component { target='_blank' href={Utils.getWindowLocationOrigin() + '/signup_team'} > - Create a New Team + {'Create a New Team'} </a> </li>); @@ -167,21 +188,23 @@ export default class NavbarDropdown extends React.Component { data-toggle='modal' data-target='#user_settings' > - Account Settings + {'Account Settings'} </a> </li> - {teamSettings} {inviteLink} {teamLink} - {manageLink} <li> <a href='#' onClick={this.handleLogoutClick} > - Logout + {'Logout'} </a> </li> + {adminDivider} + {teamSettings} + {manageLink} + {sysAdminLink} {teams} <li className='divider'></li> <li> @@ -189,7 +212,7 @@ export default class NavbarDropdown extends React.Component { target='_blank' href='/static/help/help.html' > - Help + {'Help'} </a> </li> <li> @@ -197,7 +220,7 @@ export default class NavbarDropdown extends React.Component { target='_blank' href='/static/help/report_problem.html' > - Report a Problem + {'Report a Problem'} </a> </li> </ul> diff --git a/web/react/components/new_channel_modal.jsx b/web/react/components/new_channel_modal.jsx index fc7b8c183..c43137744 100644 --- a/web/react/components/new_channel_modal.jsx +++ b/web/react/components/new_channel_modal.jsx @@ -107,8 +107,8 @@ export default class NewChannelModal extends React.Component { {channelSwitchText} </div> <div className={displayNameClass}> - <label className='col-sm-2 form__label control-label'>{'Name'}</label> - <div className='col-sm-10'> + <label className='col-sm-3 form__label control-label'>{'Name'}</label> + <div className='col-sm-9'> <input onChange={this.handleChange} type='text' @@ -121,7 +121,7 @@ export default class NewChannelModal extends React.Component { tabIndex='1' /> {displayNameError} - <p className='input__help'> + <p className='input__help dark'> {'Channel URL: ' + prettyTeamURL + this.props.channelData.name + ' ('} <a href='#' @@ -134,11 +134,11 @@ export default class NewChannelModal extends React.Component { </div> </div> <div className='form-group less'> - <div className='col-sm-2'> + <div className='col-sm-3'> <label className='form__label control-label'>{'Description'}</label> <label className='form__label light'>{'(optional)'}</label> </div> - <div className='col-sm-10'> + <div className='col-sm-9'> <textarea className='form-control no-resize' ref='channel_desc' @@ -150,7 +150,7 @@ export default class NewChannelModal extends React.Component { tabIndex='2' /> <p className='input__help'> - {'This is the purpose of your channel and helps others decide whether to join.'} + {'Description helps others decide whether to join this channel.'} </p> {serverError} </div> diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 3be615bb9..dbbcdc409 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -35,9 +35,6 @@ export default class PostBody extends React.Component { parseEmojis() { twemoji.parse(React.findDOMNode(this), {size: Constants.EMOJI_SIZE}); - this.getAllChildNodes(React.findDOMNode(this)).forEach((current) => { - global.window.emojify.run(current); - }); } componentDidMount() { @@ -154,17 +151,18 @@ export default class PostBody extends React.Component { return ( <div className='post-body'> {comment} - <p + <div key={`${post.id}_message`} id={`${post.id}_message`} className={postClass} > {loading} <span + ref='message_span' onClick={TextFormatting.handleClick} dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} /> - </p> + </div> {fileAttachmentHolder} {embed} </div> diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index c80b287a3..d2a0a4035 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -20,7 +20,7 @@ export default class PostInfo extends React.Component { createDropdown() { var post = this.props.post; var isOwner = UserStore.getCurrentId() === post.user_id; - var isAdmin = UserStore.getCurrentUser().roles.indexOf('admin') > -1; + var isAdmin = utils.isAdmin(UserStore.getCurrentUser().roles); if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || post.state === Constants.POST_DELETED) { return ''; @@ -151,7 +151,10 @@ export default class PostInfo extends React.Component { return ( <ul className='post-header post-info'> <li className='post-header-col'> - <time className='post-profile-time'> + <time + className='post-profile-time' + title={new Date(post.create_at).toString()} + > {utils.displayDateTime(post.create_at)} </time> </li> diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx index faa5e5f0b..703e548fb 100644 --- a/web/react/components/post_list.jsx +++ b/web/react/components/post_list.jsx @@ -83,6 +83,7 @@ export default class PostList extends React.Component { }; } componentDidMount() { + window.onload = () => this.scrollToBottom(); if (this.props.isActive) { this.activate(); this.loadFirstPosts(this.props.channelId); @@ -419,7 +420,7 @@ export default class PostList extends React.Component { var members = ChannelStore.getExtraInfo(channel.id).members; for (var i = 0; i < members.length; i++) { - if (members[i].roles.indexOf('admin') > -1) { + if (utils.isAdmin(members[i].roles)) { return members[i].username; } } diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx index ed136c01f..8cc2d309b 100644 --- a/web/react/components/rhs_comment.jsx +++ b/web/react/components/rhs_comment.jsx @@ -193,7 +193,10 @@ export default class RhsComment extends React.Component { <strong><UserProfile userId={post.user_id} /></strong> </li> <li className='post-header-col'> - <time className='post-right-comment-time'> + <time + className='post-profile-time' + title={new Date(post.create_at).toString()} + > {Utils.displayCommentDateTime(post.create_at)} </time> </li> @@ -204,7 +207,8 @@ export default class RhsComment extends React.Component { <div className='post-body'> <p className={postClass}> {loading} - <span + <div + ref='message_holder' onClick={TextFormatting.handleClick} dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} /> diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx index 85755a85c..86620a499 100644 --- a/web/react/components/rhs_root_post.jsx +++ b/web/react/components/rhs_root_post.jsx @@ -132,7 +132,14 @@ export default class RhsRootPost extends React.Component { <div className='post__content'> <ul className='post-header'> <li className='post-header-col'><strong><UserProfile userId={post.user_id} /></strong></li> - <li className='post-header-col'><time className='post-right-root-time'>{utils.displayCommentDateTime(post.create_at)}</time></li> + <li className='post-header-col'> + <time + className='post-profile-time' + title={new Date(post.create_at).toString()} + > + {utils.displayCommentDateTime(post.create_at)} + </time> + </li> <li className='post-header-col post-header__reply'> <div className='dropdown'> {ownerOptions} @@ -140,7 +147,8 @@ export default class RhsRootPost extends React.Component { </li> </ul> <div className='post-body'> - <p + <div + ref='message_holder' onClick={TextFormatting.handleClick} dangerouslySetInnerHTML={{__html: TextFormatting.formatText(post.message)}} /> diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx index 959411f1e..072c14e0a 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -12,7 +12,8 @@ export default class SidebarHeader extends React.Component { this.state = {}; } - toggleDropdown() { + toggleDropdown(e) { + e.preventDefault(); if (this.refs.dropdown.blockToggle) { this.refs.dropdown.blockToggle = false; return; diff --git a/web/react/components/sidebar_right_menu.jsx b/web/react/components/sidebar_right_menu.jsx index 5ecd502ba..2671d560b 100644 --- a/web/react/components/sidebar_right_menu.jsx +++ b/web/react/components/sidebar_right_menu.jsx @@ -26,7 +26,7 @@ export default class SidebarRightMenu extends React.Component { var isAdmin = false; if (currentUser != null) { - isAdmin = currentUser.roles.indexOf('admin') > -1; + isAdmin = utils.isAdmin(currentUser.roles); inviteLink = ( <li> diff --git a/web/react/components/user_settings_security.jsx b/web/react/components/user_settings_security.jsx index 6ccd09cb1..c10d790ae 100644 --- a/web/react/components/user_settings_security.jsx +++ b/web/react/components/user_settings_security.jsx @@ -251,6 +251,17 @@ export default class SecurityTab extends React.Component { <div className='divider-dark first'/> {passwordSection} <div className='divider-dark'/> + <ul + className='section-min' + > + <li className='col-sm-10 section-title'>{'Version ' + global.window.config.Version}</li> + <li className='col-sm-7 section-describe'> + <div className='text-nowrap'>{'Build Number: ' + global.window.config.BuildNumber}</div> + <div className='text-nowrap'>{'Build Date: ' + global.window.config.BuildDate}</div> + <div className='text-nowrap'>{'Build Hash: ' + global.window.config.BuildHash}</div> + </li> + </ul> + <div className='divider-dark'/> <br></br> <a data-toggle='modal' diff --git a/web/react/package.json b/web/react/package.json index 04e0f6bab..dd7d45f8a 100644 --- a/web/react/package.json +++ b/web/react/package.json @@ -9,7 +9,8 @@ "object-assign": "3.0.0", "react-zeroclipboard-mixin": "0.1.0", "twemoji": "1.4.1", - "babel-runtime": "5.8.24" + "babel-runtime": "5.8.24", + "marked": "0.3.5" }, "devDependencies": { "browserify": "11.0.1", diff --git a/web/react/stores/admin_store.jsx b/web/react/stores/admin_store.jsx index 591b52d05..dd5b60a24 100644 --- a/web/react/stores/admin_store.jsx +++ b/web/react/stores/admin_store.jsx @@ -8,16 +8,22 @@ var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; var LOG_CHANGE_EVENT = 'log_change'; +var CONFIG_CHANGE_EVENT = 'config_change'; class AdminStoreClass extends EventEmitter { constructor() { super(); this.logs = null; + this.config = null; this.emitLogChange = this.emitLogChange.bind(this); this.addLogChangeListener = this.addLogChangeListener.bind(this); this.removeLogChangeListener = this.removeLogChangeListener.bind(this); + + this.emitConfigChange = this.emitConfigChange.bind(this); + this.addConfigChangeListener = this.addConfigChangeListener.bind(this); + this.removeConfigChangeListener = this.removeConfigChangeListener.bind(this); } emitLogChange() { @@ -32,6 +38,18 @@ class AdminStoreClass extends EventEmitter { this.removeListener(LOG_CHANGE_EVENT, callback); } + emitConfigChange() { + this.emit(CONFIG_CHANGE_EVENT); + } + + addConfigChangeListener(callback) { + this.on(CONFIG_CHANGE_EVENT, callback); + } + + removeConfigChangeListener(callback) { + this.removeListener(CONFIG_CHANGE_EVENT, callback); + } + getLogs() { return this.logs; } @@ -39,6 +57,14 @@ class AdminStoreClass extends EventEmitter { saveLogs(logs) { this.logs = logs; } + + getConfig() { + return this.config; + } + + saveConfig(config) { + this.config = config; + } } var AdminStore = new AdminStoreClass(); @@ -51,6 +77,10 @@ AdminStoreClass.dispatchToken = AppDispatcher.register((payload) => { AdminStore.saveLogs(action.logs); AdminStore.emitLogChange(); break; + case ActionTypes.RECIEVED_CONFIG: + AdminStore.saveConfig(action.config); + AdminStore.emitConfigChange(); + break; default: } }); diff --git a/web/react/stores/browser_store.jsx b/web/react/stores/browser_store.jsx index e1ca52746..d2dedb271 100644 --- a/web/react/stores/browser_store.jsx +++ b/web/react/stores/browser_store.jsx @@ -9,9 +9,6 @@ function getPrefix() { return UserStore.getCurrentId() + '_'; } -// Also change model/utils.go ETAG_ROOT_VERSION -var BROWSER_STORE_VERSION = '.5'; - class BrowserStoreClass { constructor() { this.getItem = this.getItem.bind(this); @@ -25,9 +22,9 @@ class BrowserStoreClass { this.isLocalStorageSupported = this.isLocalStorageSupported.bind(this); var currentVersion = localStorage.getItem('local_storage_version'); - if (currentVersion !== BROWSER_STORE_VERSION) { + if (currentVersion !== global.window.config.Version) { this.clear(); - localStorage.setItem('local_storage_version', BROWSER_STORE_VERSION); + localStorage.setItem('local_storage_version', global.window.config.Version); } } diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index 3e23e5c33..ed228f6c4 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -345,6 +345,32 @@ export function getLogs() { ); } +export function getConfig() { + if (isCallInProgress('getConfig')) { + return; + } + + callTracker.getConfig = utils.getTimestamp(); + client.getConfig( + (data, textStatus, xhr) => { + callTracker.getConfig = 0; + + if (xhr.status === 304 || !data) { + return; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECIEVED_CONFIG, + config: data + }); + }, + (err) => { + callTracker.getConfig = 0; + dispatchError(err, 'getConfig'); + } + ); +} + export function findTeams(email) { if (isCallInProgress('findTeams_' + email)) { return; diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index ba3042d78..c9eb09c00 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -297,7 +297,7 @@ export function getLogs(success, error) { dataType: 'json', contentType: 'application/json', type: 'GET', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getLogs', xhr, status, err); error(e); @@ -305,6 +305,35 @@ export function getLogs(success, error) { }); } +export function getConfig(success, error) { + $.ajax({ + url: '/api/v1/admin/config', + dataType: 'json', + contentType: 'application/json', + type: 'GET', + success, + error: function onError(xhr, status, err) { + var e = handleError('getConfig', xhr, status, err); + error(e); + } + }); +} + +export function saveConfig(config, success, error) { + $.ajax({ + url: '/api/v1/admin/save_config', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(config), + success, + error: function onError(xhr, status, err) { + var e = handleError('saveConfig', xhr, status, err); + error(e); + } + }); +} + export function getMeSynchronous(success, error) { var currentUser = null; $.ajax({ diff --git a/web/react/utils/emoticons.jsx b/web/react/utils/emoticons.jsx new file mode 100644 index 000000000..7210201ff --- /dev/null +++ b/web/react/utils/emoticons.jsx @@ -0,0 +1,159 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +const emoticonPatterns = { + smile: /:-?\)/g, // :) + open_mouth: /:o/gi, // :o + scream: /:-o/gi, // :-o + smirk: /[:;]-?]/g, // :] + grinning: /[:;]-?d/gi, // :D + stuck_out_tongue_closed_eyes: /x-d/gi, // x-d + stuck_out_tongue_winking_eye: /[:;]-?p/gi, // ;p + rage: /:-?[\[@]/g, // :@ + frowning: /:-?\(/g, // :( + sob: /:['’]-?\(|:'\(/g, // :`( + kissing_heart: /:-?\*/g, // :* + wink: /;-?\)/g, // ;) + pensive: /:-?\//g, // :/ + confounded: /:-?s/gi, // :s + flushed: /:-?\|/g, // :| + relaxed: /:-?\$/g, // :$ + mask: /:-x/gi, // :-x + heart: /<3|<3/g, // <3 + broken_heart: /<\/3|</3/g, // </3 + thumbsup: /:\+1:/g, // :+1: + thumbsdown: /:\-1:/g // :-1: +}; + +function initializeEmoticonMap() { + const emoticonNames = + ('+1,-1,100,1234,8ball,a,ab,abc,abcd,accept,aerial_tramway,airplane,alarm_clock,alien,ambulance,anchor,angel,' + + 'anger,angry,anguished,ant,apple,aquarius,aries,arrow_backward,arrow_double_down,arrow_double_up,arrow_down,' + + 'arrow_down_small,arrow_forward,arrow_heading_down,arrow_heading_up,arrow_left,arrow_lower_left,' + + 'arrow_lower_right,arrow_right,arrow_right_hook,arrow_up,arrow_up_down,arrow_up_small,arrow_upper_left,' + + 'arrow_upper_right,arrows_clockwise,arrows_counterclockwise,art,articulated_lorry,astonished,atm,b,baby,' + + 'baby_bottle,baby_chick,baby_symbol,back,baggage_claim,balloon,ballot_box_with_check,bamboo,banana,bangbang,' + + 'bank,bar_chart,barber,baseball,basketball,bath,bathtub,battery,bear,bee,beer,beers,beetle,beginner,bell,bento,' + + 'bicyclist,bike,bikini,bird,birthday,black_circle,black_joker,black_medium_small_square,black_medium_square,' + + 'black_nib,black_small_square,black_square,black_square_button,blossom,blowfish,blue_book,blue_car,blue_heart,' + + 'blush,boar,boat,bomb,book,bookmark,bookmark_tabs,books,boom,boot,bouquet,bow,bowling,bowtie,boy,bread,' + + 'bride_with_veil,bridge_at_night,briefcase,broken_heart,bug,bulb,bullettrain_front,bullettrain_side,bus,busstop,' + + 'bust_in_silhouette,busts_in_silhouette,cactus,cake,calendar,calling,camel,camera,cancer,candy,capital_abcd,' + + 'capricorn,car,card_index,carousel_horse,cat,cat2,cd,chart,chart_with_downwards_trend,chart_with_upwards_trend,' + + 'checkered_flag,cherries,cherry_blossom,chestnut,chicken,children_crossing,chocolate_bar,christmas_tree,church,' + + 'cinema,circus_tent,city_sunrise,city_sunset,cl,clap,clapper,clipboard,clock1,clock10,clock1030,clock11,' + + 'clock1130,clock12,clock1230,clock130,clock2,clock230,clock3,clock330,clock4,clock430,clock5,clock530,clock6,' + + 'clock630,clock7,clock730,clock8,clock830,clock9,clock930,closed_book,closed_lock_with_key,closed_umbrella,cloud,' + + 'clubs,cn,cocktail,coffee,cold_sweat,collision,computer,confetti_ball,confounded,confused,congratulations,' + + 'construction,construction_worker,convenience_store,cookie,cool,cop,copyright,corn,couple,couple_with_heart,' + + 'couplekiss,cow,cow2,credit_card,crescent_moon,crocodile,crossed_flags,crown,cry,crying_cat_face,crystal_ball,' + + 'cupid,curly_loop,currency_exchange,curry,custard,customs,cyclone,dancer,dancers,dango,dart,dash,date,de,' + + 'deciduous_tree,department_store,diamond_shape_with_a_dot_inside,diamonds,disappointed,disappointed_relieved,' + + 'dizzy,dizzy_face,do_not_litter,dog,dog2,dollar,dolls,dolphin,donut,door,doughnut,dragon,dragon_face,dress,' + + 'dromedary_camel,droplet,dvd,e-mail,ear,ear_of_rice,earth_africa,earth_americas,earth_asia,egg,eggplant,eight,' + + 'eight_pointed_black_star,eight_spoked_asterisk,electric_plug,elephant,email,end,envelope,es,euro,' + + 'european_castle,european_post_office,evergreen_tree,exclamation,expressionless,eyeglasses,eyes,facepunch,' + + 'factory,fallen_leaf,family,fast_forward,fax,fearful,feelsgood,feet,ferris_wheel,file_folder,finnadie,fire,' + + 'fire_engine,fireworks,first_quarter_moon,first_quarter_moon_with_face,fish,fish_cake,fishing_pole_and_fish,fist,' + + 'five,flags,flashlight,floppy_disk,flower_playing_cards,flushed,foggy,football,fork_and_knife,fountain,four,' + + 'four_leaf_clover,fr,free,fried_shrimp,fries,frog,frowning,fu,fuelpump,full_moon,full_moon_with_face,game_die,gb,' + + 'gem,gemini,ghost,gift,gift_heart,girl,globe_with_meridians,goat,goberserk,godmode,golf,grapes,green_apple,' + + 'green_book,green_heart,grey_exclamation,grey_question,grimacing,grin,grinning,guardsman,guitar,gun,haircut,' + + 'hamburger,hammer,hamster,hand,handbag,hankey,hash,hatched_chick,hatching_chick,headphones,hear_no_evil,heart,' + + 'heart_decoration,heart_eyes,heart_eyes_cat,heartbeat,heartpulse,hearts,heavy_check_mark,heavy_division_sign,' + + 'heavy_dollar_sign,heavy_exclamation_mark,heavy_minus_sign,heavy_multiplication_x,heavy_plus_sign,helicopter,' + + 'herb,hibiscus,high_brightness,high_heel,hocho,honey_pot,honeybee,horse,horse_racing,hospital,hotel,hotsprings,' + + 'hourglass,hourglass_flowing_sand,house,house_with_garden,hurtrealbad,hushed,ice_cream,icecream,id,' + + 'ideograph_advantage,imp,inbox_tray,incoming_envelope,information_desk_person,information_source,innocent,' + + 'interrobang,iphone,it,izakaya_lantern,jack_o_lantern,japan,japanese_castle,japanese_goblin,japanese_ogre,jeans,' + + 'joy,joy_cat,jp,key,keycap_ten,kimono,kiss,kissing,kissing_cat,kissing_closed_eyes,kissing_face,kissing_heart,' + + 'kissing_smiling_eyes,koala,koko,kr,large_blue_circle,large_blue_diamond,large_orange_diamond,last_quarter_moon,' + + 'last_quarter_moon_with_face,laughing,leaves,ledger,left_luggage,left_right_arrow,leftwards_arrow_with_hook,' + + 'lemon,leo,leopard,libra,light_rail,link,lips,lipstick,lock,lock_with_ink_pen,lollipop,loop,loudspeaker,' + + 'love_hotel,love_letter,low_brightness,m,mag,mag_right,mahjong,mailbox,mailbox_closed,mailbox_with_mail,' + + 'mailbox_with_no_mail,man,man_with_gua_pi_mao,man_with_turban,mans_shoe,maple_leaf,mask,massage,meat_on_bone,' + + 'mega,melon,memo,mens,metal,metro,microphone,microscope,milky_way,minibus,minidisc,mobile_phone_off,' + + 'money_with_wings,moneybag,monkey,monkey_face,monorail,mortar_board,mount_fuji,mountain_bicyclist,' + + 'mountain_cableway,mountain_railway,mouse,mouse2,movie_camera,moyai,muscle,mushroom,musical_keyboard,' + + 'musical_note,musical_score,mute,nail_care,name_badge,neckbeard,necktie,negative_squared_cross_mark,' + + 'neutral_face,new,new_moon,new_moon_with_face,newspaper,ng,nine,no_bell,no_bicycles,no_entry,no_entry_sign,' + + 'no_good,no_mobile_phones,no_mouth,no_pedestrians,no_smoking,non-potable_water,nose,notebook,' + + 'notebook_with_decorative_cover,notes,nut_and_bolt,o,o2,ocean,octocat,octopus,oden,office,ok,ok_hand,' + + 'ok_woman,older_man,older_woman,on,oncoming_automobile,oncoming_bus,oncoming_police_car,oncoming_taxi,one,' + + 'open_file_folder,open_hands,open_mouth,ophiuchus,orange_book,outbox_tray,ox,package,page_facing_up,' + + 'page_with_curl,pager,palm_tree,panda_face,paperclip,parking,part_alternation_mark,partly_sunny,' + + 'passport_control,paw_prints,peach,pear,pencil,pencil2,penguin,pensive,performing_arts,persevere,' + + 'person_frowning,person_with_blond_hair,person_with_pouting_face,phone,pig,pig2,pig_nose,pill,pineapple,pisces,' + + 'pizza,plus1,point_down,point_left,point_right,point_up,point_up_2,police_car,poodle,poop,post_office,' + + 'postal_horn,postbox,potable_water,pouch,poultry_leg,pound,pouting_cat,pray,princess,punch,purple_heart,purse,' + + 'pushpin,put_litter_in_its_place,question,rabbit,rabbit2,racehorse,radio,radio_button,rage,rage1,rage2,rage3,' + + 'rage4,railway_car,rainbow,raised_hand,raised_hands,raising_hand,ram,ramen,rat,recycle,red_car,red_circle,' + + 'registered,relaxed,relieved,repeat,repeat_one,restroom,revolving_hearts,rewind,ribbon,rice,rice_ball,' + + 'rice_cracker,rice_scene,ring,rocket,roller_coaster,rooster,rose,rotating_light,round_pushpin,rowboat,ru,' + + 'rugby_football,runner,running,running_shirt_with_sash,sa,sagittarius,sailboat,sake,sandal,santa,satellite,' + + 'satisfied,saxophone,school,school_satchel,scissors,scorpius,scream,scream_cat,scroll,seat,secret,see_no_evil,' + + 'seedling,seven,shaved_ice,sheep,shell,ship,shipit,shirt,shit,shoe,shower,signal_strength,six,six_pointed_star,' + + 'ski,skull,sleeping,sleepy,slot_machine,small_blue_diamond,small_orange_diamond,small_red_triangle,' + + 'small_red_triangle_down,smile,smile_cat,smiley,smiley_cat,smiling_imp,smirk,smirk_cat,smoking,snail,snake,' + + 'snowboarder,snowflake,snowman,sob,soccer,soon,sos,sound,space_invader,spades,spaghetti,sparkle,sparkler,' + + 'sparkles,sparkling_heart,speak_no_evil,speaker,speech_balloon,speedboat,squirrel,star,star2,stars,station,' + + 'statue_of_liberty,steam_locomotive,stew,straight_ruler,strawberry,stuck_out_tongue,stuck_out_tongue_closed_eyes,' + + 'stuck_out_tongue_winking_eye,sun_with_face,sunflower,sunglasses,sunny,sunrise,sunrise_over_mountains,surfer,' + + 'sushi,suspect,suspension_railway,sweat,sweat_drops,sweat_smile,sweet_potato,swimmer,symbols,syringe,tada,' + + 'tanabata_tree,tangerine,taurus,taxi,tea,telephone,telephone_receiver,telescope,tennis,tent,thought_balloon,' + + 'three,thumbsdown,thumbsup,ticket,tiger,tiger2,tired_face,tm,toilet,tokyo_tower,tomato,tongue,top,tophat,' + + 'tractor,traffic_light,train,train2,tram,triangular_flag_on_post,triangular_ruler,trident,triumph,trolleybus,' + + 'trollface,trophy,tropical_drink,tropical_fish,truck,trumpet,tshirt,tulip,turtle,tv,twisted_rightwards_arrows,' + + 'two,two_hearts,two_men_holding_hands,two_women_holding_hands,u5272,u5408,u55b6,u6307,u6708,u6709,u6e80,u7121,' + + 'u7533,u7981,u7a7a,uk,umbrella,unamused,underage,unlock,up,us,v,vertical_traffic_light,vhs,vibration_mode,' + + 'video_camera,video_game,violin,virgo,volcano,vs,walking,waning_crescent_moon,waning_gibbous_moon,warning,watch,' + + 'water_buffalo,watermelon,wave,wavy_dash,waxing_crescent_moon,waxing_gibbous_moon,wc,weary,wedding,whale,whale2,' + + 'wheelchair,white_check_mark,white_circle,white_flower,white_large_square,white_medium_small_square,' + + 'white_medium_square,white_small_square,white_square_button,wind_chime,wine_glass,wink,wolf,woman,' + + 'womans_clothes,womans_hat,womens,worried,wrench,x,yellow_heart,yen,yum,zap,zero,zzz').split(','); + + // use a map to help make lookups faster instead of having to use indexOf on an array + const out = new Map(); + + for (let i = 0; i < emoticonNames.length; i++) { + out[emoticonNames[i]] = true; + } + + return out; +} + +const emoticonMap = initializeEmoticonMap(); + +export function handleEmoticons(text, tokens) { + let output = text; + + function replaceEmoticonWithToken(match, name) { + if (emoticonMap[name]) { + const index = tokens.size; + const alias = `MM_EMOTICON${index}`; + + tokens.set(alias, { + value: `<img align="absmiddle" alt=${match} class="emoji" src=${getImagePathForEmoticon(name)} title=${match} />`, + originalText: match + }); + + return alias; + } + + return match; + } + + output = output.replace(/:([a-zA-Z0-9_-]+):/g, replaceEmoticonWithToken); + + $.each(emoticonPatterns, (name, pattern) => { + // this might look a bit funny, but since the name isn't contained in the actual match + // like with the named emoticons, we need to add it in manually + output = output.replace(pattern, (match) => replaceEmoticonWithToken(match, name)); + }); + + return output; +} + +function getImagePathForEmoticon(name) { + return `/static/images/emoji/${name}.png`; +} diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx new file mode 100644 index 000000000..96da54217 --- /dev/null +++ b/web/react/utils/markdown.jsx @@ -0,0 +1,22 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +const marked = require('marked'); + +export class MattermostMarkdownRenderer extends marked.Renderer { + link(href, title, text) { + let outHref = href; + + if (outHref.lastIndexOf('http', 0) !== 0) { + outHref = `http://${outHref}`; + } + + let output = '<a class="theme" href="' + outHref + '"'; + if (title) { + output += ' title="' + title + '"'; + } + output += '>' + text + '</a>'; + + return output; + } +} diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 2025e16da..be82f7b9c 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -3,21 +3,44 @@ const Autolinker = require('autolinker'); const Constants = require('./constants.jsx'); +const Emoticons = require('./emoticons.jsx'); +const Markdown = require('./markdown.jsx'); const UserStore = require('../stores/user_store.jsx'); const Utils = require('./utils.jsx'); +const marked = require('marked'); + +const markdownRenderer = new Markdown.MattermostMarkdownRenderer(); + // Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, and // @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options // as part of the second parameter: // - searchTerm - If specified, this word is highlighted in the resulting html. Defaults to nothing. // - mentionHighlight - Specifies whether or not to highlight mentions of the current user. Defaults to true. // - singleline - Specifies whether or not to remove newlines. Defaults to false. +// - emoticons - Enables emoticon parsing. Defaults to true. +// - markdown - Enables markdown parsing. Defaults to true. export function formatText(text, options = {}) { - let output = sanitizeHtml(text); + if (!('markdown' in options)) { + options.markdown = true; + } + + // wait until marked can sanitize the html so that we don't break markdown block quotes + let output; + if (!options.markdown) { + output = sanitizeHtml(text); + } else { + output = text; + } + const tokens = new Map(); // replace important words and phrases with tokens - output = autolinkUrls(output, tokens); + if (!('emoticons' in options) || options.emoticon) { + output = Emoticons.handleEmoticons(output, tokens); + } + + output = autolinkUrls(output, tokens, !!options.markdown); output = autolinkAtMentions(output, tokens); output = autolinkHashtags(output, tokens); @@ -29,11 +52,21 @@ export function formatText(text, options = {}) { output = highlightCurrentMentions(output, tokens); } + // perform markdown parsing while we have an html-free input string + if (options.markdown) { + output = marked(output, { + renderer: markdownRenderer, + sanitize: true + }); + } + // reinsert tokens with formatted versions of the important words and phrases output = replaceTokens(output, tokens); // replace newlines with html line breaks - output = replaceNewlines(output, options.singleline); + if (options.singleline) { + output = replaceNewlines(output); + } return output; } @@ -51,17 +84,17 @@ export function sanitizeHtml(text) { return output; } -function autolinkUrls(text, tokens) { +function autolinkUrls(text, tokens, markdown) { function replaceUrlWithToken(autolinker, match) { const linkText = match.getMatchedText(); let url = linkText; - if (!url.lastIndexOf('http', 0) === 0) { + if (url.lastIndexOf('http', 0) !== 0) { url = `http://${linkText}`; } const index = tokens.size; - const alias = `__MM_LINK${index}__`; + const alias = `MM_LINK${index}`; tokens.set(alias, { value: `<a class='theme' target='_blank' href='${url}'>${linkText}</a>`, @@ -81,7 +114,30 @@ function autolinkUrls(text, tokens) { replaceFn: replaceUrlWithToken }); - return autolinker.link(text); + let output = text; + + // temporarily replace markdown links if markdown is enabled so that we don't accidentally parse them twice + const markdownLinkTokens = new Map(); + if (markdown) { + function replaceMarkdownLinkWithToken(markdownLink) { + const index = markdownLinkTokens.size; + const alias = `MM_MARKDOWNLINK${index}`; + + markdownLinkTokens.set(alias, {value: markdownLink}); + + return alias; + } + + output = output.replace(/\]\([^\)]*\)/g, replaceMarkdownLinkWithToken); + } + + output = autolinker.link(output); + + if (markdown) { + output = replaceTokens(output, markdownLinkTokens); + } + + return output; } function autolinkAtMentions(text, tokens) { @@ -91,7 +147,7 @@ function autolinkAtMentions(text, tokens) { const usernameLower = username.toLowerCase(); if (Constants.SPECIAL_MENTIONS.indexOf(usernameLower) !== -1 || UserStore.getProfileByUsername(usernameLower)) { const index = tokens.size; - const alias = `__MM_ATMENTION${index}__`; + const alias = `MM_ATMENTION${index}`; tokens.set(alias, { value: `<a class='mention-link' href='#' data-mention='${usernameLower}'>${mention}</a>`, @@ -119,7 +175,7 @@ function highlightCurrentMentions(text, tokens) { for (const [alias, token] of tokens) { if (mentionKeys.indexOf(token.originalText) !== -1) { const index = tokens.size + newTokens.size; - const newAlias = `__MM_SELFMENTION${index}__`; + const newAlias = `MM_SELFMENTION${index}`; newTokens.set(newAlias, { value: `<span class='mention-highlight'>${alias}</span>`, @@ -138,7 +194,7 @@ function highlightCurrentMentions(text, tokens) { // look for self mentions in the text function replaceCurrentMentionWithToken(fullMatch, prefix, mention) { const index = tokens.size; - const alias = `__MM_SELFMENTION${index}__`; + const alias = `MM_SELFMENTION${index}`; tokens.set(alias, { value: `<span class='mention-highlight'>${mention}</span>`, @@ -162,7 +218,7 @@ function autolinkHashtags(text, tokens) { for (const [alias, token] of tokens) { if (token.originalText.lastIndexOf('#', 0) === 0) { const index = tokens.size + newTokens.size; - const newAlias = `__MM_HASHTAG${index}__`; + const newAlias = `MM_HASHTAG${index}`; newTokens.set(newAlias, { value: `<a class='mention-link' href='#' data-hashtag='${token.originalText}'>${token.originalText}</a>`, @@ -181,7 +237,7 @@ function autolinkHashtags(text, tokens) { // look for hashtags in the text function replaceHashtagWithToken(fullMatch, prefix, hashtag) { const index = tokens.size; - const alias = `__MM_HASHTAG${index}__`; + const alias = `MM_HASHTAG${index}`; tokens.set(alias, { value: `<a class='mention-link' href='#' data-hashtag='${hashtag}'>${hashtag}</a>`, @@ -201,7 +257,7 @@ function highlightSearchTerm(text, tokens, searchTerm) { for (const [alias, token] of tokens) { if (token.originalText === searchTerm) { const index = tokens.size + newTokens.size; - const newAlias = `__MM_SEARCHTERM${index}__`; + const newAlias = `MM_SEARCHTERM${index}`; newTokens.set(newAlias, { value: `<span class='search-highlight'>${alias}</span>`, @@ -219,7 +275,7 @@ function highlightSearchTerm(text, tokens, searchTerm) { function replaceSearchTermWithToken(fullMatch, prefix, word) { const index = tokens.size; - const alias = `__MM_SEARCHTERM${index}__`; + const alias = `MM_SEARCHTERM${index}`; tokens.set(alias, { value: `<span class='search-highlight'>${word}</span>`, @@ -246,11 +302,7 @@ function replaceTokens(text, tokens) { return output; } -function replaceNewlines(text, singleline) { - if (!singleline) { - return text.replace(/\n/g, '<br />'); - } - +function replaceNewlines(text) { return text.replace(/\n/g, ' '); } diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 032cf4ff4..074591489 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -54,6 +54,29 @@ export function isTestDomain() { return false; } +export function isInRole(roles, inRole) { + var parts = roles.split(' '); + for (var i = 0; i < parts.length; i++) { + if (parts[i] === inRole) { + return true; + } + } + + return false; +} + +export function isAdmin(roles) { + if (isInRole(roles, 'admin')) { + return true; + } + + if (isInRole(roles, 'system_admin')) { + return true; + } + + return false; +} + export function getDomainWithOutSub() { var parts = window.location.host.split('.'); diff --git a/web/sass-files/sass/partials/_forms.scss b/web/sass-files/sass/partials/_forms.scss index 268576a98..c8b08f44d 100644 --- a/web/sass-files/sass/partials/_forms.scss +++ b/web/sass-files/sass/partials/_forms.scss @@ -5,9 +5,10 @@ .form__label { text-align: left; padding-right: 3px; - font-weight: bold; + font-weight: 600; font-size: 1.1em; &.light { + font-weight: normal; color: #999; font-size: 1.05em; font-style: italic; @@ -17,6 +18,9 @@ .input__help { color: #777; margin: 10px 0 0 10px; + &.dark { + color: #222; + } &.error { color: #a94442; } diff --git a/web/sass-files/sass/partials/_sidebar--left.scss b/web/sass-files/sass/partials/_sidebar--left.scss index f714a23f8..94583b153 100644 --- a/web/sass-files/sass/partials/_sidebar--left.scss +++ b/web/sass-files/sass/partials/_sidebar--left.scss @@ -11,7 +11,7 @@ padding-top: 44px; } .dropdown-menu { - max-height: 300px; + max-height: 400px; overflow-x: hidden; overflow-y: auto; max-width: 200px; diff --git a/web/static/js/emojify.min.js b/web/static/js/emojify.min.js deleted file mode 100755 index 4fedf3205..000000000 --- a/web/static/js/emojify.min.js +++ /dev/null @@ -1,4 +0,0 @@ -/*! emojify.js - v1.0.5 - - * Copyright (c) Hassan Khan 2015 - */ -!function(e,a){"use strict";"function"==typeof define&&define.amd?define([],a):"object"==typeof exports?module.exports=a():e.emojify=a()}(this,function(){"use strict";var e=function(){function e(){var e={named:/:([a-z0-9A-Z_-]+):/,smile:/:-?\)/g,open_mouth:/:o/gi,scream:/:-o/gi,smirk:/[:;]-?]/g,grinning:/[:;]-?d/gi,stuck_out_tongue_closed_eyes:/x-d/gi,stuck_out_tongue_winking_eye:/[:;]-?p/gi,rage:/:-?[\[@]/g,frowning:/:-?\(/g,sob:/:['’]-?\(|:'\(/g,kissing_heart:/:-?\*/g,wink:/;-?\)/g,pensive:/:-?\//g,confounded:/:-?s/gi,flushed:/:-?\|/g,relaxed:/:-?\$/g,mask:/:-x/gi,heart:/<3|<3/g,broken_heart:/<\/3|</3/g,thumbsup:/:\+1:/g,thumbsdown:/:\-1:/g};return d.ignore_emoticons&&(e={named:/:([a-z0-9A-Z_-]+):/,thumbsup:/:\+1:/g,thumbsdown:/:\-1:/g}),Object.keys(e).map(function(a){return[e[a],a]})}function a(){var e=_.map(function(e){var a=e[0],o=a.source||a;return o=o.replace(/(^|[^\[])\^/g,"$1"),"("+o+")"}).join("|");return new RegExp(e,"gi")}function o(e){return" "===e||" "===e||"\r"===e||"\n"===e||""===e||e===String.fromCharCode(160)}function r(e){var a=null;if(e.replacer)a=e.replacer.apply({config:d},[":"+e.emojiName+":",e.emojiName]);else{var o=d.tag_type||h[d.mode];a=e.win.document.createElement(o),"img"!==o?a.setAttribute("class","emoji emoji-"+e.emojiName):(a.setAttribute("align","absmiddle"),a.setAttribute("alt",":"+e.emojiName+":"),a.setAttribute("class","emoji"),a.setAttribute("src",d.img_dir+"/"+e.emojiName+".png")),a.setAttribute("title",":"+e.emojiName+":")}e.node.splitText(e.match.index),e.node.nextSibling.nodeValue=e.node.nextSibling.nodeValue.substr(e.match[0].length,e.node.nextSibling.nodeValue.length),a.appendChild(e.node.splitText(e.match.index)),e.node.parentNode.insertBefore(a,e.node.nextSibling)}function t(e){if(e[1]&&e[2]){var a=e[2];if(m[a])return a}else for(var o=3;o<e.length-1;o++)if(e[o])return _[o-2][1]}function i(e,a){var o=this.config.tag_type||h[this.config.mode];return"img"!==o?"<"+o+" class='emoji emoji-"+a+"' title=':"+a+":'></"+o+">":"<img align='absmiddle' alt=':"+a+":' class='emoji' src='"+this.config.img_dir+"/"+a+".png' title=':"+a+":' />"}function n(){this.lastEmojiTerminatedAt=-1}function s(o,r){if(!o)return o;r||(r=i),_=e(),c=a();var t=new n;return o.replace(c,function(){var e=Array.prototype.slice.call(arguments,0,-2),a=arguments[arguments.length-2],o=arguments[arguments.length-1],i=t.validate(e,a,o);return i?r.apply({config:d},[arguments[0],i]):arguments[0]})}function l(o,i){"undefined"==typeof o&&(o=d.only_crawl_id?document.getElementById(d.only_crawl_id):document.body);var s=o.ownerDocument,l=s.defaultView||s.parentWindow,u=function(e,a){var o;if(e.hasChildNodes())for(o=e.firstChild;o;)a(o)&&u(o,a),o=o.nextSibling},g=function(e){for(var a,o=[],s=new n;null!==(a=c.exec(e.data));)s.validate(a,a.index,a.input)&&o.push(a);for(var _=o.length;_-->0;){var u=t(o[_]);r({node:e,match:o[_],emojiName:u,replacer:i,win:l})}};_=e(),c=a();var m=[],h=new RegExp(d.blacklist.elements.join("|"),"i"),p=new RegExp(d.blacklist.classes.join("|"),"i");if("undefined"!=typeof l.document.createTreeWalker)for(var b,f=l.document.createTreeWalker(o,l.NodeFilter.SHOW_TEXT|l.NodeFilter.SHOW_ELEMENT,function(e){return 1!==e.nodeType?l.NodeFilter.FILTER_ACCEPT:e.tagName.match(h)||"svg"===e.tagName||e.className.match(p)?l.NodeFilter.FILTER_REJECT:l.NodeFilter.FILTER_SKIP},!1);null!==(b=f.nextNode());)m.push(b);else u(o,function(e){return"undefined"!=typeof e.tagName&&e.tagName.match(h)||"undefined"!=typeof e.className&&e.className.match(p)?!1:1===e.nodeType?!0:(m.push(e),!0)});m.forEach(g)}var _,c,u="+1,-1,100,1234,8ball,a,ab,abc,abcd,accept,aerial_tramway,airplane,alarm_clock,alien,ambulance,anchor,angel,anger,angry,anguished,ant,apple,aquarius,aries,arrow_backward,arrow_double_down,arrow_double_up,arrow_down,arrow_down_small,arrow_forward,arrow_heading_down,arrow_heading_up,arrow_left,arrow_lower_left,arrow_lower_right,arrow_right,arrow_right_hook,arrow_up,arrow_up_down,arrow_up_small,arrow_upper_left,arrow_upper_right,arrows_clockwise,arrows_counterclockwise,art,articulated_lorry,astonished,atm,b,baby,baby_bottle,baby_chick,baby_symbol,back,baggage_claim,balloon,ballot_box_with_check,bamboo,banana,bangbang,bank,bar_chart,barber,baseball,basketball,bath,bathtub,battery,bear,bee,beer,beers,beetle,beginner,bell,bento,bicyclist,bike,bikini,bird,birthday,black_circle,black_joker,black_medium_small_square,black_medium_square,black_nib,black_small_square,black_square,black_square_button,blossom,blowfish,blue_book,blue_car,blue_heart,blush,boar,boat,bomb,book,bookmark,bookmark_tabs,books,boom,boot,bouquet,bow,bowling,bowtie,boy,bread,bride_with_veil,bridge_at_night,briefcase,broken_heart,bug,bulb,bullettrain_front,bullettrain_side,bus,busstop,bust_in_silhouette,busts_in_silhouette,cactus,cake,calendar,calling,camel,camera,cancer,candy,capital_abcd,capricorn,car,card_index,carousel_horse,cat,cat2,cd,chart,chart_with_downwards_trend,chart_with_upwards_trend,checkered_flag,cherries,cherry_blossom,chestnut,chicken,children_crossing,chocolate_bar,christmas_tree,church,cinema,circus_tent,city_sunrise,city_sunset,cl,clap,clapper,clipboard,clock1,clock10,clock1030,clock11,clock1130,clock12,clock1230,clock130,clock2,clock230,clock3,clock330,clock4,clock430,clock5,clock530,clock6,clock630,clock7,clock730,clock8,clock830,clock9,clock930,closed_book,closed_lock_with_key,closed_umbrella,cloud,clubs,cn,cocktail,coffee,cold_sweat,collision,computer,confetti_ball,confounded,confused,congratulations,construction,construction_worker,convenience_store,cookie,cool,cop,copyright,corn,couple,couple_with_heart,couplekiss,cow,cow2,credit_card,crescent_moon,crocodile,crossed_flags,crown,cry,crying_cat_face,crystal_ball,cupid,curly_loop,currency_exchange,curry,custard,customs,cyclone,dancer,dancers,dango,dart,dash,date,de,deciduous_tree,department_store,diamond_shape_with_a_dot_inside,diamonds,disappointed,disappointed_relieved,dizzy,dizzy_face,do_not_litter,dog,dog2,dollar,dolls,dolphin,donut,door,doughnut,dragon,dragon_face,dress,dromedary_camel,droplet,dvd,e-mail,ear,ear_of_rice,earth_africa,earth_americas,earth_asia,egg,eggplant,eight,eight_pointed_black_star,eight_spoked_asterisk,electric_plug,elephant,email,end,envelope,es,euro,european_castle,european_post_office,evergreen_tree,exclamation,expressionless,eyeglasses,eyes,facepunch,factory,fallen_leaf,family,fast_forward,fax,fearful,feelsgood,feet,ferris_wheel,file_folder,finnadie,fire,fire_engine,fireworks,first_quarter_moon,first_quarter_moon_with_face,fish,fish_cake,fishing_pole_and_fish,fist,five,flags,flashlight,floppy_disk,flower_playing_cards,flushed,foggy,football,fork_and_knife,fountain,four,four_leaf_clover,fr,free,fried_shrimp,fries,frog,frowning,fu,fuelpump,full_moon,full_moon_with_face,game_die,gb,gem,gemini,ghost,gift,gift_heart,girl,globe_with_meridians,goat,goberserk,godmode,golf,grapes,green_apple,green_book,green_heart,grey_exclamation,grey_question,grimacing,grin,grinning,guardsman,guitar,gun,haircut,hamburger,hammer,hamster,hand,handbag,hankey,hash,hatched_chick,hatching_chick,headphones,hear_no_evil,heart,heart_decoration,heart_eyes,heart_eyes_cat,heartbeat,heartpulse,hearts,heavy_check_mark,heavy_division_sign,heavy_dollar_sign,heavy_exclamation_mark,heavy_minus_sign,heavy_multiplication_x,heavy_plus_sign,helicopter,herb,hibiscus,high_brightness,high_heel,hocho,honey_pot,honeybee,horse,horse_racing,hospital,hotel,hotsprings,hourglass,hourglass_flowing_sand,house,house_with_garden,hurtrealbad,hushed,ice_cream,icecream,id,ideograph_advantage,imp,inbox_tray,incoming_envelope,information_desk_person,information_source,innocent,interrobang,iphone,it,izakaya_lantern,jack_o_lantern,japan,japanese_castle,japanese_goblin,japanese_ogre,jeans,joy,joy_cat,jp,key,keycap_ten,kimono,kiss,kissing,kissing_cat,kissing_closed_eyes,kissing_face,kissing_heart,kissing_smiling_eyes,koala,koko,kr,large_blue_circle,large_blue_diamond,large_orange_diamond,last_quarter_moon,last_quarter_moon_with_face,laughing,leaves,ledger,left_luggage,left_right_arrow,leftwards_arrow_with_hook,lemon,leo,leopard,libra,light_rail,link,lips,lipstick,lock,lock_with_ink_pen,lollipop,loop,loudspeaker,love_hotel,love_letter,low_brightness,m,mag,mag_right,mahjong,mailbox,mailbox_closed,mailbox_with_mail,mailbox_with_no_mail,man,man_with_gua_pi_mao,man_with_turban,mans_shoe,maple_leaf,mask,massage,meat_on_bone,mega,melon,memo,mens,metal,metro,microphone,microscope,milky_way,minibus,minidisc,mobile_phone_off,money_with_wings,moneybag,monkey,monkey_face,monorail,mortar_board,mount_fuji,mountain_bicyclist,mountain_cableway,mountain_railway,mouse,mouse2,movie_camera,moyai,muscle,mushroom,musical_keyboard,musical_note,musical_score,mute,nail_care,name_badge,neckbeard,necktie,negative_squared_cross_mark,neutral_face,new,new_moon,new_moon_with_face,newspaper,ng,nine,no_bell,no_bicycles,no_entry,no_entry_sign,no_good,no_mobile_phones,no_mouth,no_pedestrians,no_smoking,non-potable_water,nose,notebook,notebook_with_decorative_cover,notes,nut_and_bolt,o,o2,ocean,octocat,octopus,oden,office,ok,ok_hand,ok_woman,older_man,older_woman,on,oncoming_automobile,oncoming_bus,oncoming_police_car,oncoming_taxi,one,open_file_folder,open_hands,open_mouth,ophiuchus,orange_book,outbox_tray,ox,package,page_facing_up,page_with_curl,pager,palm_tree,panda_face,paperclip,parking,part_alternation_mark,partly_sunny,passport_control,paw_prints,peach,pear,pencil,pencil2,penguin,pensive,performing_arts,persevere,person_frowning,person_with_blond_hair,person_with_pouting_face,phone,pig,pig2,pig_nose,pill,pineapple,pisces,pizza,plus1,point_down,point_left,point_right,point_up,point_up_2,police_car,poodle,poop,post_office,postal_horn,postbox,potable_water,pouch,poultry_leg,pound,pouting_cat,pray,princess,punch,purple_heart,purse,pushpin,put_litter_in_its_place,question,rabbit,rabbit2,racehorse,radio,radio_button,rage,rage1,rage2,rage3,rage4,railway_car,rainbow,raised_hand,raised_hands,raising_hand,ram,ramen,rat,recycle,red_car,red_circle,registered,relaxed,relieved,repeat,repeat_one,restroom,revolving_hearts,rewind,ribbon,rice,rice_ball,rice_cracker,rice_scene,ring,rocket,roller_coaster,rooster,rose,rotating_light,round_pushpin,rowboat,ru,rugby_football,runner,running,running_shirt_with_sash,sa,sagittarius,sailboat,sake,sandal,santa,satellite,satisfied,saxophone,school,school_satchel,scissors,scorpius,scream,scream_cat,scroll,seat,secret,see_no_evil,seedling,seven,shaved_ice,sheep,shell,ship,shipit,shirt,shit,shoe,shower,signal_strength,six,six_pointed_star,ski,skull,sleeping,sleepy,slot_machine,small_blue_diamond,small_orange_diamond,small_red_triangle,small_red_triangle_down,smile,smile_cat,smiley,smiley_cat,smiling_imp,smirk,smirk_cat,smoking,snail,snake,snowboarder,snowflake,snowman,sob,soccer,soon,sos,sound,space_invader,spades,spaghetti,sparkle,sparkler,sparkles,sparkling_heart,speak_no_evil,speaker,speech_balloon,speedboat,squirrel,star,star2,stars,station,statue_of_liberty,steam_locomotive,stew,straight_ruler,strawberry,stuck_out_tongue,stuck_out_tongue_closed_eyes,stuck_out_tongue_winking_eye,sun_with_face,sunflower,sunglasses,sunny,sunrise,sunrise_over_mountains,surfer,sushi,suspect,suspension_railway,sweat,sweat_drops,sweat_smile,sweet_potato,swimmer,symbols,syringe,tada,tanabata_tree,tangerine,taurus,taxi,tea,telephone,telephone_receiver,telescope,tennis,tent,thought_balloon,three,thumbsdown,thumbsup,ticket,tiger,tiger2,tired_face,tm,toilet,tokyo_tower,tomato,tongue,top,tophat,tractor,traffic_light,train,train2,tram,triangular_flag_on_post,triangular_ruler,trident,triumph,trolleybus,trollface,trophy,tropical_drink,tropical_fish,truck,trumpet,tshirt,tulip,turtle,tv,twisted_rightwards_arrows,two,two_hearts,two_men_holding_hands,two_women_holding_hands,u5272,u5408,u55b6,u6307,u6708,u6709,u6e80,u7121,u7533,u7981,u7a7a,uk,umbrella,unamused,underage,unlock,up,us,v,vertical_traffic_light,vhs,vibration_mode,video_camera,video_game,violin,virgo,volcano,vs,walking,waning_crescent_moon,waning_gibbous_moon,warning,watch,water_buffalo,watermelon,wave,wavy_dash,waxing_crescent_moon,waxing_gibbous_moon,wc,weary,wedding,whale,whale2,wheelchair,white_check_mark,white_circle,white_flower,white_large_square,white_medium_small_square,white_medium_square,white_small_square,white_square_button,wind_chime,wine_glass,wink,wolf,woman,womans_clothes,womans_hat,womens,worried,wrench,x,yellow_heart,yen,yum,zap,zero,zzz",g=u.split(/,/),m=g.reduce(function(e,a){return e[a]=!0,e},{}),d={blacklist:{ids:[],classes:["no-emojify"],elements:["script","textarea","a","pre","code"]},tag_type:null,only_crawl_id:null,img_dir:"images/emoji",ignore_emoticons:!1,mode:"img"},h={img:"img",sprite:"span","data-uri":"span"};return n.prototype={validate:function(e,a,r){function i(){return n.lastEmojiTerminatedAt=_+a,s}var n=this,s=t(e);if(s){var l=e[0],_=l.length;if(0===a)return i();if(r.length===l.length+a)return i();var c=this.lastEmojiTerminatedAt===a;if(c)return i();if(o(r.charAt(a-1)))return i();var u=o(r.charAt(l.length+a));return u&&c?i():void 0}}},{defaultConfig:d,emojiNames:g,setConfig:function(e){Object.keys(d).forEach(function(a){a in e&&(d[a]=e[a])})},replace:s,run:l}}();return e});
\ No newline at end of file |