├── css
├── signin.scss
├── tracklog.scss
├── bootstrap.scss
├── logs.scss
├── layout.scss
└── heartrate.scss
├── version.go
├── .babelrc
├── script
├── build-docker
├── fmt
├── build
├── build-aci
└── build-release
├── doc
├── screenshots
│ ├── log.jpg
│ ├── logs.jpg
│ ├── log-thumbnail.jpg
│ └── logs-thumbnail.jpg
└── config.md
├── js
├── Dispatcher.js
├── components
│ ├── LogName.js
│ ├── LogMap.js
│ ├── LogCharts.js
│ ├── LogHeartrateChart.js
│ ├── LogElevationChart.js
│ ├── LogSpeedChart.js
│ ├── LogTags.js
│ ├── LogHeartrateZones.js
│ ├── LogDetails.js
│ └── Log.js
├── stores
│ └── LogStore.js
├── upload.js
└── tracklog.js
├── .gitignore
├── pkg
├── geo
│ ├── conversion.go
│ └── equirectangular.go
├── models
│ ├── user.go
│ ├── track.go
│ ├── point.go
│ └── log.go
├── server
│ ├── breadcrumb.go
│ ├── dashboard.go
│ ├── context.go
│ ├── sessions.go
│ ├── server.go
│ └── logs.go
├── utils
│ ├── haversine.go
│ ├── utils.go
│ └── utils_test.go
├── db
│ ├── interface.go
│ ├── postgres.sql
│ └── postgres.go
├── rdp
│ ├── rdp.go
│ └── rdp_test.go
├── config
│ ├── config.go
│ └── config_test.go
└── heartrate
│ └── heartrate.go
├── templates
├── log.html
├── signin.html
├── dashboard.html
├── layout.html
└── logs.html
├── config.toml.example
├── go.mod
├── Dockerfile
├── LICENSE
├── package.json
├── cmd
├── server
│ └── main.go
└── control
│ ├── import.go
│ ├── main.go
│ └── user.go
├── README.md
└── go.sum
/css/signin.scss:
--------------------------------------------------------------------------------
1 | #signin {
2 | margin-top: 80px;
3 | }
4 |
--------------------------------------------------------------------------------
/version.go:
--------------------------------------------------------------------------------
1 | package tracklog
2 |
3 | const Version = "0.1.0"
4 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-react"]
3 | }
4 |
--------------------------------------------------------------------------------
/script/build-docker:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 | docker build -t thcyron/tracklog .
4 |
--------------------------------------------------------------------------------
/doc/screenshots/log.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thcyron/tracklog/HEAD/doc/screenshots/log.jpg
--------------------------------------------------------------------------------
/script/fmt:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | find pkg cmd -type f -name '*.go' -print0 | xargs -0 goimports -w
3 |
--------------------------------------------------------------------------------
/doc/screenshots/logs.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thcyron/tracklog/HEAD/doc/screenshots/logs.jpg
--------------------------------------------------------------------------------
/doc/screenshots/log-thumbnail.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thcyron/tracklog/HEAD/doc/screenshots/log-thumbnail.jpg
--------------------------------------------------------------------------------
/js/Dispatcher.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import {Dispatcher} from "flux";
4 |
5 | export default new Dispatcher();
6 |
--------------------------------------------------------------------------------
/doc/screenshots/logs-thumbnail.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thcyron/tracklog/HEAD/doc/screenshots/logs-thumbnail.jpg
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | cmd/control/control
2 | cmd/server/server
3 | node_modules/
4 | npm-debug.log
5 | public/
6 | dist/
7 | config.toml
8 | *.aci
9 |
--------------------------------------------------------------------------------
/pkg/geo/conversion.go:
--------------------------------------------------------------------------------
1 | package geo
2 |
3 | import "math"
4 |
5 | func ToRad(deg float64) float64 {
6 | return deg * math.Pi / 180
7 | }
8 |
--------------------------------------------------------------------------------
/templates/log.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/pkg/models/user.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type User struct {
4 | ID int
5 | Username string
6 | Password string
7 | PasswordVersion int
8 | }
9 |
--------------------------------------------------------------------------------
/pkg/geo/equirectangular.go:
--------------------------------------------------------------------------------
1 | package geo
2 |
3 | import (
4 | "math"
5 | )
6 |
7 | func EquirectangularProjection(λ, φ, φ1 float64) (x, y float64) {
8 | return λ * math.Cos(φ1), φ
9 | }
10 |
--------------------------------------------------------------------------------
/script/build:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 | export CGO_ENABLED=0
4 | (cd cmd/server && go build)
5 | (cd cmd/control && go build)
6 |
7 | if [ "$1" = "production" ]; then
8 | npm run production:build
9 | else
10 | npm run build
11 | fi
12 |
--------------------------------------------------------------------------------
/pkg/models/track.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "time"
4 |
5 | type Track struct {
6 | ID int
7 | LogID int
8 | Name string
9 | Start time.Time
10 | End time.Time
11 | Duration uint
12 | Distance uint
13 | Points []*Point
14 | }
15 |
--------------------------------------------------------------------------------
/css/tracklog.scss:
--------------------------------------------------------------------------------
1 | @import "./bootstrap";
2 |
3 | @import "../node_modules/bootstrap-sass/assets/stylesheets/_bootstrap";
4 | @import "../node_modules/leaflet/dist/leaflet";
5 |
6 | @import "./layout";
7 | @import "./signin";
8 | @import "./logs";
9 | @import "./heartrate";
10 |
--------------------------------------------------------------------------------
/config.toml.example:
--------------------------------------------------------------------------------
1 | [server]
2 | development = true
3 | listen_address = ":8080"
4 | csrf_auth_key = "SECRETSECRETSECRETSECRETSECRETSE"
5 | signing_key = "SECRETSECRETSECRETSECRETSECRETSECRET"
6 | mapbox_access_token = ""
7 |
8 | [db]
9 | driver = "postgres"
10 | dsn = "dbname=tracklog sslmode=disable"
11 |
--------------------------------------------------------------------------------
/css/bootstrap.scss:
--------------------------------------------------------------------------------
1 | $brand-primary: rgb(30, 179, 0);
2 | $link-hover-color: $brand-primary;
3 | $input-border-focus: $brand-primary;
4 |
5 | $breadcrumb-bg: transparent;
6 | $breadcrumb-padding-vertical: 0;
7 | $breadcrumb-padding-horizontal: 0;
8 |
9 | $font-size-h1: 28px;
10 | $font-size-h2: 24px;
11 | $font-size-h3: 20px;
12 |
--------------------------------------------------------------------------------
/pkg/server/breadcrumb.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | type BreadcrumbItem struct {
4 | Title string
5 | Path string
6 | Active bool
7 | }
8 |
9 | type Breadcrumb struct {
10 | items []BreadcrumbItem
11 | }
12 |
13 | func (b *Breadcrumb) Add(title, path string, active bool) {
14 | b.items = append(b.items, BreadcrumbItem{
15 | Title: title,
16 | Path: path,
17 | Active: active,
18 | })
19 | }
20 |
21 | func (b *Breadcrumb) Items() []BreadcrumbItem {
22 | return b.items
23 | }
24 |
--------------------------------------------------------------------------------
/script/build-aci:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | ACBUILD=${ACBUILD:-acbuild}
3 |
4 | set -e
5 | trap "$ACBUILD end" EXIT
6 |
7 | $ACBUILD begin
8 | $ACBUILD set-name thcyron.de/tracklog
9 | $ACBUILD copy cmd/server/server /bin/server
10 | $ACBUILD copy cmd/control/control /bin/control
11 | $ACBUILD copy templates /templates
12 | $ACBUILD copy public /public
13 | $ACBUILD set-exec /bin/server
14 | $ACBUILD mount add config /config.toml --read-only
15 | $ACBUILD write --overwrite tracklog-latest-linux-amd64.aci
16 |
--------------------------------------------------------------------------------
/css/logs.scss:
--------------------------------------------------------------------------------
1 | .logs-years {
2 | .pagination {
3 | margin: 0 0 20px 0;
4 | }
5 | }
6 |
7 | .log-name {
8 | margin: 0 0 20px 0;
9 | }
10 |
11 | .log-name-edit-field {
12 | margin: 0 0 20px 0;
13 | }
14 |
15 | .log-map {
16 | height: 600px;
17 | margin-bottom: 20px;
18 | }
19 |
20 | .log-charts, .log-charts-tabs {
21 | margin-bottom: 20px;
22 | }
23 |
24 | .log-chart-chart {
25 | height: 300px;
26 | }
27 |
28 | .logs-upload-button-file {
29 | display: none !important;
30 | }
31 |
--------------------------------------------------------------------------------
/pkg/utils/haversine.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "math"
4 |
5 | const earthRadius = 6371000
6 |
7 | func Haversine(lat1, lon1, lat2, lon2 float64) float64 {
8 | dLat := (lat2 - lat1) * (math.Pi / 180.0)
9 | dLon := (lon2 - lon1) * (math.Pi / 180.0)
10 | rlat1 := lat1 * (math.Pi / 180.0)
11 | rlat2 := lat2 * (math.Pi / 180.0)
12 | a1 := math.Sin(dLat/2) * math.Sin(dLat/2)
13 | a2 := math.Sin(dLon/2) * math.Sin(dLon/2) * math.Cos(rlat1) * math.Cos(rlat2)
14 | a := a1 + a2
15 | c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
16 | return earthRadius * c
17 | }
18 |
--------------------------------------------------------------------------------
/script/build-release:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | if [[ $# -ne 3 ]]; then
5 | echo "usage: build-release " >&2
6 | exit 2
7 | fi
8 |
9 | version=$1
10 | os=$2
11 | arch=$3
12 |
13 | GOOS=$os GOARCH=$arch ./script/build
14 |
15 | dir=$(mktemp -d ./tracklog.XXXXXXXX)
16 | trap "rm -rf $dir" EXIT
17 |
18 | dest=$dir/tracklog-$version
19 | mkdir $dest
20 | cp -r templates public config.toml.example $dest
21 | mkdir $dest/bin
22 | cp cmd/server/server $dest/bin/server
23 | cp cmd/control/control $dest/bin/control
24 |
25 | (cd $dir && tar czf - tracklog-$version) > tracklog-$version-$os-$arch.tar.gz
26 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/thcyron/tracklog
2 |
3 | go 1.12
4 |
5 | require (
6 | github.com/codegangsta/negroni v1.0.0
7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible
8 | github.com/gorilla/context v1.1.1
9 | github.com/gorilla/csrf v1.6.0
10 | github.com/gorilla/handlers v1.4.2
11 | github.com/julienschmidt/httprouter v1.2.0
12 | github.com/kylelemons/godebug v1.1.0 // indirect
13 | github.com/lib/pq v1.2.0
14 | github.com/naoina/go-stringutil v0.1.0 // indirect
15 | github.com/naoina/toml v0.1.1
16 | github.com/thcyron/gpx v0.0.0-20160210223831-f95a7bb694cc
17 | github.com/thcyron/sqlbuilder v2.0.1-0.20160307210201-609a1abb836e+incompatible
18 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4
19 | )
20 |
--------------------------------------------------------------------------------
/js/components/LogName.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 |
5 | import Dispatcher from "../Dispatcher";
6 |
7 | export default class LogName extends React.Component {
8 | _onChange(event) {
9 | Dispatcher.dispatch({
10 | type: "log-set-name",
11 | name: event.target.value,
12 | });
13 | }
14 |
15 | render() {
16 | if (this.props.editing) {
17 | return (
18 |
23 | );
24 | }
25 |
26 | return (
27 | {this.props.log.get("name")}
28 | );
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:12 as node-builder
2 | WORKDIR /tracklog
3 | COPY package.json .babelrc ./
4 | RUN npm install
5 | COPY css css/
6 | COPY js js/
7 | RUN mkdir public && npm run production:build
8 |
9 | FROM golang:1.12 as go-builder
10 | WORKDIR /tracklog
11 | COPY go.mod go.sum ./
12 | RUN go mod download
13 | COPY . .
14 | RUN CGO_ENABLED=0 go build -o /bin/tracklog-server ./cmd/server
15 | RUN CGO_ENABLED=0 go build -o /bin/tracklog-control ./cmd/control
16 |
17 | FROM alpine:3.10
18 | WORKDIR /tracklog
19 | COPY templates templates/
20 | COPY --from=node-builder /tracklog/public public/
21 | COPY --from=go-builder /bin/tracklog-server /bin/tracklog-server
22 | COPY --from=go-builder /bin/tracklog-control /bin/tracklog-control
23 | ENTRYPOINT ["/bin/tracklog-server"]
24 |
--------------------------------------------------------------------------------
/js/stores/LogStore.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import {EventEmitter} from "events";
4 |
5 | import Dispatcher from "../Dispatcher";
6 |
7 | class LogStore extends EventEmitter {
8 | init(log) {
9 | this._log = log;
10 | }
11 |
12 | get log() {
13 | return this._log;
14 | }
15 |
16 | constructor() {
17 | super();
18 |
19 | Dispatcher.register((action) => {
20 | switch (action.type) {
21 | case "log-set-name":
22 | this._log = this._log.set("name", action.name);
23 | break;
24 | case "log-set-tags":
25 | this._log = this._log.set("tags", action.tags);
26 | break;
27 | default:
28 | return; // do not emit change event if no action was triggered
29 | }
30 |
31 | this.emit("change");
32 | });
33 | }
34 | }
35 |
36 | export default new LogStore();
37 |
--------------------------------------------------------------------------------
/pkg/db/interface.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import "github.com/thcyron/tracklog/pkg/models"
4 |
5 | type DB interface {
6 | Open(dsn string) error
7 |
8 | UserByID(id int) (*models.User, error)
9 | UserByUsername(username string) (*models.User, error)
10 | AddUser(user *models.User) error
11 | UpdateUser(user *models.User) error
12 | DeleteUser(user *models.User) error
13 |
14 | RecentUserLogs(user *models.User, count int) ([]*models.Log, error)
15 | UserLogYears(user *models.User) ([]int, error)
16 | UserLogByID(user *models.User, id int) (*models.Log, error)
17 | UserLogsByYear(user *models.User, year int) ([]*models.Log, error)
18 | AddUserLog(user *models.User, log *models.Log) error
19 | UpdateLog(log *models.Log) error
20 | DeleteLog(log *models.Log) error
21 | }
22 |
23 | func Driver(name string) DB {
24 | switch name {
25 | case "postgres":
26 | return new(Postgres)
27 | default:
28 | return nil
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/pkg/rdp/rdp.go:
--------------------------------------------------------------------------------
1 | package rdp
2 |
3 | import "math"
4 |
5 | type Point struct {
6 | X, Y float64
7 | Data interface{}
8 | }
9 |
10 | // Reduce reduces points using the Ramer–Douglas–Peucker algorithm.
11 | func Reduce(points []Point, epsilon float64) []Point {
12 | if len(points) <= 2 || epsilon <= 0 {
13 | return points
14 | }
15 |
16 | var (
17 | p, q = points[0], points[len(points)-1]
18 | dmax float64
19 | index int
20 | )
21 | for i := 1; i <= len(points)-2; i++ {
22 | pp := points[i]
23 | d := distance(pp, p, q)
24 | if d > dmax {
25 | dmax = d
26 | index = i
27 | }
28 | }
29 | if index > 0 && dmax > epsilon {
30 | res1 := Reduce(points[0:index], epsilon)
31 | res2 := Reduce(points[index:], epsilon)
32 | return append(res1, res2...)
33 | }
34 |
35 | return []Point{p, q}
36 | }
37 |
38 | func distance(p0, p1, p2 Point) float64 {
39 | return math.Abs((p2.Y-p1.Y)*p0.X-(p2.X-p1.X)*p0.Y+p2.X*p1.Y-p2.Y*p1.X) /
40 | math.Sqrt(math.Pow(p2.Y-p1.Y, 2)+math.Pow(p2.X-p1.X, 2))
41 | }
42 |
--------------------------------------------------------------------------------
/pkg/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "errors"
5 | "io"
6 |
7 | "github.com/naoina/toml"
8 | )
9 |
10 | type Config struct {
11 | Server struct {
12 | Development bool `toml:"development"`
13 | ListenAddress string `toml:"listen_address"`
14 | CSRFAuthKey string `toml:"csrf_auth_key"`
15 | SigningKey string `toml:"signing_key"`
16 | MapboxAccessToken string `toml:"mapbox_access_token"`
17 | } `toml:"server"`
18 | DB struct {
19 | Driver string `toml:"driver"`
20 | DSN string `toml:"dsn"`
21 | } `toml:"db"`
22 | }
23 |
24 | func Read(r io.Reader) (*Config, error) {
25 | config := new(Config)
26 | if err := toml.NewDecoder(r).Decode(&config); err != nil {
27 | return nil, err
28 | }
29 | return config, nil
30 | }
31 |
32 | func Check(config *Config) error {
33 | if config.Server.CSRFAuthKey == "" {
34 | return errors.New("missing server.csrf_auth_key")
35 | }
36 | if config.Server.SigningKey == "" {
37 | return errors.New("missing server.signing_key")
38 | }
39 | return nil
40 | }
41 |
--------------------------------------------------------------------------------
/css/layout.scss:
--------------------------------------------------------------------------------
1 | body {
2 | padding-bottom: 20px;
3 | }
4 |
5 | .navbar {
6 | margin-bottom: 0;
7 | }
8 |
9 | .navbar-brand {
10 | color: $brand-primary !important;
11 | }
12 |
13 | .navbar-upload-log-button {
14 | padding-left: 15px;
15 | }
16 |
17 | .breadcrumb-container {
18 | border-bottom: 1px solid $navbar-default-border;
19 | margin-bottom: 20px;
20 | padding: 10px 0;
21 |
22 | .breadcrumb {
23 | margin: 0;
24 | }
25 | }
26 |
27 | #footer {
28 | border-top: 1px solid $navbar-default-border;
29 | padding-top: 20px;
30 | margin: 20px 0 0 0;
31 | color: $text-muted;
32 |
33 | ul {
34 | margin-bottom: 0;
35 | }
36 | }
37 |
38 | $dl-horizontal-small-offset: 85px;
39 |
40 | .dl-horizontal-small {
41 | dt {
42 | float: left;
43 | width: ($dl-horizontal-small-offset - 10px);
44 | clear: left;
45 | text-align: right;
46 | @include text-overflow;
47 | }
48 | dd {
49 | margin-left: $dl-horizontal-small-offset;
50 | @include clearfix;
51 | }
52 | }
53 |
54 | .kill-bottom-margin > :last-child {
55 | margin-bottom: 0;
56 | }
57 |
--------------------------------------------------------------------------------
/pkg/utils/utils.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "fmt"
4 |
5 | type Duration uint // seconds
6 |
7 | func (d Duration) String() string {
8 | seconds := d % 60
9 | minutes := int(d/60) % 60
10 | hours := int(d / 60 / 60)
11 |
12 | if hours < 24 {
13 | return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
14 | }
15 |
16 | days := int(hours / 24)
17 | hours %= 24
18 | return fmt.Sprintf("%dd %d:%02d:%02d", days, hours, minutes, seconds)
19 | }
20 |
21 | type Distance uint // meters
22 |
23 | func (d Distance) String() string {
24 | km := float64(d) / 1000.0
25 | return fmt.Sprintf("%.02f km", km)
26 | }
27 |
28 | type Speed float64 // meters per second
29 |
30 | func (s Speed) String() string {
31 | kmh := s / 1000.0 * 3600.0
32 | return fmt.Sprintf("%.02f km/h", kmh)
33 | }
34 |
35 | func (s Speed) Pace() Pace {
36 | return Pace(1 / s)
37 | }
38 |
39 | type Pace float64 // seconds per meter
40 |
41 | func (p Pace) String() string {
42 | secPerKM := p * 1000
43 | min := int(secPerKM / 60)
44 | sec := int(secPerKM) % 60
45 | return fmt.Sprintf("%d:%02d min/km", min, sec)
46 | }
47 |
--------------------------------------------------------------------------------
/pkg/models/point.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "math"
5 | "time"
6 |
7 | "github.com/thcyron/gpx"
8 | "github.com/thcyron/tracklog/pkg/utils"
9 | )
10 |
11 | type Point struct {
12 | ID int
13 | TrackID int
14 | Latitude float64
15 | Longitude float64
16 | Time time.Time
17 | Elevation float64
18 | Heartrate uint
19 | }
20 |
21 | func NewPoint(point gpx.Point) *Point {
22 | p := &Point{
23 | Latitude: point.Latitude,
24 | Longitude: point.Longitude,
25 | Time: point.Time,
26 | Elevation: point.Elevation,
27 | }
28 |
29 | ge, err := gpx.ParseGarminTrackPointExtension(point.Extensions)
30 | if err == nil {
31 | p.Heartrate = ge.HeartRate
32 | }
33 |
34 | return p
35 | }
36 |
37 | func (p *Point) DistanceTo(p2 *Point) float64 {
38 | return utils.Haversine(p.Latitude, p.Longitude, p2.Latitude, p2.Longitude)
39 | }
40 |
41 | func (p *Point) SpeedTo(p2 *Point) float64 {
42 | dist := p.DistanceTo(p2)
43 | dur := p.Time.Sub(p2.Time).Seconds()
44 | speed := float64(dist) / float64(dur)
45 | if math.IsInf(speed, 0) {
46 | speed = 0
47 | }
48 | return speed
49 | }
50 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Thomas Cyron
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tracklog",
3 | "version": "0.1.0",
4 | "scripts": {
5 | "build": "npm run build:css && npm run build:js",
6 | "build:css": "node-sass --output-style compressed css/tracklog.scss public/app.css",
7 | "build:js": "browserify -t babelify -d js/tracklog.js > public/app.js",
8 | "production:build": "npm run build:css & npm run production:build:js",
9 | "production:build:js": "NODE_ENV=production browserify -t babelify js/tracklog.js | uglifyjs -m -c > public/app.js"
10 | },
11 | "devDependencies": {
12 | "@babel/cli": "^7.4.4",
13 | "@babel/core": "^7.4.4",
14 | "@babel/preset-env": "^7.4.4",
15 | "@babel/preset-react": "^7.0.0",
16 | "babelify": "^10.0.0",
17 | "browserify": "^16.2.3",
18 | "uglify-js": "^3.5.11",
19 | "node-sass": "^4.12.0"
20 | },
21 | "dependencies": {
22 | "react": "^16.8.6",
23 | "react-dom": "^16.8.6",
24 | "flux": "^3.1.3",
25 | "bootstrap-sass": "^3.4.1",
26 | "leaflet": "^1.5.1",
27 | "whatwg-fetch": "^3.0.0",
28 | "immutable": "^3.8.2",
29 | "highcharts": "^7.1.1",
30 | "classnames": "^2.2.6"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/pkg/config/config_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "bytes"
5 | "testing"
6 | )
7 |
8 | var data = []byte(`[server]
9 | development = true
10 | listen_address = ":8080"
11 | csrf_auth_key = "secr"
12 | signing_key = "secret"
13 | mapbox_access_token = "abc"
14 |
15 | [db]
16 | driver = "postgres"
17 | dsn = "dbname=tracklog"
18 | `)
19 |
20 | func TestRead(t *testing.T) {
21 | c, err := Read(bytes.NewReader(data))
22 | if err != nil {
23 | t.Fatal(err)
24 | }
25 |
26 | if expected := true; c.Server.Development != expected {
27 | t.Errorf("expected Server.Development = %v; got %v", expected, c.Server.Development)
28 | }
29 | if expected := ":8080"; c.Server.ListenAddress != expected {
30 | t.Errorf("expected Server.ListenAddress = %q; got %q", expected, c.Server.ListenAddress)
31 | }
32 | if expected := "secr"; c.Server.CSRFAuthKey != expected {
33 | t.Errorf("expected Server.CSRFAuthKey = %q; got %q", expected, c.Server.CSRFAuthKey)
34 | }
35 |
36 | if expected := "postgres"; c.DB.Driver != expected {
37 | t.Errorf("expected DB.Driver = %q; got %q", expected, c.DB.Driver)
38 | }
39 | if expected := "dbname=tracklog"; c.DB.DSN != expected {
40 | t.Errorf("expected DB.DSN = %q; got %q", expected, c.DB.DSN)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/cmd/server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "net/http"
8 | "os"
9 |
10 | "github.com/thcyron/tracklog/pkg/config"
11 | "github.com/thcyron/tracklog/pkg/db"
12 | "github.com/thcyron/tracklog/pkg/server"
13 | )
14 |
15 | var (
16 | configFile = flag.String("config", "config.toml", "config file")
17 | )
18 |
19 | func main() {
20 | flag.Parse()
21 |
22 | f, err := os.Open(*configFile)
23 | if err != nil {
24 | die("cannot open config file: %s", err)
25 | }
26 | defer f.Close()
27 | conf, err := config.Read(f)
28 | if err != nil {
29 | die("cannot read config file: %s", err)
30 | }
31 | if err := config.Check(conf); err != nil {
32 | die("invalid config file: %s", err)
33 | }
34 |
35 | DB := db.Driver(conf.DB.Driver)
36 | if DB == nil {
37 | die("unknown database driver %s", conf.DB.Driver)
38 | }
39 | if err := DB.Open(conf.DB.DSN); err != nil {
40 | die("cannot open database: %s", err)
41 | }
42 |
43 | s, err := server.New(conf, DB)
44 | if err != nil {
45 | die("%s", err)
46 | }
47 |
48 | log.Fatalln(http.ListenAndServe(conf.Server.ListenAddress, s))
49 | }
50 |
51 | func die(format string, args ...interface{}) {
52 | fmt.Fprintf(os.Stderr, "server: "+fmt.Sprintf(format, args...)+"\n")
53 | os.Exit(1)
54 | }
55 |
--------------------------------------------------------------------------------
/js/upload.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | export function uploadLog({file}) {
4 | return new Promise((resolve, reject) => {
5 | const reader = new FileReader();
6 |
7 | reader.onload = (event) => {
8 | window.fetch("/logs", {
9 | method: "POST",
10 | credentials: "same-origin",
11 | headers: {
12 | "Content-Type": "application/json; charset=utf-8",
13 | "X-CSRF-Token": Tracklog.csrfToken,
14 | },
15 | body: JSON.stringify({
16 | "filename": file.name,
17 | "gpx": reader.result,
18 | }),
19 | })
20 | .then((data) => {
21 | return data.json();
22 | })
23 | .then((json) => {
24 | if (json.id) {
25 | resolve({
26 | id: json.id,
27 | });
28 | } else {
29 | reject(new Error("bad response from server"));
30 | }
31 | })
32 | .catch((err) => {
33 | reject(err);
34 | });
35 | };
36 |
37 | reader.onerror = () => {
38 | reject(reader.error);
39 | };
40 |
41 | reader.readAsText(file);
42 | });
43 | }
44 |
45 | export function uploadLogs({files}) {
46 | return Promise.all(files.map((file) => {
47 | return uploadLog({
48 | file: file,
49 | });
50 | }));
51 | }
52 |
--------------------------------------------------------------------------------
/doc/config.md:
--------------------------------------------------------------------------------
1 | # Config
2 |
3 | Tracklog is configured via a TOML file.
4 |
5 | ## `server` Section
6 |
7 | The `server` section configures the server.
8 |
9 | | Key | Required | Description |
10 | | --------------------- | -------- | ------------------------------------------------------ |
11 | | `development` | no | Enables development mode when set to `true` |
12 | | `listen_address` | yes | Listen address of the HTTP server |
13 | | `csrf_auth_key` | yes | Secret key used for CSRF protection (must be 32 bytes) |
14 | | `signing_key` | yes | Secret key used for signing tokens |
15 | | `mapbox_access_token` | no | Mapbox access token for Mapbox maps |
16 |
17 | ## `db` section
18 |
19 | The `db` section configures the database.
20 |
21 | | Key | Required | Description |
22 | | --------------------- | -------- | ------------------------------------------------------ |
23 | | `driver` | yes | Driver |
24 | | `dsn` | yes | Data source name |
25 |
--------------------------------------------------------------------------------
/pkg/server/dashboard.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/thcyron/tracklog/pkg/utils"
7 | )
8 |
9 | const dashboardRecentLogsCount = 5
10 |
11 | type dashboardData struct {
12 | Logs []dashboardLog
13 | }
14 |
15 | type dashboardLog struct {
16 | ID int
17 | Name string
18 | Date string
19 | Duration string
20 | Distance string
21 | Tags []string
22 | }
23 |
24 | func (s *Server) HandleDashboard(w http.ResponseWriter, r *http.Request) {
25 | ctx := NewContext(r, w)
26 |
27 | user := ctx.User()
28 | if user == nil {
29 | s.redirectToSignIn(w, r)
30 | return
31 | }
32 |
33 | logs, err := s.db.RecentUserLogs(user, dashboardRecentLogsCount)
34 | if err != nil {
35 | panic(err)
36 | }
37 |
38 | data := new(dashboardData)
39 | for _, log := range logs {
40 | data.Logs = append(data.Logs, dashboardLog{
41 | ID: log.ID,
42 | Name: log.Name,
43 | Date: log.Start.Format(logTimeFormat),
44 | Duration: utils.Duration(log.Duration).String(),
45 | Distance: utils.Distance(log.Distance).String(),
46 | Tags: log.Tags,
47 | })
48 | }
49 |
50 | ctx.SetTitle("Dashboard")
51 | ctx.Breadcrumb().Add("Dashboard", "", true)
52 | ctx.SetActiveTab("dashboard")
53 | ctx.SetData(data)
54 |
55 | s.render(w, r, "dashboard")
56 | }
57 |
--------------------------------------------------------------------------------
/templates/signin.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Sign In – Tracklog
6 |
7 |
8 |
11 |
12 |
13 |
14 |
15 |
16 | {{if .Data.Alert}}
17 |
18 |
{{.Data.Alert}}
19 |
20 | {{end}}
21 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/css/heartrate.scss:
--------------------------------------------------------------------------------
1 | $heart-rate-red-color: $progress-bar-danger-bg;
2 | $heart-rate-anaerobic-color: $progress-bar-warning-bg;
3 | $heart-rate-aerobic-color: lighten($progress-bar-success-bg, 10%);
4 | $heart-rate-fatburning-color: darken($progress-bar-success-bg, 10%);
5 | $heart-rate-easy-color: $progress-bar-info-bg;
6 | $heart-rate-none-color: $gray-light;
7 |
8 | .text-heart-rate-red {
9 | color: $heart-rate-red-color;
10 | }
11 |
12 | .text-heart-rate-anaerobic {
13 | color: $heart-rate-anaerobic-color;
14 | }
15 |
16 | .text-heart-rate-aerobic {
17 | color: $heart-rate-aerobic-color;
18 | }
19 |
20 | .text-heart-rate-fatburning {
21 | color: $heart-rate-fatburning-color;
22 | }
23 |
24 | .text-heart-rate-easy {
25 | color: $heart-rate-easy-color;
26 | }
27 |
28 | .text-heart-rate-none {
29 | color: $heart-rate-none-color;
30 | }
31 |
32 | .heart-rate-red {
33 | @include progress-bar-variant($heart-rate-red-color);
34 | }
35 |
36 | .heart-rate-anaerobic {
37 | @include progress-bar-variant($heart-rate-anaerobic-color);
38 | }
39 |
40 | .heart-rate-aerobic {
41 | @include progress-bar-variant($heart-rate-aerobic-color);
42 | }
43 |
44 | .heart-rate-fatburning {
45 | @include progress-bar-variant($heart-rate-fatburning-color);
46 | }
47 |
48 | .heart-rate-easy {
49 | @include progress-bar-variant($heart-rate-easy-color);
50 | }
51 |
52 | .heart-rate-none {
53 | @include progress-bar-variant($heart-rate-none-color);
54 | }
55 |
--------------------------------------------------------------------------------
/pkg/rdp/rdp_test.go:
--------------------------------------------------------------------------------
1 | package rdp
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestReduce(t *testing.T) {
9 | testCases := []struct {
10 | points []Point
11 | reduced []Point
12 | epsilon float64
13 | }{
14 | {
15 | points: []Point{
16 | Point{X: 0, Y: 0},
17 | Point{X: 1, Y: 1},
18 | },
19 | reduced: []Point{
20 | Point{X: 0, Y: 0},
21 | Point{X: 1, Y: 1},
22 | },
23 | epsilon: 1,
24 | },
25 | {
26 | points: []Point{
27 | Point{X: 0, Y: 0},
28 | Point{X: 1, Y: 1},
29 | Point{X: 2, Y: 2},
30 | },
31 | reduced: []Point{
32 | Point{X: 0, Y: 0},
33 | Point{X: 2, Y: 2},
34 | },
35 | epsilon: 1,
36 | },
37 | {
38 | points: []Point{
39 | Point{X: 0, Y: 0},
40 | Point{X: 1, Y: 1},
41 | Point{X: 2, Y: 2},
42 | },
43 | reduced: []Point{
44 | Point{X: 0, Y: 0},
45 | Point{X: 1, Y: 1},
46 | Point{X: 2, Y: 2},
47 | },
48 | epsilon: 0,
49 | },
50 | {
51 | points: []Point{
52 | Point{X: 0, Y: 0},
53 | Point{X: 1, Y: 3},
54 | Point{X: 2, Y: 2},
55 | },
56 | reduced: []Point{
57 | Point{X: 0, Y: 0},
58 | Point{X: 1, Y: 3},
59 | Point{X: 2, Y: 2},
60 | },
61 | epsilon: 1,
62 | },
63 | }
64 |
65 | for i, testCase := range testCases {
66 | r := Reduce(testCase.points, testCase.epsilon)
67 | if !reflect.DeepEqual(r, testCase.reduced) {
68 | t.Errorf("test case %d: reduced points do not match: %v <> %v", i, r, testCase.reduced)
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/pkg/models/log.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "bytes"
5 | "math"
6 | "time"
7 |
8 | "github.com/thcyron/gpx"
9 | )
10 |
11 | type Log struct {
12 | ID int
13 | UserID int
14 | Name string
15 | Start time.Time
16 | End time.Time
17 | Duration uint
18 | Distance uint
19 | GPX string
20 | Tracks []*Track
21 | Tags []string
22 | }
23 |
24 | func NewLog(name string, data []byte) (*Log, error) {
25 | doc, err := gpx.NewDecoder(bytes.NewReader(data)).Decode()
26 | if err != nil {
27 | return nil, err
28 | }
29 |
30 | log := &Log{
31 | Name: name,
32 | Start: doc.Start(),
33 | End: doc.End(),
34 | Duration: uint(doc.Duration().Seconds()),
35 | Distance: uint(doc.Distance()),
36 | GPX: string(data),
37 | }
38 |
39 | for _, track := range doc.Tracks {
40 | for _, segment := range track.Segments {
41 | t := &Track{
42 | Name: track.Name,
43 | Start: segment.Start(),
44 | End: segment.End(),
45 | Duration: uint(segment.Duration().Seconds()),
46 | Distance: uint(segment.Distance()),
47 | }
48 |
49 | for _, point := range segment.Points {
50 | t.Points = append(t.Points, NewPoint(point))
51 | }
52 |
53 | log.Tracks = append(log.Tracks, t)
54 | }
55 | }
56 |
57 | return log, nil
58 | }
59 |
60 | // Speed returns the speed in meters per second.
61 | func (log *Log) Speed() float64 {
62 | speed := float64(log.Distance) / float64(log.Duration)
63 | if math.IsInf(speed, 0) {
64 | speed = 0
65 | }
66 | return speed
67 | }
68 |
--------------------------------------------------------------------------------
/js/components/LogMap.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 | import ReactDOM from "react-dom";
5 | import Leaflet from "leaflet";
6 |
7 | export default class LogMap extends React.Component {
8 | componentDidMount() {
9 | this.map = Leaflet.map(ReactDOM.findDOMNode(this));
10 |
11 | let layer;
12 | if (this.props.mapboxAccessToken) {
13 | layer = Leaflet.tileLayer(`https://api.mapbox.com/v4/mapbox.outdoors/{z}/{x}/{y}.png?access_token=${this.props.mapboxAccessToken}`, {
14 | attribution: `Terms & Feedback`,
15 | });
16 | } else {
17 | layer = Leaflet.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png");
18 | }
19 |
20 | this.map.addLayer(layer);
21 | this.updateMap();
22 | }
23 |
24 | componentWillUnmount() {
25 | this.map.remove();
26 | this.map = null;
27 | }
28 |
29 | updateMap() {
30 | if (this.multiPolyline) {
31 | this.map.removeLayer(this.multiPolyline);
32 | }
33 |
34 | const latlngs = this.props.log.get("tracks").map((track) => {
35 | return track.map((point) => {
36 | return [point.get("lat"), point.get("lon")];
37 | });
38 | }).toJS();
39 |
40 | this.multiPolyline = Leaflet.polyline(latlngs, { color: "red", weight: 3, opacity: 0.8 });
41 | this.multiPolyline.addTo(this.map);
42 | this.map.fitBounds(this.multiPolyline.getBounds());
43 | }
44 |
45 | render() {
46 | if (this.map) {
47 | this.updateMap();
48 | }
49 |
50 | return ;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/cmd/control/import.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "io/ioutil"
7 | "log"
8 | "os"
9 | "path"
10 | "strings"
11 |
12 | "github.com/thcyron/tracklog/pkg/models"
13 | )
14 |
15 | func importUsage() {
16 | fmt.Fprint(os.Stderr, `usage: tracklog-control import [options]
17 |
18 | Options:
19 |
20 | -user User to import files for
21 | `)
22 | os.Exit(2)
23 | }
24 |
25 | func importCmd(args []string) {
26 | flags := flag.NewFlagSet("tracklog-control import", flag.ExitOnError)
27 | flags.Usage = usage
28 | username := flags.String("user", "", "username")
29 | flags.Parse(args)
30 | if *username == "" || flags.NArg() == 0 {
31 | usage()
32 | }
33 |
34 | user, err := database.UserByUsername(*username)
35 | if err != nil {
36 | log.Fatalf("cannot load user: %s\n", err)
37 | }
38 | if user == nil {
39 | log.Fatalf("no such user: %s\n", *username)
40 | }
41 |
42 | for _, fileName := range flags.Args() {
43 | if err := importFile(user, fileName); err != nil {
44 | fmt.Fprintf(os.Stderr, "%s: %s\n", fileName, err)
45 | } else {
46 | fmt.Println(fileName)
47 | }
48 | }
49 | }
50 |
51 | func importFile(user *models.User, fileName string) error {
52 | f, err := os.Open(fileName)
53 | if err != nil {
54 | return err
55 | }
56 | defer f.Close()
57 |
58 | data, err := ioutil.ReadAll(f)
59 | if err != nil {
60 | return err
61 | }
62 |
63 | name := strings.TrimSuffix(path.Base(fileName), ".gpx")
64 | log, err := models.NewLog(name, data)
65 | if err != nil {
66 | return err
67 | }
68 |
69 | return database.AddUserLog(user, log)
70 | }
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tracklog
2 |
3 | **Tracklog** is a web application for managing GPX track files written in Go.
4 |
5 |
6 |
7 |
8 | ## Run Tracklog with Docker
9 |
10 | There’s a Docker image [thcyron/tracklog](https://hub.docker.com/r/thcyron/tracklog)
11 | for Tracklog. This image only contains the server and import binary, you have to
12 | bring your own Postgres server. You also have to provide a config file.
13 |
14 | docker run -v /path/to/config.toml:/config.toml -p 8080:8080 thcyron/tracklog
15 |
16 | ## Installation
17 |
18 | First, make sure you have Go and Node.js installed.
19 |
20 | To build the JavaScript and CSS assets, run:
21 |
22 | npm install
23 | npm run build
24 |
25 | Now, build the command line programs:
26 |
27 | ./script/build
28 |
29 | Create and initialize a new Postgres database:
30 |
31 | createdb tracklog
32 | psql tracklog < db/postgres.sql
33 |
34 | Add a new user:
35 |
36 | cmd/control/control -config config.toml user add
37 |
38 | Start the server and point your browser to http://localhost:8080/:
39 |
40 | cmd/server/server -config config.toml
41 |
42 | You can batch-import your GPX files with:
43 |
44 | cmd/control/control -config config.toml import -user /path/to/your/*.gpx
45 |
46 | ## License
47 |
48 | Tracklog is licensed under the MIT license.
49 |
--------------------------------------------------------------------------------
/pkg/heartrate/heartrate.go:
--------------------------------------------------------------------------------
1 | package heartrate
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/thcyron/tracklog/pkg/models"
7 | )
8 |
9 | type Summary struct {
10 | Average int
11 | Rates []Heartrate
12 | Zones map[Zone]int
13 | }
14 |
15 | type Heartrate struct {
16 | BPM int
17 | Time time.Time
18 | }
19 |
20 | type Zone string
21 |
22 | const (
23 | ZoneRed Zone = "Red"
24 | ZoneAnaerobic Zone = "Anaerobic"
25 | ZoneAerobic Zone = "Aerobic"
26 | ZoneFatBurning Zone = "Fat Burning"
27 | ZoneEasy Zone = "Easy"
28 | ZoneNone Zone = "None"
29 | )
30 |
31 | func (z Zone) String() string {
32 | return string(z)
33 | }
34 |
35 | func (hr Heartrate) Zone() Zone {
36 | switch {
37 | case hr.BPM >= 175:
38 | return ZoneRed
39 | case hr.BPM >= 164:
40 | return ZoneAnaerobic
41 | case hr.BPM >= 153:
42 | return ZoneAerobic
43 | case hr.BPM >= 142:
44 | return ZoneFatBurning
45 | case hr.BPM >= 131:
46 | return ZoneEasy
47 | default:
48 | return ZoneNone
49 | }
50 | }
51 |
52 | func SummaryForLog(log *models.Log) Summary {
53 | summary := Summary{
54 | Zones: make(map[Zone]int),
55 | }
56 | sum := uint(0)
57 |
58 | for _, track := range log.Tracks {
59 | for _, point := range track.Points {
60 | if point.Heartrate > 0 {
61 | hr := Heartrate{
62 | BPM: int(point.Heartrate),
63 | Time: point.Time,
64 | }
65 |
66 | summary.Rates = append(summary.Rates, hr)
67 | summary.Zones[hr.Zone()]++
68 | sum += point.Heartrate
69 | }
70 | }
71 | }
72 |
73 | if sum > 0 {
74 | summary.Average = int(sum / uint(len(summary.Rates)))
75 | }
76 | return summary
77 | }
78 |
--------------------------------------------------------------------------------
/cmd/control/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "os"
8 |
9 | "github.com/thcyron/tracklog/pkg/config"
10 | "github.com/thcyron/tracklog/pkg/db"
11 | )
12 |
13 | var (
14 | conf *config.Config
15 | database db.DB
16 | )
17 |
18 | func init() {
19 | log.SetFlags(0)
20 | }
21 |
22 | func main() {
23 | flags := flag.NewFlagSet("tracklog-control", flag.ExitOnError)
24 | flags.Usage = usage
25 | configFile := flags.String("config", "config.toml", "path to config file")
26 | flags.Parse(os.Args[1:])
27 | if flags.NArg() == 0 {
28 | usage()
29 | }
30 |
31 | cnf, err := readConfig(*configFile)
32 | if err != nil {
33 | log.Fatalln(err)
34 | }
35 | conf = cnf
36 |
37 | dbase := db.Driver(conf.DB.Driver)
38 | if dbase == nil {
39 | log.Fatalf("unknown database driver %s\n", conf.DB.Driver)
40 | }
41 | if err := dbase.Open(conf.DB.DSN); err != nil {
42 | log.Fatalf("cannot open database: %s\n", err)
43 | }
44 | database = dbase
45 |
46 | args := flags.Args()
47 | switch args[0] {
48 | case "user":
49 | userCmd(args[1:])
50 | case "import":
51 | importCmd(args[1:])
52 | default:
53 | usage()
54 | }
55 | }
56 |
57 | func readConfig(path string) (*config.Config, error) {
58 | f, err := os.Open(path)
59 | if err != nil {
60 | return nil, err
61 | }
62 | defer f.Close()
63 | return config.Read(f)
64 | }
65 |
66 | func usage() {
67 | fmt.Fprint(os.Stderr, `usage: tracklog-control [options] [arg...]
68 |
69 | Options:
70 |
71 | -config Path to config file (default: config.toml)
72 |
73 | Commands:
74 |
75 | user Manage user accounts
76 | import Import .gpx files
77 | `)
78 |
79 | os.Exit(2)
80 | }
81 |
--------------------------------------------------------------------------------
/templates/dashboard.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Recent Logs
6 |
7 | {{with .Data.Logs}}
8 |
9 |
10 |
11 | |
12 | Name
13 | Tags
14 | |
15 | Date |
16 | Duration |
17 | Distance |
18 |
19 |
20 |
21 | {{range .}}
22 |
23 | |
24 | {{.Name}}
25 | {{with .Tags}}
26 |
27 | {{range .}}
28 | {{.}}
29 | {{end}}
30 |
31 | {{end}}
32 | |
33 | {{.Date}} |
34 | {{.Duration}} |
35 | {{.Distance}} |
36 |
37 | {{end}}
38 |
39 |
40 | {{else}}
41 |
42 |
43 | No logs
44 |
45 |
46 | {{end}}
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/js/components/LogCharts.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 | import classNames from "classnames";
5 |
6 | import LogElevationChart from "./LogElevationChart";
7 | import LogSpeedChart from "./LogSpeedChart";
8 | import LogHeartrateChart from "./LogHeartrateChart";
9 |
10 | export default class LogCharts extends React.Component {
11 | constructor(props) {
12 | super(props);
13 | this.state = { tab: "elevation" };
14 | }
15 |
16 | get _chart() {
17 | switch (this.state.tab) {
18 | case "elevation":
19 | return ;
20 | case "speed":
21 | return ;
22 | case "heartrate":
23 | return ;
24 | default:
25 | return null;
26 | }
27 | }
28 |
29 | get _tabs() {
30 | let tabs = [
31 | { name: "Elevation", key: "elevation" },
32 | { name: "Speed", key: "speed" },
33 | ];
34 | if (this.props.log.get("hr")) {
35 | tabs.push({ name: "Heartrate", key: "heartrate" });
36 | }
37 | return tabs;
38 | }
39 |
40 | _onTabClick(tab, event) {
41 | event.preventDefault();
42 |
43 | if (this.state.tab != tab) {
44 | this.setState({ tab: tab });
45 | }
46 | }
47 |
48 | render() {
49 | return (
50 |
51 |
60 |
61 |
62 | {this._chart}
63 |
64 |
65 |
66 | );
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/js/components/LogHeartrateChart.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 | import Highcharts from "highcharts";
5 |
6 | export default class LogHeartrateChart extends React.Component {
7 | _createChart(container) {
8 | if (container == null) {
9 | return;
10 | }
11 | Highcharts.chart(container, {
12 | chart: {
13 | animation: false,
14 | style: {
15 | fontFamily: `"Helvetica Neue", Helvetica, Arial, sans-serif`,
16 | fontSize: "12px",
17 | },
18 | },
19 | title: {
20 | text: null,
21 | },
22 | tooltip: {
23 | formatter: function() { return `${this.y} bpm`; },
24 | },
25 | xAxis: {
26 | title: {
27 | text: "Distance",
28 | },
29 | labels: {
30 | formatter: function() { return `${this.value / 1000} km`; },
31 | },
32 | },
33 | yAxis: {
34 | title: {
35 | text: "Heartrate"
36 | },
37 | labels: {
38 | format: "{value} bpm"
39 | },
40 | },
41 | legend: {
42 | enabled: false,
43 | },
44 | series: [
45 | {
46 | name: "Heartrate",
47 | color: "rgb(30, 179, 0)",
48 | data: this._dataFromLog(this.props.log),
49 | animation: false,
50 | },
51 | ],
52 | });
53 | }
54 |
55 | _dataFromLog(log) {
56 | let data = [];
57 | let distance = 0;
58 |
59 | log.get("tracks").forEach((track) => {
60 | track.forEach((point, i) => {
61 | const hr = point.get("hr");
62 | if (hr) {
63 | const distance = point.get("cumulated_distance");
64 | data.push([Math.round(distance), hr]);
65 | }
66 | });
67 | });
68 |
69 | return data;
70 | }
71 |
72 | render() {
73 | return (
74 |
75 | );
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/js/components/LogElevationChart.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 | import Highcharts from "highcharts";
5 |
6 | export default class LogElevationChart extends React.Component {
7 | _createChart(container) {
8 | if (container == null) {
9 | return;
10 | }
11 | Highcharts.chart(container, {
12 | chart: {
13 | animation: false,
14 | style: {
15 | fontFamily: `"Helvetica Neue", Helvetica, Arial, sans-serif`,
16 | fontSize: "12px",
17 | },
18 | },
19 | title: {
20 | text: null,
21 | },
22 | tooltip: {
23 | formatter: function() { return `${this.y} m`; },
24 | },
25 | xAxis: {
26 | title: {
27 | text: "Distance",
28 | },
29 | labels: {
30 | formatter: function() { return `${this.value / 1000} km`; },
31 | },
32 | },
33 | yAxis: {
34 | title: {
35 | text: "Elevation"
36 | },
37 | labels: {
38 | format: "{value} m"
39 | },
40 | },
41 | legend: {
42 | enabled: false,
43 | },
44 | series: [
45 | {
46 | name: "Elevation",
47 | color: "rgb(30, 179, 0)",
48 | data: this._dataFromLog(this.props.log),
49 | animation: false,
50 | },
51 | ],
52 | });
53 | }
54 |
55 | _dataFromLog(log) {
56 | let data = [];
57 | let distance = 0;
58 |
59 | log.get("tracks").forEach((track) => {
60 | track.forEach((point, i) => {
61 | const ele = point.get("ele");
62 | if (ele) {
63 | const distance = point.get("cumulated_distance");
64 | data.push([Math.round(distance), Math.round(ele)]);
65 | }
66 | });
67 | });
68 |
69 | return data;
70 | }
71 |
72 | render() {
73 | return (
74 |
75 | );
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/js/components/LogSpeedChart.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 | import Highcharts from "highcharts";
5 | import Leaflet from "leaflet";
6 |
7 | export default class LogSpeedChart extends React.Component {
8 | _createChart(container) {
9 | if (container == null) {
10 | return;
11 | }
12 | Highcharts.chart(container, {
13 | chart: {
14 | animation: false,
15 | style: {
16 | fontFamily: `"Helvetica Neue", Helvetica, Arial, sans-serif`,
17 | fontSize: "12px",
18 | },
19 | },
20 | title: {
21 | text: null,
22 | },
23 | tooltip: {
24 | formatter: function() { return `${this.y} km/h`; },
25 | },
26 | xAxis: {
27 | title: {
28 | text: "Distance",
29 | },
30 | labels: {
31 | formatter: function() { return `${this.value / 1000} km`; },
32 | },
33 | },
34 | yAxis: {
35 | title: {
36 | text: "Speed"
37 | },
38 | labels: {
39 | format: "{value} km/h"
40 | },
41 | },
42 | legend: {
43 | enabled: false,
44 | },
45 | series: [
46 | {
47 | name: "Speed",
48 | color: "rgb(30, 179, 0)",
49 | data: this._dataFromLog(this.props.log),
50 | animation: false,
51 | },
52 | ],
53 | });
54 | }
55 |
56 | _dataFromLog(log) {
57 | let data = [];
58 |
59 | log.get("tracks").forEach((track) => {
60 | track.forEach((point, i) => {
61 | const speed = point.get("speed");
62 | if (speed !== null) {
63 | const distance = point.get("cumulated_distance");
64 | const kmh = speed / 1000 * 3600;
65 | data.push([Math.round(distance), Math.round(kmh * 10) / 10]);
66 | }
67 | });
68 | });
69 |
70 | return data;
71 | }
72 |
73 | render() {
74 | return (
75 |
76 | );
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/js/components/LogTags.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 |
5 | import Dispatcher from "../Dispatcher";
6 |
7 | export default class LogTags extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | this.state = this._initialStateForProps(props);
11 | }
12 |
13 | componentWillReceiveProps(nextProps) {
14 | this.setState(this._initialStateForProps(nextProps));
15 | }
16 |
17 | _initialStateForProps(props) {
18 | let tags = props.log.get("tags");
19 |
20 | if (props.editing && tags.filter(tag => tag.length == 0).size == 0) {
21 | tags = tags.push("");
22 | }
23 |
24 | return {
25 | tags: tags,
26 | };
27 | }
28 |
29 | _onTagChange(index, tag) {
30 | this.setState({
31 | tags: this.state.tags.set(index, tag),
32 | }, () => {
33 | Dispatcher.dispatch({
34 | type: "log-set-tags",
35 | tags: this.state.tags.filter(tag => tag.length > 0),
36 | });
37 | });
38 | }
39 |
40 | render() {
41 | let items;
42 |
43 | if (this.props.editing) {
44 | items = this.state.tags.toJS().map((tag, i) => {
45 | const onChange = (event) => {
46 | return this._onTagChange(i, event.target.value);
47 | };
48 | return (
49 |
50 |
51 |
52 | );
53 | });
54 | } else {
55 | items = this.state.tags.toJS().map((tag, i) => {
56 | return (
57 |
58 | {tag}
59 |
60 | );
61 | });
62 | }
63 |
64 | return (
65 |
66 |
67 |
Tags
68 |
69 |
72 |
73 | );
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/js/components/LogHeartrateZones.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 | import Highcharts from "highcharts";
5 |
6 | export default class LogHeartrateZones extends React.Component {
7 | _createChart(container) {
8 | if (container == null) {
9 | return;
10 | }
11 | Highcharts.chart(container, {
12 | chart: {
13 | type: "pie",
14 | animation: false,
15 | style: {
16 | fontFamily: `"Helvetica Neue", Helvetica, Arial, sans-serif`,
17 | fontSize: "12px",
18 | },
19 | },
20 | title: {
21 | text: null,
22 | },
23 | tooltip: {
24 | pointFormat: "{point.y}",
25 | valueSuffix: "%",
26 | },
27 | legend: {
28 | enabled: false,
29 | },
30 | series: [
31 | {
32 | name: "Heartrate",
33 | color: "rgb(30, 179, 0)",
34 | data: this.data,
35 | dataLabels: {
36 | enabled: false,
37 | },
38 | animation: false,
39 | },
40 | ],
41 | });
42 | }
43 |
44 | get data() {
45 | return [
46 | {
47 | y: Math.round(this.props.zones.get("red")),
48 | name: "Red",
49 | color: "#d9534f",
50 | },
51 | {
52 | y: Math.round(this.props.zones.get("anaerobic")),
53 | name: "Anaerobic",
54 | color: "#f0ad4e",
55 | },
56 | {
57 | y: Math.round(this.props.zones.get("aerobic")),
58 | name: "Aerobic",
59 | color: "#80c780",
60 | },
61 | {
62 | y: Math.round(this.props.zones.get("fatburning")),
63 | name: "Fat Burning",
64 | color: "#449d44",
65 | },
66 | {
67 | y: Math.round(this.props.zones.get("easy")),
68 | name: "Easy",
69 | color: "#5bc0de",
70 | },
71 | {
72 | y: Math.round(this.props.zones.get("none")),
73 | name: "None",
74 | color: "#777777",
75 | },
76 | ];
77 | }
78 |
79 | render() {
80 | return (
81 |
82 |
83 |
Heart Rate Zones
84 |
85 |
88 |
89 | );
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/pkg/db/postgres.sql:
--------------------------------------------------------------------------------
1 | BEGIN;
2 |
3 | CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public;
4 |
5 | CREATE TABLE "user" (
6 | "id" serial PRIMARY KEY,
7 | "username" citext NOT NULL,
8 | "password" text NOT NULL,
9 | "password_version" int NOT NULL DEFAULT 1
10 | );
11 |
12 | CREATE UNIQUE INDEX "user_username_idx" ON "user" ("username");
13 |
14 | CREATE TABLE "log" (
15 | "id" serial PRIMARY KEY,
16 | "user_id" int NOT NULL,
17 | "name" text NOT NULL,
18 | "gpx" xml NOT NULL,
19 | "start" timestamptz NOT NULL,
20 | "end" timestamptz NOT NULL,
21 | "duration" int NOT NULL,
22 | "distance" int NOT NULL,
23 | "created" timestamptz NOT NULL DEFAULT now(),
24 | FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE
25 | );
26 |
27 | CREATE INDEX "log_name_idx" ON "log" ("name");
28 | CREATE INDEX "log_start_idx" ON "log" ("start");
29 | CREATE INDEX "log_end_idx" ON "log" ("end");
30 | CREATE INDEX "log_duration_idx" ON "log" ("duration");
31 | CREATE INDEX "log_distance_idx" ON "log" ("distance");
32 |
33 | CREATE TABLE "track" (
34 | "id" serial PRIMARY KEY,
35 | "log_id" int NOT NULL,
36 | "name" text,
37 | "start" timestamptz NOT NULL,
38 | "end" timestamptz NOT NULL,
39 | "duration" int NOT NULL,
40 | "distance" int NOT NULL,
41 | FOREIGN KEY ("log_id") REFERENCES "log" ("id") ON UPDATE CASCADE ON DELETE CASCADE
42 | );
43 |
44 | CREATE INDEX "track_start_idx" ON "track" ("start");
45 | CREATE INDEX "track_end_idx" ON "track" ("end");
46 | CREATE INDEX "track_duration_idx" ON "track" ("duration");
47 | CREATE INDEX "track_distance_idx" ON "track" ("distance");
48 |
49 | CREATE TABLE "trackpoint" (
50 | "id" serial PRIMARY KEY,
51 | "track_id" int NOT NULL,
52 | "point" point NOT NULL,
53 | "time" timestamptz NOT NULL,
54 | "elevation" real,
55 | "heartrate" int,
56 | "deleted" boolean NOT NULL DEFAULT false,
57 | FOREIGN KEY ("track_id") REFERENCES "track" ("id") ON UPDATE CASCADE ON DELETE CASCADE
58 | );
59 |
60 | CREATE INDEX "trackpoint_time_idx" ON "trackpoint" ("time");
61 | CREATE INDEX "trackpoint_deleted_idx" ON "trackpoint" ("deleted");
62 |
63 | CREATE TABLE "log_tag" (
64 | "log_id" int NOT NULL,
65 | "tag" citext NOT NULL,
66 | PRIMARY KEY ("log_id", "tag"),
67 | FOREIGN KEY ("log_id") REFERENCES "log" ("id") ON UPDATE CASCADE ON DELETE CASCADE
68 | );
69 |
70 | CREATE INDEX "log_tag_tag_idx" ON "log_tag" ("tag");
71 |
72 | COMMIT;
73 |
--------------------------------------------------------------------------------
/templates/layout.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{if .Title}}{{.Title}} – Tracklog{{else}}Tracklog{{end}}
6 |
7 |
8 |
14 |
15 |
16 |
37 | {{with .Breadcrumb.Items}}
38 |
39 |
40 |
41 | {{range .}}
42 | {{if .Active}}
43 | - {{.Title}}
44 | {{else}}
45 | - {{.Title}}
46 | {{end}}
47 | {{end}}
48 |
49 |
50 |
51 | {{end}}
52 |
53 | {{.Content}}
54 |
55 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/pkg/server/context.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/gorilla/context"
8 | "github.com/julienschmidt/httprouter"
9 | "github.com/thcyron/tracklog/pkg/models"
10 | )
11 |
12 | type contextKey int
13 |
14 | const (
15 | ctxTitle contextKey = iota + 1
16 | ctxActiveTab
17 | ctxBreadcrumb
18 | ctxNoLayout
19 | ctxStart
20 | ctxData
21 | ctxUser
22 | ctxParams
23 | )
24 |
25 | type Context struct {
26 | r *http.Request
27 | w http.ResponseWriter
28 | }
29 |
30 | func NewContext(r *http.Request, w http.ResponseWriter) *Context {
31 | return &Context{r: r, w: w}
32 | }
33 |
34 | func (c *Context) Breadcrumb() *Breadcrumb {
35 | br, ok := context.Get(c.r, ctxBreadcrumb).(*Breadcrumb)
36 | if !ok || br == nil {
37 | br = new(Breadcrumb)
38 | context.Set(c.r, ctxBreadcrumb, br)
39 | }
40 | return br
41 | }
42 |
43 | func (c *Context) Title() string {
44 | title, _ := context.Get(c.r, ctxTitle).(string)
45 | return title
46 | }
47 |
48 | func (c *Context) SetTitle(title string) {
49 | context.Set(c.r, ctxTitle, title)
50 | }
51 |
52 | func (c *Context) ActiveTab() string {
53 | activeTab, _ := context.Get(c.r, ctxActiveTab).(string)
54 | return activeTab
55 | }
56 |
57 | func (c *Context) SetActiveTab(activeTab string) {
58 | context.Set(c.r, ctxActiveTab, activeTab)
59 | }
60 |
61 | func (c *Context) NoLayout() bool {
62 | noLayout, _ := context.Get(c.r, ctxNoLayout).(bool)
63 | return noLayout
64 | }
65 |
66 | func (c *Context) SetNoLayout(noLayout bool) {
67 | context.Set(c.r, ctxNoLayout, noLayout)
68 | }
69 |
70 | func (c *Context) Data() interface{} {
71 | return context.Get(c.r, ctxData)
72 | }
73 |
74 | func (c *Context) SetData(data interface{}) {
75 | context.Set(c.r, ctxData, data)
76 | }
77 |
78 | func (c *Context) User() *models.User {
79 | user, _ := context.Get(c.r, ctxUser).(*models.User)
80 | return user
81 | }
82 |
83 | func (c *Context) SetUser(user *models.User) {
84 | context.Set(c.r, ctxUser, user)
85 | }
86 |
87 | func (c *Context) Params() httprouter.Params {
88 | params, _ := context.Get(c.r, ctxParams).(httprouter.Params)
89 | return params
90 | }
91 |
92 | func (c *Context) SetParams(ps httprouter.Params) {
93 | context.Set(c.r, ctxParams, ps)
94 | }
95 |
96 | func (c *Context) Start() time.Time {
97 | start, _ := context.Get(c.r, ctxStart).(time.Time)
98 | return start
99 | }
100 |
101 | func (c *Context) SetStart(start time.Time) {
102 | context.Set(c.r, ctxStart, start)
103 | }
104 |
--------------------------------------------------------------------------------
/templates/logs.html:
--------------------------------------------------------------------------------
1 | {{if .Data.Years}}
2 |
3 |
4 |
15 |
16 |
17 | {{end}}
18 |
19 |
20 |
21 | {{with .Data.Groups}}
22 | {{range .}}
23 |
24 |
25 |
{{.Title}}
26 |
27 |
28 |
29 | |
30 | Name
31 | Tags
32 | |
33 | Date |
34 | Duration |
35 | Distance |
36 |
37 |
38 | {{range .Logs}}
39 |
40 | |
41 | {{.Name}}
42 | {{with .Tags}}
43 |
44 | {{range .}}
45 | {{.}}
46 | {{end}}
47 |
48 | {{end}}
49 | |
50 | {{.Start}} |
51 | {{.Duration}} |
52 | {{.Distance}} |
53 |
54 | {{end}}
55 |
56 |
57 |
58 | |
59 | {{.Duration}} |
60 | {{.Distance}} |
61 |
62 |
63 |
64 |
65 | {{end}}
66 | {{else}}
67 |
68 |
69 |
70 | No logs
71 |
72 |
73 |
74 | {{end}}
75 |
76 |
77 |
--------------------------------------------------------------------------------
/js/components/LogDetails.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 |
5 | import LogTags from "./LogTags";
6 | import LogHeartrateZones from "./LogHeartrateZones";
7 |
8 | export default class LogDetails extends React.Component {
9 | onEditClick(event) {
10 | event.preventDefault();
11 |
12 | if (this.props.onEdit) {
13 | this.props.onEdit();
14 | }
15 | }
16 |
17 | render() {
18 | let tags = "", hrZones = "";
19 |
20 | if (this.props.log.get("tags").size > 0 || this.props.editing) {
21 | tags = ;
22 | }
23 |
24 | if (this.props.log.get("hr")) {
25 | const zones = this.props.log.get("hrzones");
26 | hrZones = ;
27 | }
28 |
29 | let details = [
30 | ["Start", this.props.log.get("start")],
31 | ["End", this.props.log.get("end")],
32 | ["Duration", this.props.log.get("duration")],
33 | ["Distance", this.props.log.get("distance")],
34 | ["∅ Speed", this.props.log.get("speed")],
35 | ["∅ Pace", this.props.log.get("pace")],
36 | ]
37 | if (this.props.log.has("ascent")) {
38 | details.push(["Ascent", this.props.log.get("ascent")]);
39 | details.push(["Descent", this.props.log.get("descent")]);
40 | }
41 |
42 | if (this.props.log.get("hr")) {
43 | details.push(["∅ HR", this.props.log.get("hr")]);
44 | }
45 |
46 | let dlElements = [];
47 | details.forEach((detail, i) => {
48 | dlElements.push({detail[0]});
49 | dlElements.push({detail[1]});
50 | });
51 |
52 | return (
53 |
54 |
55 |
56 |
Details
57 |
58 |
59 |
60 | {dlElements}
61 |
62 |
63 |
64 | {tags}
65 | {hrZones}
66 |
77 |
78 | );
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/pkg/utils/utils_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "testing"
4 |
5 | func TestDuration(t *testing.T) {
6 | testCases := []struct {
7 | seconds uint
8 | duration string
9 | }{
10 | {
11 | seconds: 1,
12 | duration: "0:00:01",
13 | },
14 | {
15 | seconds: 60,
16 | duration: "0:01:00",
17 | },
18 | {
19 | seconds: 61,
20 | duration: "0:01:01",
21 | },
22 | {
23 | seconds: 3600,
24 | duration: "1:00:00",
25 | },
26 | {
27 | seconds: 36001,
28 | duration: "10:00:01",
29 | },
30 | {
31 | seconds: 24 * 3600,
32 | duration: "1d 0:00:00",
33 | },
34 | {
35 | seconds: 24*3600 + 61,
36 | duration: "1d 0:01:01",
37 | },
38 | {
39 | seconds: 25*3600 + 61,
40 | duration: "1d 1:01:01",
41 | },
42 | }
43 |
44 | for i, testCase := range testCases {
45 | s := Duration(testCase.seconds).String()
46 | if s != testCase.duration {
47 | t.Errorf("test case %d: expected %q; got %q", i, testCase.duration, s)
48 | }
49 | }
50 | }
51 |
52 | func TestDistance(t *testing.T) {
53 | testCases := []struct {
54 | meters uint
55 | distance string
56 | }{
57 | {
58 | meters: 1,
59 | distance: "0.00 km",
60 | },
61 | {
62 | meters: 10,
63 | distance: "0.01 km",
64 | },
65 | {
66 | meters: 100,
67 | distance: "0.10 km",
68 | },
69 | {
70 | meters: 1000,
71 | distance: "1.00 km",
72 | },
73 | {
74 | meters: 1011,
75 | distance: "1.01 km",
76 | },
77 | }
78 |
79 | for i, testCase := range testCases {
80 | s := Distance(testCase.meters).String()
81 | if s != testCase.distance {
82 | t.Errorf("test case %d: expected %q; got %q", i, testCase.distance, s)
83 | }
84 | }
85 | }
86 |
87 | func TestSpeed(t *testing.T) {
88 | testCases := []struct {
89 | mps float64 // meters per second
90 | speed string
91 | }{
92 | {
93 | mps: 0.2777,
94 | speed: "1.00 km/h",
95 | },
96 | {
97 | mps: 2.7777,
98 | speed: "10.00 km/h",
99 | },
100 | {
101 | mps: 18.46145,
102 | speed: "66.46 km/h",
103 | },
104 | }
105 |
106 | for i, testCase := range testCases {
107 | s := Speed(testCase.mps).String()
108 | if s != testCase.speed {
109 | t.Errorf("test case %d: expected %q; got %q", i, testCase.speed, s)
110 | }
111 | }
112 | }
113 |
114 | func TestPace(t *testing.T) {
115 | testCases := []struct {
116 | spm float64 // seconds per meter
117 | pace string
118 | }{
119 | {
120 | spm: 0.060,
121 | pace: "1:00 min/km",
122 | },
123 | {
124 | spm: 0.601,
125 | pace: "10:01 min/km",
126 | },
127 | }
128 |
129 | for i, testCase := range testCases {
130 | s := Pace(testCase.spm).String()
131 | if s != testCase.pace {
132 | t.Errorf("test case %d: expected %q; got %q", i, testCase.pace, s)
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/codegangsta/negroni v1.0.0 h1:+aYywywx4bnKXWvoWtRfJ91vC59NbEhEY03sZjQhbVY=
2 | github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0=
3 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
4 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
5 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
6 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
7 | github.com/gorilla/csrf v1.6.0 h1:60oN1cFdncCE8tjwQ3QEkFND5k37lQPcRjnlvm7CIJ0=
8 | github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
9 | github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
10 | github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
11 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
12 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
13 | github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g=
14 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
15 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
16 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
17 | github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
18 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
19 | github.com/naoina/go-stringutil v0.1.0 h1:rCUeRUHjBjGTSHl0VC00jUPLz8/F9dDzYI70Hzifhks=
20 | github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0=
21 | github.com/naoina/toml v0.1.1 h1:PT/lllxVVN0gzzSqSlHEmP8MJB4MY2U7STGxiouV4X8=
22 | github.com/naoina/toml v0.1.1/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E=
23 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
24 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
25 | github.com/thcyron/gpx v0.0.0-20160210223831-f95a7bb694cc h1:+H31hHGMW8l423zznrfQraQc1HQ7Kv0jfPQ3clA5KdE=
26 | github.com/thcyron/gpx v0.0.0-20160210223831-f95a7bb694cc/go.mod h1:lbbxvopbvIqhA57Bi1C3CACj4nr5ti86XKLHr7ceFnM=
27 | github.com/thcyron/sqlbuilder v2.0.1-0.20160307210201-609a1abb836e+incompatible h1:BeLkcwOwiVc6E0RUoyCNdsiTRVnn3VzpSZmogq6ljTw=
28 | github.com/thcyron/sqlbuilder v2.0.1-0.20160307210201-609a1abb836e+incompatible/go.mod h1:W/nWeL06VCuTWU/BfO3hFkp8XTKV7QNB7SoRZpI3N2A=
29 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
30 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=
31 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
32 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
33 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
34 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
35 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
36 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
37 |
--------------------------------------------------------------------------------
/cmd/control/user.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "syscall"
8 | "unicode/utf8"
9 |
10 | "github.com/thcyron/tracklog/pkg/models"
11 |
12 | "golang.org/x/crypto/bcrypt"
13 | "golang.org/x/crypto/ssh/terminal"
14 | )
15 |
16 | func userUsage() {
17 | fmt.Fprint(os.Stderr, `usage: tracklog-control user [arg...]
18 |
19 | Commands:
20 |
21 | add Add a new user
22 | delete Delete a user
23 | password Change a user’s password
24 | `)
25 | os.Exit(2)
26 | }
27 |
28 | func userCmd(args []string) {
29 | if len(args) == 0 {
30 | userUsage()
31 | }
32 |
33 | switch args[0] {
34 | case "add":
35 | addUserCmd(args[1:])
36 | case "delete":
37 | deleteUserCmd(args[1:])
38 | case "password":
39 | passwordUserCmd(args[1:])
40 | default:
41 | userUsage()
42 | }
43 | }
44 |
45 | func addUserUsage() {
46 | fmt.Fprint(os.Stderr, `usage: tracklog-control user add \n`)
47 | os.Exit(2)
48 | }
49 |
50 | func addUserCmd(args []string) {
51 | if len(args) != 1 {
52 | addUserUsage()
53 | }
54 |
55 | username := args[0]
56 | if username == "" {
57 | addUserUsage()
58 | }
59 |
60 | fmt.Print("Password: ")
61 | password, err := terminal.ReadPassword(syscall.Stdin)
62 | if err != nil {
63 | log.Fatalln(err)
64 | }
65 | if utf8.RuneCount(password) == 0 {
66 | log.Fatalln("zero-length password not allowed")
67 | }
68 |
69 | pwhash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
70 | fmt.Print("\n")
71 | if err != nil {
72 | log.Fatalln(err)
73 | }
74 |
75 | user := &models.User{
76 | Username: username,
77 | Password: string(pwhash),
78 | }
79 |
80 | if err := database.AddUser(user); err != nil {
81 | log.Fatalln(err)
82 | }
83 |
84 | fmt.Printf("user %s created\n", username)
85 | }
86 |
87 | func deleteUserCmd(args []string) {
88 | if len(args) != 1 {
89 | addUserUsage()
90 | }
91 |
92 | username := args[0]
93 | if username == "" {
94 | addUserUsage()
95 | }
96 |
97 | user, err := database.UserByUsername(username)
98 | if err != nil {
99 | log.Fatalln(err)
100 | }
101 | if user == nil {
102 | log.Fatalf("no such user: %s\n", username)
103 | }
104 |
105 | if err := database.DeleteUser(user); err != nil {
106 | log.Fatalln(err)
107 | }
108 |
109 | log.Printf("user %s deleted\n", username)
110 | }
111 |
112 | func passwordUserCmd(args []string) {
113 | if len(args) != 1 {
114 | addUserUsage()
115 | }
116 |
117 | username := args[0]
118 | if username == "" {
119 | addUserUsage()
120 | }
121 |
122 | user, err := database.UserByUsername(username)
123 | if err != nil {
124 | log.Fatalln(err)
125 | }
126 | if user == nil {
127 | log.Fatalf("no such user: %s\n", username)
128 | }
129 |
130 | fmt.Print("Password: ")
131 | password, err := terminal.ReadPassword(syscall.Stdin)
132 | if err != nil {
133 | log.Fatalln(err)
134 | }
135 | if utf8.RuneCount(password) == 0 {
136 | log.Fatalln("zero-length password not allowed")
137 | }
138 |
139 | pwhash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
140 | fmt.Print("\n")
141 | if err != nil {
142 | log.Fatalln(err)
143 | }
144 | user.Password = string(pwhash)
145 | user.PasswordVersion += 1
146 |
147 | if err := database.UpdateUser(user); err != nil {
148 | log.Fatalln(err)
149 | }
150 |
151 | log.Printf("password for %s changed\n", username)
152 | }
153 |
--------------------------------------------------------------------------------
/js/tracklog.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import "whatwg-fetch";
4 |
5 | import {uploadLogs} from "./upload";
6 | import {renderLog} from "./components/Log";
7 |
8 | document.addEventListener("DOMContentLoaded", () => {
9 | const nodes = document.querySelectorAll("a[data-method]");
10 | for (let i = 0; i < nodes.length; i++) {
11 | const node = nodes[i];
12 | const method = node.attributes.getNamedItem("data-method").value;
13 | node.appendChild(createForm(node.href, method));
14 | node.addEventListener("click", (event) => {
15 | event.preventDefault();
16 | const a = event.target;
17 | const form = a.querySelector("form");
18 | form.submit();
19 | });
20 | }
21 | });
22 |
23 | function createForm(href, method) {
24 | const form = document.createElement("form");
25 | form.method = "POST";
26 | form.action = href;
27 | form.style = "display: none;"
28 |
29 | const methodField = document.createElement("input");
30 | methodField.type = "hidden";
31 | methodField.name = "_method";
32 | methodField.value = method.toUpperCase();
33 | form.appendChild(methodField);
34 |
35 | const csrfField = document.createElement("input");
36 | csrfField.type = "hidden";
37 | csrfField.name = "_csrf";
38 | csrfField.value = Tracklog.csrfToken;
39 | form.appendChild(csrfField);
40 |
41 | return form;
42 | }
43 |
44 | document.addEventListener("DOMContentLoaded", () => {
45 | const nodes = document.querySelectorAll("form");
46 | for (let i = 0; i < nodes.length; i++) {
47 | const node = nodes[i];
48 | node.addEventListener("submit", (event) => {
49 | const form = event.target;
50 | const csrfField = document.createElement("input");
51 | csrfField.type = "hidden";
52 | csrfField.name = "_csrf";
53 | csrfField.value = Tracklog.csrfToken;
54 | form.appendChild(csrfField);
55 | });
56 | }
57 | });
58 |
59 | document.addEventListener("DOMContentLoaded", () => {
60 | const nodes = document.querySelectorAll(".logs-upload-button");
61 | for (let i = 0; i < nodes.length; i++) {
62 | const node = nodes[i];
63 |
64 | const changeButton = ({textAttribute, disabled}) => {
65 | node.textContent = node.attributes.getNamedItem(textAttribute).value;
66 | node.disabled = disabled;
67 | };
68 |
69 | node.addEventListener("click", () => {
70 | const fileInput = node.nextElementSibling;
71 |
72 | fileInput.addEventListener("change", (event) => {
73 | const input = event.target;
74 | const files = input.files;
75 |
76 | let filesArray = [];
77 | for (let i = 0; i < files.length; i++) {
78 | filesArray.push(files[i]);
79 | }
80 |
81 | uploadLogs({
82 | files: filesArray,
83 | })
84 | .then((results) => {
85 | changeButton({
86 | textAttribute: "data-text-upload",
87 | disabled: false,
88 | });
89 |
90 | if (results.length == 1) {
91 | const id = results[0].id;
92 | window.location = `/logs/${id}`;
93 | } else {
94 | window.location = "/logs";
95 | }
96 | })
97 | .catch((err) => {
98 | changeButton({
99 | textAttribute: "data-text-upload",
100 | disabled: false,
101 | });
102 |
103 | alert(err);
104 | });
105 |
106 | changeButton({
107 | textAttribute: "data-text-uploading",
108 | disabled: true,
109 | });
110 | });
111 |
112 | fileInput.click();
113 | });
114 | }
115 | });
116 |
117 | window.Tracklog = {
118 | renderLog: renderLog,
119 | };
120 |
--------------------------------------------------------------------------------
/pkg/server/sessions.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 |
7 | "github.com/dgrijalva/jwt-go"
8 | "github.com/thcyron/tracklog/pkg/models"
9 | "golang.org/x/crypto/bcrypt"
10 | )
11 |
12 | const tokenCookieName = "tracklog_token"
13 |
14 | var tokenSigningMethod = jwt.SigningMethodHS256
15 |
16 | func (s *Server) HandleGetSignIn(w http.ResponseWriter, r *http.Request) {
17 | ctx := NewContext(r, w)
18 | user := ctx.User()
19 | if user != nil {
20 | http.Redirect(w, r, "/", http.StatusFound)
21 | return
22 | }
23 | s.renderSignIn(w, r, signInData{})
24 | }
25 |
26 | func (s *Server) HandlePostSignIn(w http.ResponseWriter, r *http.Request) {
27 | username, password := r.FormValue("username"), r.FormValue("password")
28 | if username == "" || password == "" {
29 | s.renderSignIn(w, r, signInData{})
30 | return
31 | }
32 |
33 | user, err := s.db.UserByUsername(username)
34 | if err != nil {
35 | panic(err)
36 | }
37 | if user == nil {
38 | s.renderSignIn(w, r, signInData{
39 | Username: username,
40 | Alert: "Bad username/password",
41 | })
42 | return
43 | }
44 | switch bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) {
45 | case nil:
46 | break
47 | case bcrypt.ErrMismatchedHashAndPassword:
48 | s.renderSignIn(w, r, signInData{
49 | Username: username,
50 | Alert: "Bad username/password",
51 | })
52 | return
53 | default:
54 | panic(err)
55 | }
56 |
57 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
58 | "user_id": user.ID,
59 | "v": user.PasswordVersion})
60 |
61 | tokenString, err := token.SignedString([]byte(s.config.Server.SigningKey))
62 | if err != nil {
63 | panic(err)
64 | }
65 |
66 | cookie := &http.Cookie{
67 | Name: tokenCookieName,
68 | Value: tokenString,
69 | HttpOnly: true,
70 | }
71 | http.SetCookie(w, cookie)
72 |
73 | http.Redirect(w, r, "/", http.StatusFound)
74 | }
75 |
76 | type signInData struct {
77 | Username string
78 | Alert string
79 | }
80 |
81 | func (s *Server) renderSignIn(w http.ResponseWriter, r *http.Request, data signInData) {
82 | ctx := NewContext(r, w)
83 | ctx.SetNoLayout(true)
84 | ctx.SetData(data)
85 | s.render(w, r, "signin")
86 | }
87 |
88 | func (s *Server) HandlePostSignOut(w http.ResponseWriter, r *http.Request) {
89 | cookie := &http.Cookie{
90 | Name: tokenCookieName,
91 | Value: "",
92 | HttpOnly: true,
93 | }
94 | http.SetCookie(w, cookie)
95 | s.redirectToSignIn(w, r)
96 | }
97 |
98 | func (s *Server) redirectToSignIn(w http.ResponseWriter, r *http.Request) {
99 | http.Redirect(w, r, "/signin", http.StatusFound)
100 | }
101 |
102 | func (s *Server) userFromRequest(r *http.Request) (*models.User, error) {
103 | cookie, err := r.Cookie(tokenCookieName)
104 | if err != nil {
105 | return nil, nil
106 | }
107 |
108 | token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (interface{}, error) {
109 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
110 | return nil, errors.New("bad signing method")
111 | }
112 | return []byte(s.config.Server.SigningKey), nil
113 | })
114 | if err != nil || !token.Valid {
115 | return nil, nil
116 | }
117 |
118 | claims, ok := token.Claims.(jwt.MapClaims)
119 | if !ok {
120 | return nil, nil
121 | }
122 |
123 | id, ok := claims["user_id"].(float64)
124 | if !ok {
125 | return nil, nil
126 | }
127 | user, err := s.db.UserByID(int(id))
128 | if err != nil {
129 | return nil, err
130 | }
131 | if user == nil {
132 | return nil, nil
133 | }
134 |
135 | v, ok := claims["v"].(float64)
136 | if !ok {
137 | return nil, err
138 | }
139 | if int(v) != user.PasswordVersion {
140 | return nil, nil
141 | }
142 | return user, nil
143 | }
144 |
--------------------------------------------------------------------------------
/js/components/Log.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 | import ReactDOM from "react-dom";
5 | import Immutable from "immutable";
6 |
7 | import LogMap from "./LogMap";
8 | import LogDetails from "./LogDetails";
9 | import LogName from "./LogName";
10 | import LogCharts from "./LogCharts";
11 |
12 | import Dispatcher from "../Dispatcher";
13 |
14 | import LogStore from "../stores/LogStore";
15 |
16 | export default class Log extends React.Component {
17 | constructor(props) {
18 | super(props);
19 |
20 | this.state = {
21 | log: props.log,
22 | editing: false,
23 | };
24 |
25 | LogStore.init(this.props.log);
26 |
27 | LogStore.on("change", () => {
28 | this.setState({
29 | log: LogStore.log,
30 | });
31 | });
32 | }
33 |
34 | onEdit() {
35 | if (!this.state.editing) {
36 | this.setState({
37 | editing: true,
38 | oldLog: this.state.log,
39 | });
40 | }
41 | }
42 |
43 | onSave(event) {
44 | this.setState({
45 | editing: false,
46 | oldLog: null,
47 | });
48 |
49 | const tags = this.state.log.get("tags").filter(tag => tag.length > 0);
50 |
51 | window.fetch(`/logs/${this.state.log.get("id")}`, {
52 | method: "PATCH",
53 | credentials: "same-origin",
54 | headers: {
55 | "Content-Type": "application/json; charset=utf-8",
56 | "X-CSRF-Token": Tracklog.csrfToken,
57 | },
58 | body: JSON.stringify({
59 | "name": this.state.log.get("name"),
60 | "tags": tags.toJSON(),
61 | }),
62 | })
63 | .then((data) => {
64 | if (data.status != 204) {
65 | alert("Failed to save log");
66 | this.setState({
67 | editing: true,
68 | oldLog: this.state.log,
69 | });
70 | }
71 | })
72 | .catch((err) => {
73 | alert(err);
74 | });
75 | }
76 |
77 | onCancel(event) {
78 | this.setState({
79 | editing: false,
80 | log: this.state.oldLog,
81 | oldLog: null,
82 | });
83 | }
84 |
85 | get topRow() {
86 | if (this.state.editing) {
87 | return (
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | );
104 | }
105 |
106 | return (
107 |
112 | );
113 | }
114 |
115 | render() {
116 | return (
117 |
118 | {this.topRow}
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | );
130 | }
131 | }
132 |
133 | export function renderLog(container, log) {
134 | const immutableLog = Immutable.fromJS(log);
135 | ReactDOM.render(, container);
136 | }
137 |
--------------------------------------------------------------------------------
/pkg/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "html/template"
7 | "net/http"
8 | "path"
9 | "time"
10 |
11 | "github.com/codegangsta/negroni"
12 | "github.com/gorilla/context"
13 | "github.com/gorilla/csrf"
14 | "github.com/gorilla/handlers"
15 | "github.com/julienschmidt/httprouter"
16 | "github.com/thcyron/tracklog"
17 | "github.com/thcyron/tracklog/pkg/config"
18 | "github.com/thcyron/tracklog/pkg/db"
19 | "github.com/thcyron/tracklog/pkg/models"
20 | )
21 |
22 | // DataDir points to the directory where the public/ and templates/ directories are.
23 | var DataDir = "."
24 |
25 | type Server struct {
26 | config *config.Config
27 | db db.DB
28 | handler http.Handler
29 | csrfHandler func(http.Handler) http.Handler
30 | tmpl *template.Template
31 | }
32 |
33 | func New(conf *config.Config, db db.DB) (*Server, error) {
34 | s := &Server{
35 | config: conf,
36 | db: db,
37 | }
38 |
39 | if !s.config.Server.Development {
40 | tmpl, err := s.loadTemplates()
41 | if err != nil {
42 | return nil, err
43 | }
44 | s.tmpl = tmpl
45 | }
46 |
47 | n := negroni.Classic()
48 |
49 | csrfHandler := csrf.Protect(
50 | []byte(s.config.Server.CSRFAuthKey),
51 | csrf.Secure(!s.config.Server.Development),
52 | csrf.FieldName("_csrf"),
53 | )
54 | n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
55 | csrfHandler(next).ServeHTTP(w, r)
56 | })
57 |
58 | n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
59 | handlers.HTTPMethodOverrideHandler(next).ServeHTTP(w, r)
60 | })
61 | n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
62 | handlers.CompressHandler(next).ServeHTTP(w, r)
63 | })
64 |
65 | n.UseFunc(s.userAuthMiddleware)
66 |
67 | r := httprouter.New()
68 | r.ServeFiles("/static/*filepath", http.Dir(path.Join(DataDir, "public")))
69 | r.GET("/signin", s.wrapHandler(s.HandleGetSignIn))
70 | r.POST("/signin", s.wrapHandler(s.HandlePostSignIn))
71 | r.POST("/signout", s.wrapHandler(s.HandlePostSignOut))
72 | r.GET("/logs", s.wrapHandler(s.HandleGetLogs))
73 | r.POST("/logs", s.wrapHandler(s.HandlePostLog))
74 | r.GET("/logs/:id/download", s.wrapHandler(s.HandleDownloadLog))
75 | r.GET("/logs/:id", s.wrapHandler(s.HandleGetLog))
76 | r.PATCH("/logs/:id", s.wrapHandler(s.HandlePatchLog))
77 | r.DELETE("/logs/:id", s.wrapHandler(s.HandleDeleteLog))
78 | r.GET("/", s.wrapHandler(s.HandleDashboard))
79 | n.UseHandler(r)
80 |
81 | s.handler = n
82 | return s, nil
83 | }
84 |
85 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
86 | defer context.Clear(r)
87 | s.handler.ServeHTTP(w, r)
88 | }
89 |
90 | type HandlerFunc func(w http.ResponseWriter, r *http.Request)
91 |
92 | func (s *Server) wrapHandler(handler HandlerFunc) httprouter.Handle {
93 | return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
94 | ctx := NewContext(r, w)
95 | ctx.SetStart(time.Now())
96 | ctx.SetParams(ps)
97 | handler(w, r)
98 | }
99 | }
100 |
101 | func (s *Server) userAuthMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
102 | user, err := s.userFromRequest(r)
103 | if err != nil {
104 | panic(err)
105 | }
106 | if user != nil {
107 | ctx := NewContext(r, w)
108 | ctx.SetUser(user)
109 | }
110 | next(w, r)
111 | }
112 |
113 | func (s *Server) loadTemplates() (*template.Template, error) {
114 | return template.ParseGlob(path.Join(DataDir, "templates/*.html"))
115 | }
116 |
117 | type renderData struct {
118 | Title string
119 | ActiveTab string
120 | Breadcrumb *Breadcrumb
121 | User *models.User
122 | CSRFToken string
123 | CSRFField template.HTML
124 | MapboxAccessToken string
125 | Version string
126 | Runtime string
127 | Content template.HTML
128 | Data interface{}
129 | }
130 |
131 | func (s *Server) render(w http.ResponseWriter, r *http.Request, name string) {
132 | ctx := NewContext(r, w)
133 |
134 | tmpl := s.tmpl
135 | if tmpl == nil {
136 | t, err := s.loadTemplates()
137 | if err != nil {
138 | panic(err)
139 | }
140 | tmpl = t
141 | }
142 |
143 | data := renderData{
144 | Title: ctx.Title(),
145 | ActiveTab: ctx.ActiveTab(),
146 | Breadcrumb: ctx.Breadcrumb(),
147 | User: ctx.User(),
148 | CSRFToken: csrf.Token(r),
149 | CSRFField: csrf.TemplateField(r),
150 | MapboxAccessToken: s.config.Server.MapboxAccessToken,
151 | Version: tracklog.Version,
152 | Data: ctx.Data(),
153 | }
154 | if s.config.Server.Development {
155 | data.Runtime = fmt.Sprintf("%.0fms", time.Now().Sub(ctx.Start()).Seconds()*1000)
156 | }
157 |
158 | if ctx.NoLayout() {
159 | if err := tmpl.ExecuteTemplate(w, name+".html", data); err != nil {
160 | panic(err)
161 | }
162 | return
163 | }
164 |
165 | buf := new(bytes.Buffer)
166 | if err := tmpl.ExecuteTemplate(buf, name+".html", data); err != nil {
167 | panic(err)
168 | }
169 | data.Content = template.HTML(buf.String())
170 |
171 | w.Header().Set("Content-Type", "text/html; charset=utf-8")
172 |
173 | if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
174 | panic(err)
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/pkg/server/logs.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "strconv"
8 | "strings"
9 | "time"
10 |
11 | "github.com/thcyron/tracklog/pkg/geo"
12 | "github.com/thcyron/tracklog/pkg/heartrate"
13 | "github.com/thcyron/tracklog/pkg/models"
14 | "github.com/thcyron/tracklog/pkg/rdp"
15 | "github.com/thcyron/tracklog/pkg/utils"
16 | )
17 |
18 | const logTimeFormat = "2006-01-02 15:04:05"
19 |
20 | type logsData struct {
21 | Years []logsDataYear
22 | Groups []*logDataGroup
23 | }
24 |
25 | type logsDataYear struct {
26 | Year int
27 | Active bool
28 | }
29 |
30 | type logDataGroup struct {
31 | Title string
32 | Duration string
33 | Distance string
34 | Logs []logsDataLog
35 | }
36 |
37 | type logsDataLog struct {
38 | ID int
39 | Name string
40 | Start string
41 | Duration string
42 | Distance string
43 | Tags []string
44 | }
45 |
46 | func (s *Server) HandleGetLogs(w http.ResponseWriter, r *http.Request) {
47 | ctx := NewContext(r, w)
48 |
49 | user := ctx.User()
50 | if user == nil {
51 | s.redirectToSignIn(w, r)
52 | return
53 | }
54 |
55 | years, err := s.db.UserLogYears(user)
56 | if err != nil {
57 | panic(err)
58 | }
59 |
60 | year := 0
61 | explicitYear := false
62 | if s := r.FormValue("year"); s != "" {
63 | if y, err := strconv.Atoi(s); err == nil {
64 | year = y
65 | explicitYear = true
66 | }
67 | }
68 | if year == 0 && len(years) > 0 {
69 | year = years[len(years)-1]
70 | }
71 |
72 | logs, err := s.db.UserLogsByYear(user, year)
73 | if err != nil {
74 | panic(err)
75 | }
76 |
77 | var (
78 | data logsData
79 | group *logDataGroup
80 | duration uint
81 | distance uint
82 | month time.Month
83 | )
84 |
85 | for _, log := range logs {
86 | if month != log.Start.Month() {
87 | if group != nil {
88 | group.Duration = utils.Duration(duration).String()
89 | group.Distance = utils.Distance(distance).String()
90 | }
91 |
92 | duration = 0
93 | distance = 0
94 | month = log.Start.Month()
95 |
96 | group = &logDataGroup{
97 | Title: fmt.Sprintf("%s %d", month, year),
98 | }
99 | data.Groups = append(data.Groups, group)
100 | }
101 |
102 | group.Logs = append(group.Logs, logsDataLog{
103 | ID: log.ID,
104 | Name: log.Name,
105 | Start: log.Start.Format(logTimeFormat),
106 | Duration: utils.Duration(log.Duration).String(),
107 | Distance: utils.Distance(log.Distance).String(),
108 | Tags: log.Tags,
109 | })
110 | distance += log.Distance
111 | duration += log.Duration
112 | }
113 | if group != nil {
114 | group.Duration = utils.Duration(duration).String()
115 | group.Distance = utils.Distance(distance).String()
116 | }
117 |
118 | for _, y := range years {
119 | data.Years = append(data.Years, logsDataYear{
120 | Year: y,
121 | Active: y == year,
122 | })
123 | }
124 |
125 | if explicitYear {
126 | ctx.SetTitle(fmt.Sprintf("Logs %d", year))
127 | ctx.Breadcrumb().Add("Logs", "/logs", false)
128 | ctx.Breadcrumb().Add(strconv.Itoa(year), "", true)
129 | } else {
130 | ctx.SetTitle("Logs")
131 | ctx.Breadcrumb().Add("Logs", "", true)
132 | }
133 |
134 | ctx.SetActiveTab("logs")
135 | ctx.SetData(data)
136 |
137 | s.render(w, r, "logs")
138 | }
139 |
140 | type postLogRequest struct {
141 | Filename string `json:"filename"`
142 | GPX string `json:"gpx"`
143 | }
144 |
145 | type postLogResponse struct {
146 | ID int `json:"id"`
147 | }
148 |
149 | func (s *Server) HandlePostLog(w http.ResponseWriter, r *http.Request) {
150 | if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
151 | http.Error(w, "JSON requests only", http.StatusNotAcceptable)
152 | return
153 | }
154 |
155 | ctx := NewContext(r, w)
156 | user := ctx.User()
157 | if user == nil {
158 | http.Error(w, "unauthorized", http.StatusUnauthorized)
159 | return
160 | }
161 |
162 | var req postLogRequest
163 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
164 | http.Error(w, err.Error(), http.StatusBadRequest)
165 | return
166 | }
167 |
168 | name := strings.TrimSuffix(req.Filename, ".gpx")
169 | log, err := models.NewLog(name, []byte(req.GPX))
170 | if err != nil {
171 | http.Error(w, err.Error(), http.StatusBadRequest)
172 | return
173 | }
174 |
175 | if err := s.db.AddUserLog(user, log); err != nil {
176 | panic(err)
177 | }
178 |
179 | w.Header().Set("Content-Type", "application/json; charset=utf-8")
180 | w.WriteHeader(http.StatusCreated)
181 | resp := postLogResponse{ID: log.ID}
182 | json.NewEncoder(w).Encode(resp)
183 | }
184 |
185 | type logData struct {
186 | Log logDataLog
187 | }
188 |
189 | type logDataLog struct {
190 | ID int `json:"id"`
191 | Name string `json:"name"`
192 | Start string `json:"start"`
193 | End string `json:"end"`
194 | Duration string `json:"duration"`
195 | Distance string `json:"distance"`
196 | Speed string `json:"speed"`
197 | Pace string `json:"pace"`
198 | HR string `json:"hr,omitempty"`
199 | HRZones logDataHRZones `json:"hrzones"`
200 | Tracks [][]logDataPoint `json:"tracks"`
201 | Tags []string `json:"tags"`
202 | Ascent string `json:"ascent,omitempty"`
203 | Descent string `json:"descent,omitempty"`
204 | }
205 |
206 | type logDataHRZones struct {
207 | Red float64 `json:"red"`
208 | Anaerobic float64 `json:"anaerobic"`
209 | Aerobic float64 `json:"aerobic"`
210 | FatBurning float64 `json:"fatburning"`
211 | Easy float64 `json:"easy"`
212 | None float64 `json:"none"`
213 | }
214 |
215 | type logDataPoint struct {
216 | Lat float64 `json:"lat"`
217 | Lon float64 `json:"lon"`
218 | CumulatedDistance float64 `json:"cumulated_distance"`
219 | Speed float64 `json:"speed"`
220 | Ele float64 `json:"ele"`
221 | HR uint `json:"hr"`
222 | }
223 |
224 | func (s *Server) HandleGetLog(w http.ResponseWriter, r *http.Request) {
225 | ctx := NewContext(r, w)
226 |
227 | user := ctx.User()
228 | if user == nil {
229 | s.redirectToSignIn(w, r)
230 | return
231 | }
232 |
233 | id, err := strconv.Atoi(ctx.Params().ByName("id"))
234 | if err != nil {
235 | http.NotFound(w, r)
236 | return
237 | }
238 |
239 | log, err := s.db.UserLogByID(user, id)
240 | if err != nil {
241 | panic(err)
242 | }
243 | if log == nil {
244 | http.NotFound(w, r)
245 | return
246 | }
247 |
248 | data := logData{
249 | Log: logDataLog{
250 | ID: log.ID,
251 | Name: log.Name,
252 | Start: log.Start.Format(logTimeFormat),
253 | End: log.End.Format(logTimeFormat),
254 | Duration: utils.Duration(log.Duration).String(),
255 | Distance: utils.Distance(log.Distance).String(),
256 | Speed: utils.Speed(log.Speed()).String(),
257 | Pace: utils.Speed(log.Speed()).Pace().String(),
258 | Tracks: make([][]logDataPoint, 0, len(log.Tracks)),
259 | Tags: log.Tags,
260 | },
261 | }
262 | if data.Log.Tags == nil {
263 | data.Log.Tags = make([]string, 0, 0)
264 | }
265 |
266 | var cumDistance, ascent, descent float64
267 | performReduce := r.FormValue("reduce") != "no"
268 |
269 | for _, track := range log.Tracks {
270 | points := track.Points
271 |
272 | if performReduce {
273 | rdpPoints := make([]rdp.Point, 0, len(track.Points))
274 | for _, point := range track.Points {
275 | λ, φ := geo.ToRad(point.Longitude), geo.ToRad(point.Latitude)
276 | x, y := geo.EquirectangularProjection(λ, φ, 0)
277 | rdpPoints = append(rdpPoints, rdp.Point{
278 | X: x,
279 | Y: y,
280 | Data: point,
281 | })
282 | }
283 | const epsilon = 0.0000001 // ≈ 1m
284 | reducedPoints := rdp.Reduce(rdpPoints, epsilon)
285 | points = make([]*models.Point, 0, len(reducedPoints))
286 | for _, rp := range reducedPoints {
287 | points = append(points, rp.Data.(*models.Point))
288 | }
289 | }
290 |
291 | var ps []logDataPoint
292 | for i, point := range points {
293 | p := logDataPoint{
294 | Lat: point.Latitude,
295 | Lon: point.Longitude,
296 | Ele: point.Elevation,
297 | HR: point.Heartrate,
298 | }
299 | if i > 0 {
300 | lastPoint := points[i-1]
301 | cumDistance += point.DistanceTo(lastPoint)
302 | p.CumulatedDistance = cumDistance
303 | p.Speed = point.SpeedTo(lastPoint)
304 |
305 | dEle := point.Elevation - lastPoint.Elevation
306 | if dEle >= 0 {
307 | ascent += dEle
308 | } else {
309 | descent += -dEle
310 | }
311 | } else if len(points) > 1 {
312 | nextPoint := points[i+1]
313 | p.CumulatedDistance = cumDistance
314 | p.Speed = nextPoint.SpeedTo(point)
315 | }
316 | ps = append(ps, p)
317 | }
318 | data.Log.Tracks = append(data.Log.Tracks, ps)
319 | }
320 |
321 | if ascent != 0 || descent != 0 {
322 | data.Log.Ascent = fmt.Sprintf("%d m", int(ascent))
323 | data.Log.Descent = fmt.Sprintf("%d m", int(descent))
324 | }
325 |
326 | hrSummary := heartrate.SummaryForLog(log)
327 | if len(hrSummary.Rates) > 0 {
328 | data.Log.HR = strconv.Itoa(hrSummary.Average)
329 |
330 | perc := func(zone heartrate.Zone) float64 {
331 | return float64(hrSummary.Zones[zone]) / float64(len(hrSummary.Rates)) * 100.0
332 | }
333 |
334 | data.Log.HRZones.Red = perc(heartrate.ZoneRed)
335 | data.Log.HRZones.Anaerobic = perc(heartrate.ZoneAnaerobic)
336 | data.Log.HRZones.Aerobic = perc(heartrate.ZoneAerobic)
337 | data.Log.HRZones.FatBurning = perc(heartrate.ZoneFatBurning)
338 | data.Log.HRZones.Easy = perc(heartrate.ZoneEasy)
339 | data.Log.HRZones.None = perc(heartrate.ZoneNone)
340 | }
341 |
342 | ctx.SetTitle(log.Name)
343 | ctx.Breadcrumb().Add("Logs", "/logs", false)
344 | ctx.Breadcrumb().Add(log.Name, "", true)
345 | ctx.SetActiveTab("logs")
346 | ctx.SetData(data)
347 |
348 | s.render(w, r, "log")
349 | }
350 |
351 | func (s *Server) HandleDownloadLog(w http.ResponseWriter, r *http.Request) {
352 | ctx := NewContext(r, w)
353 |
354 | user := ctx.User()
355 | if user == nil {
356 | s.redirectToSignIn(w, r)
357 | return
358 | }
359 |
360 | id, err := strconv.Atoi(ctx.Params().ByName("id"))
361 | if err != nil {
362 | http.NotFound(w, r)
363 | return
364 | }
365 |
366 | log, err := s.db.UserLogByID(user, id)
367 | if err != nil {
368 | panic(err)
369 | }
370 | if log == nil {
371 | http.NotFound(w, r)
372 | return
373 | }
374 |
375 | w.Header().Set("Content-Type", "application/gpx+json")
376 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%d.gpx", log.ID))
377 | w.Write([]byte(log.GPX))
378 | }
379 |
380 | type patchLogRequest struct {
381 | Name string `json:"name"`
382 | Tags []string `json:"tags"`
383 | }
384 |
385 | func (s *Server) HandlePatchLog(w http.ResponseWriter, r *http.Request) {
386 | ctx := NewContext(r, w)
387 |
388 | user := ctx.User()
389 | if user == nil {
390 | s.redirectToSignIn(w, r)
391 | return
392 | }
393 |
394 | id, err := strconv.Atoi(ctx.Params().ByName("id"))
395 | if err != nil {
396 | http.NotFound(w, r)
397 | return
398 | }
399 |
400 | log, err := s.db.UserLogByID(user, id)
401 | if err != nil {
402 | panic(err)
403 | }
404 | if log == nil {
405 | http.NotFound(w, r)
406 | return
407 | }
408 |
409 | var req patchLogRequest
410 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
411 | panic(err)
412 | }
413 | log.Name = req.Name
414 | if req.Tags != nil {
415 | log.Tags = req.Tags
416 | }
417 |
418 | if err := s.db.UpdateLog(log); err != nil {
419 | panic(err)
420 | }
421 |
422 | w.WriteHeader(http.StatusNoContent)
423 | }
424 |
425 | func (s *Server) HandleDeleteLog(w http.ResponseWriter, r *http.Request) {
426 | ctx := NewContext(r, w)
427 |
428 | user := ctx.User()
429 | if user == nil {
430 | s.redirectToSignIn(w, r)
431 | return
432 | }
433 |
434 | id, err := strconv.Atoi(ctx.Params().ByName("id"))
435 | if err != nil {
436 | http.NotFound(w, r)
437 | return
438 | }
439 |
440 | log, err := s.db.UserLogByID(user, id)
441 | if err != nil {
442 | panic(err)
443 | }
444 | if log == nil {
445 | http.NotFound(w, r)
446 | return
447 | }
448 |
449 | if err := s.db.DeleteLog(log); err != nil {
450 | panic(err)
451 | }
452 |
453 | http.Redirect(w, r, "/logs", http.StatusSeeOther)
454 | }
455 |
--------------------------------------------------------------------------------
/pkg/db/postgres.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 |
7 | _ "github.com/lib/pq"
8 | "github.com/thcyron/sqlbuilder"
9 | "github.com/thcyron/tracklog/pkg/models"
10 | )
11 |
12 | type Postgres struct {
13 | db *sql.DB
14 | }
15 |
16 | func (d *Postgres) Open(dsn string) error {
17 | db, err := sql.Open("postgres", dsn)
18 | if err != nil {
19 | return err
20 | }
21 | if err := db.Ping(); err != nil {
22 | return err
23 | }
24 | d.db = db
25 | return nil
26 | }
27 |
28 | func (d *Postgres) UserByID(id int) (*models.User, error) {
29 | user := new(models.User)
30 |
31 | query, args, dest := sqlbuilder.Select().
32 | Dialect(sqlbuilder.Postgres).
33 | From(`"user"`).
34 | Map(`"id"`, &user.ID).
35 | Map(`"username"`, &user.Username).
36 | Map(`"password"`, &user.Password).
37 | Map(`"password_version"`, &user.PasswordVersion).
38 | Where(`"id" = ?`, id).
39 | Build()
40 |
41 | err := d.db.QueryRow(query, args...).Scan(dest...)
42 | if err == sql.ErrNoRows {
43 | return nil, nil
44 | }
45 | if err != nil {
46 | return nil, err
47 | }
48 | return user, nil
49 | }
50 |
51 | func (d *Postgres) UserByUsername(username string) (*models.User, error) {
52 | user := new(models.User)
53 |
54 | query, args, dest := sqlbuilder.Select().
55 | Dialect(sqlbuilder.Postgres).
56 | From(`"user"`).
57 | Map(`"id"`, &user.ID).
58 | Map(`"username"`, &user.Username).
59 | Map(`"password"`, &user.Password).
60 | Map(`"password_version"`, &user.PasswordVersion).
61 | Where(`"username" = ?`, username).
62 | Build()
63 |
64 | err := d.db.QueryRow(query, args...).Scan(dest...)
65 | if err == sql.ErrNoRows {
66 | return nil, nil
67 | }
68 | if err != nil {
69 | return nil, err
70 | }
71 | return user, nil
72 | }
73 |
74 | func (d *Postgres) AddUser(user *models.User) error {
75 | var id int
76 |
77 | query, args, dest := sqlbuilder.Insert().
78 | Dialect(sqlbuilder.Postgres).
79 | Into(`"user"`).
80 | Set(`"username"`, user.Username).
81 | Set(`"password"`, user.Password).
82 | Return(`"id"`, &id).
83 | Build()
84 |
85 | err := d.db.QueryRow(query, args...).Scan(dest...)
86 | if err != nil {
87 | return err
88 | }
89 |
90 | user.ID = id
91 | return nil
92 | }
93 |
94 | func (d *Postgres) UpdateUser(user *models.User) error {
95 | query, args := sqlbuilder.Update().
96 | Dialect(sqlbuilder.Postgres).
97 | Table(`"user"`).
98 | Set(`"username"`, user.Username).
99 | Set(`"password"`, user.Password).
100 | Set(`"password_version"`, user.PasswordVersion).
101 | Build()
102 | _, err := d.db.Exec(query, args...)
103 | return err
104 | }
105 |
106 | func (d *Postgres) DeleteUser(user *models.User) error {
107 | _, err := d.db.Exec(`DELETE FROM "user" WHERE "id" = $1`, user.ID)
108 | return err
109 | }
110 |
111 | func (d *Postgres) RecentUserLogs(user *models.User, count int) ([]*models.Log, error) {
112 | tx, err := d.db.Begin()
113 | if err != nil {
114 | return nil, err
115 | }
116 | defer tx.Rollback() // read-only transaction
117 |
118 | var (
119 | log models.Log
120 | logs []*models.Log
121 | )
122 |
123 | query, args, dest := sqlbuilder.Select().
124 | Dialect(sqlbuilder.Postgres).
125 | From(`"log"`).
126 | Map(`"id"`, &log.ID).
127 | Map(`"user_id"`, &log.UserID).
128 | Map(`"name"`, &log.Name).
129 | Map(`"start"`, &log.Start).
130 | Map(`"end"`, &log.End).
131 | Map(`"duration"`, &log.Duration).
132 | Map(`"distance"`, &log.Distance).
133 | Where(`"user_id" = ?`, user.ID).
134 | Order(`"created" DESC`).
135 | Limit(count).
136 | Build()
137 |
138 | rows, err := tx.Query(query, args...)
139 | if err != nil {
140 | return nil, err
141 | }
142 | for rows.Next() {
143 | if err := rows.Scan(dest...); err != nil {
144 | return nil, err
145 | }
146 | l := new(models.Log)
147 | *l = log
148 | logs = append(logs, l)
149 | }
150 | if err := rows.Err(); err != nil {
151 | return nil, err
152 | }
153 |
154 | for _, log := range logs {
155 | if err := d.getLogTags(tx, log); err != nil {
156 | return nil, err
157 | }
158 | }
159 |
160 | return logs, nil
161 | }
162 |
163 | func (d *Postgres) UserLogYears(user *models.User) ([]int, error) {
164 | var (
165 | years []int
166 | year int
167 | )
168 |
169 | query, args, dest := sqlbuilder.Select().
170 | Dialect(sqlbuilder.Postgres).
171 | From(`"log"`).
172 | Map(`DISTINCT EXTRACT(YEAR FROM "start")`, &year).
173 | Where(`"user_id" = ?`, user.ID).
174 | Order(`EXTRACT(YEAR FROM "start") ASC`).
175 | Build()
176 |
177 | rows, err := d.db.Query(query, args...)
178 | if err != nil {
179 | return nil, err
180 | }
181 | for rows.Next() {
182 | if err := rows.Scan(dest...); err != nil {
183 | return nil, err
184 | }
185 | years = append(years, year)
186 | }
187 | if err := rows.Err(); err != nil {
188 | return nil, err
189 | }
190 | return years, nil
191 | }
192 |
193 | func (d *Postgres) UserLogByID(user *models.User, id int) (*models.Log, error) {
194 | tx, err := d.db.Begin()
195 | if err != nil {
196 | return nil, err
197 | }
198 | defer tx.Rollback() // read-only transaction
199 |
200 | log := new(models.Log)
201 |
202 | query, args, dest := sqlbuilder.Select().
203 | Dialect(sqlbuilder.Postgres).
204 | From(`"log"`).
205 | Map(`"id"`, &log.ID).
206 | Map(`"user_id"`, &log.UserID).
207 | Map(`"name"`, &log.Name).
208 | Map(`"start"`, &log.Start).
209 | Map(`"end"`, &log.End).
210 | Map(`"duration"`, &log.Duration).
211 | Map(`"distance"`, &log.Distance).
212 | Map(`"gpx"`, &log.GPX).
213 | Where(`"id" = ?`, id).
214 | Build()
215 |
216 | err = tx.QueryRow(query, args...).Scan(dest...)
217 | if err == sql.ErrNoRows {
218 | return nil, nil
219 | }
220 | if err != nil {
221 | return nil, err
222 | }
223 |
224 | if err := d.getLogTracks(tx, log); err != nil {
225 | return nil, err
226 | }
227 | if err := d.getLogTags(tx, log); err != nil {
228 | return nil, err
229 | }
230 |
231 | return log, nil
232 | }
233 |
234 | func (d *Postgres) getLogTracks(tx *sql.Tx, log *models.Log) error {
235 | var (
236 | track models.Track
237 | tracks []*models.Track
238 | )
239 |
240 | query, args, dest := sqlbuilder.Select().
241 | Dialect(sqlbuilder.Postgres).
242 | From(`"track"`).
243 | Map(`"id"`, &track.ID).
244 | Map(`"log_id"`, &track.LogID).
245 | Map(`COALESCE("name", '')`, &track.Name).
246 | Map(`"start"`, &track.Start).
247 | Map(`"end"`, &track.End).
248 | Map(`"duration"`, &track.Duration).
249 | Map(`"distance"`, &track.Distance).
250 | Where(`"log_id" = ?`, log.ID).
251 | Build()
252 |
253 | rows, err := tx.Query(query, args...)
254 | if err != nil {
255 | return err
256 | }
257 | for rows.Next() {
258 | if err := rows.Scan(dest...); err != nil {
259 | return err
260 | }
261 | t := new(models.Track)
262 | *t = track
263 | tracks = append(tracks, t)
264 | }
265 | if err := rows.Err(); err != nil {
266 | return err
267 | }
268 |
269 | for _, track := range tracks {
270 | if err := d.getTrackPoints(tx, track); err != nil {
271 | return err
272 | }
273 | }
274 |
275 | log.Tracks = tracks
276 | return nil
277 | }
278 |
279 | func (d *Postgres) getTrackPoints(tx *sql.Tx, track *models.Track) error {
280 | var (
281 | point models.Point
282 | points []*models.Point
283 | )
284 |
285 | query, args, dest := sqlbuilder.Select().
286 | Dialect(sqlbuilder.Postgres).
287 | From(`"trackpoint"`).
288 | Map(`"id"`, &point.ID).
289 | Map(`"track_id"`, &point.TrackID).
290 | Map(`"point"[0]`, &point.Longitude).
291 | Map(`"point"[1]`, &point.Latitude).
292 | Map(`"time"`, &point.Time).
293 | Map(`"elevation"`, &point.Elevation).
294 | Map(`"heartrate"`, &point.Heartrate).
295 | Where(`"track_id" = ?`, track.ID).
296 | Build()
297 |
298 | rows, err := tx.Query(query, args...)
299 | if err != nil {
300 | return err
301 | }
302 | for rows.Next() {
303 | if err := rows.Scan(dest...); err != nil {
304 | return err
305 | }
306 | p := new(models.Point)
307 | *p = point
308 | points = append(points, p)
309 | }
310 | if err := rows.Err(); err != nil {
311 | return err
312 | }
313 |
314 | track.Points = points
315 | return nil
316 | }
317 |
318 | func (d *Postgres) getLogTags(tx *sql.Tx, log *models.Log) error {
319 | var (
320 | tag string
321 | tags []string
322 | )
323 |
324 | query, args, dest := sqlbuilder.Select().
325 | Dialect(sqlbuilder.Postgres).
326 | From(`"log_tag"`).
327 | Map(`"tag"`, &tag).
328 | Where(`"log_id" = ?`, log.ID).
329 | Build()
330 |
331 | rows, err := tx.Query(query, args...)
332 | if err != nil {
333 | return err
334 | }
335 | for rows.Next() {
336 | if err := rows.Scan(dest...); err != nil {
337 | return err
338 | }
339 | tags = append(tags, tag)
340 | }
341 | if err := rows.Err(); err != nil {
342 | return err
343 | }
344 | log.Tags = tags
345 | return nil
346 | }
347 |
348 | func (d *Postgres) UserLogsByYear(user *models.User, year int) ([]*models.Log, error) {
349 | tx, err := d.db.Begin()
350 | if err != nil {
351 | return nil, err
352 | }
353 | defer tx.Rollback() // read-only transaction
354 |
355 | var (
356 | log models.Log
357 | logs []*models.Log
358 | )
359 |
360 | query, args, dest := sqlbuilder.Select().
361 | Dialect(sqlbuilder.Postgres).
362 | From(`"log"`).
363 | Map(`"id"`, &log.ID).
364 | Map(`"name"`, &log.Name).
365 | Map(`"start"`, &log.Start).
366 | Map(`"end"`, &log.End).
367 | Map(`"duration"`, &log.Duration).
368 | Map(`"distance"`, &log.Distance).
369 | Map(`"gpx"`, &log.GPX).
370 | Where(`"user_id" = ?`, user.ID).
371 | Where(`EXTRACT(YEAR FROM "start") = ?`, year).
372 | Order(`"start" DESC`).
373 | Build()
374 |
375 | rows, err := tx.Query(query, args...)
376 | if err != nil {
377 | return nil, err
378 | }
379 | for rows.Next() {
380 | if err := rows.Scan(dest...); err != nil {
381 | rows.Close()
382 | return nil, err
383 | }
384 | l := new(models.Log)
385 | *l = log
386 | logs = append(logs, l)
387 | }
388 | if err := rows.Err(); err != nil {
389 | return nil, err
390 | }
391 |
392 | for _, log := range logs {
393 | if err := d.getLogTags(tx, log); err != nil {
394 | return nil, err
395 | }
396 | }
397 |
398 | return logs, nil
399 | }
400 |
401 | func (d *Postgres) AddUserLog(user *models.User, log *models.Log) error {
402 | tx, err := d.db.Begin()
403 | if err != nil {
404 | return err
405 | }
406 | query, args, dest := sqlbuilder.Insert().
407 | Dialect(sqlbuilder.Postgres).
408 | Into(`"log"`).
409 | Set(`"user_id"`, user.ID).
410 | Set(`"start"`, log.Start).
411 | Set(`"end"`, log.End).
412 | Set(`"duration"`, log.Duration).
413 | Set(`"distance"`, log.Distance).
414 | Set(`"name"`, log.Name).
415 | Set(`"gpx"`, log.GPX).
416 | Return(`"id"`, &log.ID).
417 | Build()
418 |
419 | if err := tx.QueryRow(query, args...).Scan(dest...); err != nil {
420 | tx.Rollback()
421 | return err
422 | }
423 |
424 | for _, track := range log.Tracks {
425 | if err := d.addLogTrack(tx, log, track); err != nil {
426 | tx.Rollback()
427 | return err
428 | }
429 | }
430 |
431 | return tx.Commit()
432 | }
433 |
434 | func (d *Postgres) addLogTrack(tx *sql.Tx, log *models.Log, track *models.Track) error {
435 | var name *string
436 | if track.Name != "" {
437 | name = &track.Name
438 | }
439 | query, args, dest := sqlbuilder.Insert().
440 | Dialect(sqlbuilder.Postgres).
441 | Into(`"track"`).
442 | Set(`"log_id"`, log.ID).
443 | Set(`"name"`, name).
444 | Set(`"start"`, track.Start).
445 | Set(`"end"`, track.End).
446 | Set(`"duration"`, track.Duration).
447 | Set(`"distance"`, track.Distance).
448 | Return(`"id"`, &track.ID).
449 | Build()
450 |
451 | if err := tx.QueryRow(query, args...).Scan(dest...); err != nil {
452 | return err
453 | }
454 |
455 | for _, point := range track.Points {
456 | if err := d.addTrackPoint(tx, track, point); err != nil {
457 | return err
458 | }
459 | }
460 |
461 | return nil
462 | }
463 |
464 | func (d *Postgres) addTrackPoint(tx *sql.Tx, track *models.Track, point *models.Point) error {
465 | query, args, dest := sqlbuilder.Insert().
466 | Dialect(sqlbuilder.Postgres).
467 | Into(`"trackpoint"`).
468 | Set(`"track_id"`, track.ID).
469 | SetSQL(`"point"`, fmt.Sprintf("point(%f,%f)", point.Longitude, point.Latitude)).
470 | Set(`"time"`, point.Time).
471 | Set(`"elevation"`, point.Elevation).
472 | Set(`"heartrate"`, point.Heartrate).
473 | Return(`"id"`, &point.ID).
474 | Build()
475 |
476 | if err := tx.QueryRow(query, args...).Scan(dest...); err != nil {
477 | return err
478 | }
479 | return nil
480 | }
481 |
482 | func (d *Postgres) UpdateLog(log *models.Log) error {
483 | tx, err := d.db.Begin()
484 | if err != nil {
485 | return nil
486 | }
487 |
488 | query, args := sqlbuilder.Update().
489 | Dialect(sqlbuilder.Postgres).
490 | Table(`"log"`).
491 | Set(`"name"`, log.Name).
492 | Where(`"id" = ?`, log.ID).
493 | Build()
494 |
495 | _, err = tx.Exec(query, args...)
496 | if err != nil {
497 | return err
498 | }
499 |
500 | if err := d.replaceLogTags(tx, log); err != nil {
501 | tx.Rollback()
502 | return err
503 | }
504 |
505 | return tx.Commit()
506 | }
507 |
508 | func (d *Postgres) replaceLogTags(tx *sql.Tx, log *models.Log) error {
509 | _, err := tx.Exec(`DELETE FROM "log_tag" WHERE "log_id" = $1`, log.ID)
510 | if err != nil {
511 | return err
512 | }
513 |
514 | for _, tag := range log.Tags {
515 | query, args, _ := sqlbuilder.Insert().
516 | Dialect(sqlbuilder.Postgres).
517 | Into(`"log_tag"`).
518 | Set(`"log_id"`, log.ID).
519 | Set(`"tag"`, tag).
520 | Build()
521 | _, err = tx.Exec(query, args...)
522 | if err != nil {
523 | return err
524 | }
525 | }
526 |
527 | return nil
528 | }
529 |
530 | func (d *Postgres) DeleteLog(log *models.Log) error {
531 | tx, err := d.db.Begin()
532 | if err != nil {
533 | return err
534 | }
535 |
536 | _, err = tx.Exec(`DELETE FROM "log" WHERE "id" = $1`, log.ID)
537 | if err != nil {
538 | return err
539 | }
540 |
541 | return tx.Commit()
542 | }
543 |
--------------------------------------------------------------------------------