mirror of
https://github.com/Luzifer/mediatimeline.git
synced 2024-11-08 14:50:08 +00:00
Initial implementation
This commit is contained in:
commit
bf68f32c0e
6 changed files with 733 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
.env
|
||||
mediatimeline
|
||||
runwrap.sh
|
||||
tweets.db
|
260
frontend/index.html
Normal file
260
frontend/index.html
Normal file
|
@ -0,0 +1,260 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
|
||||
<title>MediaTimeline</title>
|
||||
|
||||
<!-- Bootstrap -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@4.3.1/dist/darkly/bootstrap.min.css"
|
||||
integrity="sha256-6W1mxPaAt4a6pkJVW5x5Xmq/LvxuQpR9dlzgy77SeZs=" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css"
|
||||
integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous">
|
||||
|
||||
<style>
|
||||
.card-img-top {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
.faved {
|
||||
color: yellow;
|
||||
}
|
||||
.user-image {
|
||||
border-radius: 1.25rem;
|
||||
height: 36px;
|
||||
}
|
||||
.tweet {
|
||||
max-height: 50px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-5">
|
||||
<a class="navbar-brand" href="#">MediaTimeline Viewer</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-3 col-12" v-for="tweet in tweets" :key="tweet.id" v-if="tweet.images">
|
||||
<div class="card mb-3">
|
||||
<div :style="`background-image: url('${tweet.images[0].image}')`" class="card-img-top"></div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="media">
|
||||
<img :src="tweet.user.image" class="mr-2 user-image">
|
||||
<div class="media-body">
|
||||
<h6 class="mt-0 mb-0">{{ tweet.user.screen_name }}</h6>
|
||||
<small>{{ moment(tweet.posted).format('lll') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card-footer text-right">
|
||||
<a class="btn btn-sm btn-secondary" :href="`https://twitter.com/${tweet.user.screen_name}/status/${tweet.id}`">
|
||||
<i class="fas fa-link"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-secondary" @click="refetch(tweet)">
|
||||
<i class="fas fa-sync"></i>
|
||||
</button>
|
||||
<button
|
||||
:class="{ btn: true, 'btn-sm': true, 'btn-secondary': true, 'faved': tweet.favorited }"
|
||||
@click="favourite(tweet)"
|
||||
>
|
||||
<i class="fas fa-star"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" @click="callModal(tweet)">
|
||||
<i class="fas fa-search-plus"></i>
|
||||
<span class="badge badge-light badge-pill" v-if="tweet.images.length > 1">{{ tweet.images.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal" tabindex="-1" role="dialog" v-if="modalTweet">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ modalTweet.user.screen_name }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<div id="carouselExampleControls" class="carousel slide">
|
||||
<div class="carousel-inner">
|
||||
<div :class="{ 'carousel-item': true, 'active': idx == 0 }" v-for="(image, idx) in modalTweet.images">
|
||||
<img :src="image.image" class="d-block w-100">
|
||||
</div>
|
||||
</div>
|
||||
<a class="carousel-control-prev" href="#carouselExampleControls" role="button" data-slide="prev" v-if="modalTweet.images.length > 1">
|
||||
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
|
||||
<span class="sr-only">Previous</span>
|
||||
</a>
|
||||
<a class="carousel-control-next" href="#carouselExampleControls" role="button" data-slide="next" v-if="modalTweet.images.length > 1">
|
||||
<span class="carousel-control-next-icon" aria-hidden="true"></span>
|
||||
<span class="sr-only">Next</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /.container -->
|
||||
|
||||
|
||||
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.4.0/dist/jquery.min.js"
|
||||
integrity="sha256-BJeo0qm959uMBGb65z40ejJYGSgR7REI4+CW1fNKwOg=" crossorigin="anonymous"></script>
|
||||
<!-- Include all compiled plugins (below), or include individual files as needed -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/js/bootstrap.min.js"
|
||||
integrity="sha256-CjSoeELFOcH0/uxWu6mC/Vlrc1AARqbm/jiiImDGV3s=" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.min.js"
|
||||
integrity="sha256-chlNFSVx3TdcQ2Xlw7SvnbLAavAQLO0Y/LBiWX04viY=" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios@0.18.0/dist/axios.min.js"
|
||||
integrity="sha256-mpnrJ5DpEZZkwkE1ZgkEQQJW/46CSEh/STrZKOB/qoM=" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment@2.24.0/min/moment.min.js"
|
||||
integrity="sha256-4iQZ6BVL4qNKlQ27TExEhBN1HFPvAvAMbFavKKosSWQ=" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
function sortOrder(i, j) {
|
||||
switch (true) {
|
||||
case i < j:
|
||||
return -1
|
||||
case j < i:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
let app = new Vue({
|
||||
computed: {
|
||||
},
|
||||
|
||||
data: {
|
||||
tweets: [],
|
||||
modalTweet: null,
|
||||
},
|
||||
|
||||
el: ".container",
|
||||
|
||||
methods: {
|
||||
callModal(tweet) {
|
||||
this.modalTweet = tweet
|
||||
},
|
||||
|
||||
favourite(tweet) {
|
||||
axios
|
||||
.post('/api/favourite', { id: tweet.id })
|
||||
.then((res) => {
|
||||
if (res.data.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.upsertTweets(res.data)
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
},
|
||||
|
||||
refetch(tweet) {
|
||||
axios
|
||||
.post('/api/refresh', { id: tweet.id })
|
||||
.then((res) => {
|
||||
if (res.data.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.upsertTweets(res.data)
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
},
|
||||
|
||||
refresh() {
|
||||
let apiURL = '/api/page' // By default query page 1
|
||||
if (this.tweets.length > 0) {
|
||||
apiURL = `/api/since?id=${this.tweets[0].id}`
|
||||
}
|
||||
|
||||
axios
|
||||
.get(apiURL)
|
||||
.then((resp) => {
|
||||
if (resp.data.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.upsertTweets(resp.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
},
|
||||
|
||||
upsertTweets(data) {
|
||||
let tweets = this.tweets
|
||||
|
||||
for (idx in data) {
|
||||
let tweet = data[idx]
|
||||
let inserted = false
|
||||
|
||||
for (let i = 0; i < tweets.length; i++) {
|
||||
if (tweets[i].id == tweet.id) {
|
||||
tweets[i] = tweet
|
||||
inserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
tweets = [...tweets, tweet]
|
||||
}
|
||||
}
|
||||
|
||||
tweets.sort((j, i) => sortOrder(i.id, j.id))
|
||||
this.tweets = tweets
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.refresh()
|
||||
window.setInterval(this.refresh, 30000)
|
||||
},
|
||||
|
||||
watch: {
|
||||
modalTweet() {
|
||||
window.setTimeout(() => {
|
||||
$('.modal').on('hide.bs.modal', () => {
|
||||
// When modal is closed clean it up
|
||||
$('.carousel').carousel('dispose')
|
||||
this.modalTweet = null
|
||||
})
|
||||
$('.modal').modal('show')
|
||||
$('.carousel').carousel({
|
||||
pause: true,
|
||||
})
|
||||
}, 100)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
131
http.go
Normal file
131
http.go
Normal file
|
@ -0,0 +1,131 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/ChimeraCoder/anaconda"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func init() {
|
||||
http.HandleFunc("/api/page", handlePage)
|
||||
http.HandleFunc("/api/since", handleNewest)
|
||||
http.HandleFunc("/api/favourite", handleFavourite)
|
||||
http.HandleFunc("/api/refresh", handleTweetRefresh)
|
||||
}
|
||||
|
||||
func handlePage(w http.ResponseWriter, r *http.Request) {
|
||||
var page int = 1
|
||||
|
||||
if p, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
|
||||
tweets, err := tweetStore.GetTweetPage(page)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to fetch tweets for page request")
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tweetResponse(w, tweets)
|
||||
}
|
||||
|
||||
func handleNewest(w http.ResponseWriter, r *http.Request) {
|
||||
var since uint64 = 0
|
||||
|
||||
if s, err := strconv.ParseUint(r.URL.Query().Get("id"), 10, 64); err == nil && s > 0 {
|
||||
since = s
|
||||
}
|
||||
|
||||
if since == 0 {
|
||||
http.Error(w, "Must specify last id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tweets, err := tweetStore.GetTweetsSince(since)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to fetch tweets for newest request")
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tweetResponse(w, tweets)
|
||||
}
|
||||
|
||||
func tweetResponse(w http.ResponseWriter, tweets []tweet) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
json.NewEncoder(w).Encode(tweets)
|
||||
}
|
||||
|
||||
func handleFavourite(w http.ResponseWriter, r *http.Request) {
|
||||
req := struct {
|
||||
ID int64 `json:"id,string"`
|
||||
}{}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID == 0 {
|
||||
http.Error(w, "Need to specify id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tweet, err := twitter.Favorite(req.ID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to favourite tweet")
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tweets, err := convertTweetList([]anaconda.Tweet{tweet}, true)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to convert tweet for storing")
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = tweetStore.StoreTweets(tweets); err != nil {
|
||||
log.WithError(err).Error("Unable to update tweet")
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tweetResponse(w, tweets)
|
||||
}
|
||||
|
||||
func handleTweetRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
req := struct {
|
||||
ID int64 `json:"id,string"`
|
||||
}{}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID == 0 {
|
||||
http.Error(w, "Need to specify id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tweet, err := twitter.GetTweet(req.ID, url.Values{})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to fetch tweet")
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tweets, err := convertTweetList([]anaconda.Tweet{tweet}, true)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to convert tweet for storing")
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = tweetStore.StoreTweets(tweets); err != nil {
|
||||
log.WithError(err).Error("Unable to update tweet")
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tweetResponse(w, tweets)
|
||||
}
|
101
main.go
Normal file
101
main.go
Normal file
|
@ -0,0 +1,101 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ChimeraCoder/anaconda"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/Luzifer/rconfig"
|
||||
)
|
||||
|
||||
var (
|
||||
cfg = struct {
|
||||
AppToken string `flag:"app-token" description:"Token for the application" validate:"nonzero"`
|
||||
AppSecret string `flag:"app-secret" description:"Secret for the provided token" validate:"nonzero"`
|
||||
Database string `flag:"database" default:"tweets.db" description:"Database storage location"`
|
||||
Frontend string `flag:"frontend" default:"frontend" description:"Directory containing frontend files"`
|
||||
Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"`
|
||||
ListOwner string `flag:"list-owner" description:"Owner of the specified list" validate:"nonzero"`
|
||||
ListSlug string `flag:"list-slug" description:"Slug of the list" validate:"nonzero"`
|
||||
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
|
||||
UserToken string `flag:"user-token" description:"Token for the user" validate:"nonzero"`
|
||||
UserSecret string `flag:"user-secret" description:"Secret for the provided token" validate:"nonzero"`
|
||||
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
|
||||
}{}
|
||||
|
||||
tweetStore *store
|
||||
twitter *anaconda.TwitterApi
|
||||
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rconfig.AutoEnv(true)
|
||||
if err := rconfig.ParseAndValidate(&cfg); err != nil {
|
||||
log.Fatalf("Unable to parse commandline options: %s", err)
|
||||
}
|
||||
|
||||
if cfg.VersionAndExit {
|
||||
fmt.Printf("mediatimeline %s\n", version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if l, err := log.ParseLevel(cfg.LogLevel); err != nil {
|
||||
log.WithError(err).Fatal("Unable to parse log level")
|
||||
} else {
|
||||
log.SetLevel(l)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
if tweetStore, err = newStore(cfg.Database); err != nil {
|
||||
log.WithError(err).Fatal("Unable to create store")
|
||||
}
|
||||
|
||||
twitter = anaconda.NewTwitterApiWithCredentials(
|
||||
cfg.UserToken, cfg.UserSecret,
|
||||
cfg.AppToken, cfg.AppSecret,
|
||||
)
|
||||
go loadAndStoreTweets()
|
||||
|
||||
http.Handle("/", http.FileServer(http.Dir(cfg.Frontend)))
|
||||
http.ListenAndServe(cfg.Listen, nil)
|
||||
}
|
||||
|
||||
func loadAndStoreTweets() {
|
||||
for t := time.NewTicker(time.Minute); true; <-t.C {
|
||||
params := url.Values{
|
||||
"count": []string{"100"},
|
||||
}
|
||||
|
||||
lastTweet := tweetStore.GetLastTweetID()
|
||||
|
||||
if lastTweet > 0 {
|
||||
params.Set("since_id", strconv.FormatUint(lastTweet, 10))
|
||||
}
|
||||
|
||||
anacondaTweets, err := twitter.GetListTweetsBySlug(cfg.ListSlug, cfg.ListOwner, false, params)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to fetch tweets")
|
||||
continue
|
||||
}
|
||||
|
||||
tweets, err := convertTweetList(anacondaTweets, true)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Unable to parse tweets")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := tweetStore.StoreTweets(tweets); err != nil {
|
||||
log.WithError(err).Error("Unable to store tweets")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
149
storage.go
Normal file
149
storage.go
Normal file
|
@ -0,0 +1,149 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const tweetPageSize = 50
|
||||
|
||||
type store struct {
|
||||
s []tweet
|
||||
location string
|
||||
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func init() {
|
||||
gob.Register(tweet{})
|
||||
}
|
||||
|
||||
func newStore(location string) (*store, error) {
|
||||
s := &store{
|
||||
s: []tweet{},
|
||||
location: location,
|
||||
}
|
||||
|
||||
return s, s.load()
|
||||
}
|
||||
|
||||
func (s *store) StoreTweets(tweets []tweet) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
tmp := s.s
|
||||
|
||||
for _, t := range tweets {
|
||||
var stored bool
|
||||
|
||||
for i := 0; i < len(tmp); i++ {
|
||||
if tmp[i].ID == t.ID {
|
||||
tmp[i] = t
|
||||
stored = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !stored {
|
||||
tmp = append(tmp, t)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(tmp, func(j, i int) bool { return tmp[i].ID < tmp[j].ID })
|
||||
|
||||
s.s = tmp
|
||||
|
||||
return s.save()
|
||||
}
|
||||
|
||||
func (s *store) GetLastTweetID() uint64 {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
if len(s.s) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return s.s[0].ID
|
||||
}
|
||||
|
||||
func (s *store) GetTweetPage(page int) ([]tweet, error) {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
var (
|
||||
start = (page - 1) * tweetPageSize
|
||||
num = tweetPageSize
|
||||
)
|
||||
|
||||
if start > len(s.s) {
|
||||
return []tweet{}, nil
|
||||
}
|
||||
|
||||
if start+num >= len(s.s) {
|
||||
num = len(s.s) - start
|
||||
}
|
||||
|
||||
return s.s[start:num], nil
|
||||
}
|
||||
|
||||
func (s *store) GetTweetsSince(since uint64) ([]tweet, error) {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
var i int
|
||||
|
||||
for i = 0; i < len(s.s); i++ {
|
||||
if s.s[i].ID <= since {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return s.s[:i], nil
|
||||
}
|
||||
|
||||
func (s *store) load() error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
if _, err := os.Stat(s.location); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Leave a fresh store
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "Unable to stat storage file")
|
||||
}
|
||||
|
||||
f, err := os.Open(s.location)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Unable to open storage file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
tmp := []tweet{}
|
||||
|
||||
if err := gob.NewDecoder(f).Decode(&tmp); err != nil {
|
||||
return errors.Wrap(err, "Unable to decode storage file")
|
||||
}
|
||||
|
||||
s.s = tmp
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) save() error {
|
||||
// No need to lock here, has write-lock from s.StoreTweets
|
||||
|
||||
f, err := os.Create(s.location)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Unable to open store for writing")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return errors.Wrap(gob.NewEncoder(f).Encode(s.s), "Unable to encode store")
|
||||
}
|
88
tweet.go
Normal file
88
tweet.go
Normal file
|
@ -0,0 +1,88 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ChimeraCoder/anaconda"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func convertTweetList(in []anaconda.Tweet, filterForMedia bool) ([]tweet, error) {
|
||||
out := []tweet{}
|
||||
|
||||
for _, t := range in {
|
||||
tw, err := tweetFromAnaconda(t)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Unable to parse tweet")
|
||||
}
|
||||
|
||||
if len(tw.Images) == 0 && filterForMedia {
|
||||
continue
|
||||
}
|
||||
out = append(out, tw)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type tweet struct {
|
||||
Favorited bool `json:"favorited"`
|
||||
ID uint64 `json:"id,string"`
|
||||
Images []media `json:"images"`
|
||||
Posted time.Time `json:"posted"`
|
||||
User user `json:"user"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func tweetFromAnaconda(in anaconda.Tweet) (tweet, error) {
|
||||
medias := []media{}
|
||||
for _, m := range in.ExtendedEntities.Media {
|
||||
if m.Type != "photo" {
|
||||
continue
|
||||
}
|
||||
|
||||
medias = append(medias, mediaFromAnaconda(m))
|
||||
}
|
||||
|
||||
created, _ := in.CreatedAtTime()
|
||||
id, err := strconv.ParseUint(in.IdStr, 10, 64)
|
||||
if err != nil {
|
||||
return tweet{}, errors.Wrap(err, "Unable to parse tweet ID")
|
||||
}
|
||||
|
||||
return tweet{
|
||||
Favorited: in.Favorited,
|
||||
ID: id,
|
||||
Images: medias,
|
||||
Posted: created,
|
||||
User: userFromAnaconda(in.User),
|
||||
Text: in.Text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type user struct {
|
||||
ID int64 `json:"id"`
|
||||
ScreenName string `json:"screen_name"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
func userFromAnaconda(in anaconda.User) user {
|
||||
return user{
|
||||
ID: in.Id,
|
||||
ScreenName: in.ScreenName,
|
||||
Image: in.ProfileImageUrlHttps,
|
||||
}
|
||||
}
|
||||
|
||||
type media struct {
|
||||
ID int64 `json:"id"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
func mediaFromAnaconda(in anaconda.EntityMedia) media {
|
||||
return media{
|
||||
ID: in.Id,
|
||||
Image: in.Media_url_https,
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue