mirror of
https://github.com/Luzifer/mediatimeline.git
synced 2024-09-19 07:52:57 +00:00
Initial implementation
This commit is contained in:
commit
bf68f32c0e
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 New Issue
Block a user