mirror of https://github.com/writeas/writefreely
This adds a "Reader" section of the site for admins who want to enable it for their instance. That means visitors can go to /read and see who has publicly shared their writing. They can also follow all public posts via RSS by going to /read/feed/. Writers on an instance with this `local_timeline` setting enabled can publish to the timeline by going into their blog settings and choosing the "Public" visibility setting. The `local_timeline` feature is disabled by default, as is the Public setting on writer blogs. Enabling it adds a "Reader" navigation item and enables the reader endpoints. This feature will also consume more memory, as public posts are cached in memory for 10 minutes. These changes include code ported over from Read.Write.as, and thus include some experimental features like filtering public posts by tags and authors. These features aren't well-tested or complete. Closes T554pull/78/head
parent
7828bf6ba2
commit
25a68d0c0e
@ -0,0 +1,292 @@ |
||||
package writefreely |
||||
|
||||
import ( |
||||
"database/sql" |
||||
"fmt" |
||||
. "github.com/gorilla/feeds" |
||||
"github.com/gorilla/mux" |
||||
stripmd "github.com/writeas/go-strip-markdown" |
||||
"github.com/writeas/impart" |
||||
"github.com/writeas/web-core/log" |
||||
"github.com/writeas/web-core/memo" |
||||
"github.com/writeas/writefreely/page" |
||||
"html/template" |
||||
"math" |
||||
"net/http" |
||||
"strconv" |
||||
"time" |
||||
) |
||||
|
||||
const ( |
||||
tlFeedLimit = 100 |
||||
tlAPIPageLimit = 10 |
||||
tlMaxAuthorPosts = 5 |
||||
tlPostsPerPage = 16 |
||||
) |
||||
|
||||
type localTimeline struct { |
||||
m *memo.Memo |
||||
posts *[]PublicPost |
||||
|
||||
// Configuration values
|
||||
postsPerPage int |
||||
} |
||||
|
||||
type readPublication struct { |
||||
page.StaticPage |
||||
Posts *[]PublicPost |
||||
CurrentPage int |
||||
TotalPages int |
||||
} |
||||
|
||||
func initLocalTimeline(app *app) { |
||||
app.timeline = &localTimeline{ |
||||
postsPerPage: tlPostsPerPage, |
||||
m: memo.New(app.db.FetchPublicPosts, 10*time.Minute), |
||||
} |
||||
} |
||||
|
||||
// satisfies memo.Func
|
||||
func (db *datastore) FetchPublicPosts() (interface{}, error) { |
||||
// Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months
|
||||
rows, err := db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated |
||||
FROM collections c |
||||
LEFT JOIN posts p ON p.collection_id = c.id |
||||
WHERE c.privacy = 1 AND (p.created >= ` + db.dateSub(3, "month") + ` AND p.created <= ` + db.now() + ` AND pinned_position IS NULL) |
||||
ORDER BY p.created DESC`) |
||||
if err != nil { |
||||
log.Error("Failed selecting from posts: %v", err) |
||||
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()} |
||||
} |
||||
defer rows.Close() |
||||
|
||||
ap := map[string]uint{} |
||||
|
||||
posts := []PublicPost{} |
||||
for rows.Next() { |
||||
p := &Post{} |
||||
c := &Collection{} |
||||
var alias, title sql.NullString |
||||
err = rows.Scan(&p.ID, &alias, &title, &p.Slug, &p.Title, &p.Content, &p.Font, &p.Language, &p.RTL, &p.Created, &p.Updated) |
||||
if err != nil { |
||||
log.Error("[READ] Unable to scan row, skipping: %v", err) |
||||
continue |
||||
} |
||||
isCollectionPost := alias.Valid |
||||
if isCollectionPost { |
||||
c.Alias = alias.String |
||||
if c.Alias != "" && ap[c.Alias] == tlMaxAuthorPosts { |
||||
// Don't add post if we've hit the post-per-author limit
|
||||
continue |
||||
} |
||||
|
||||
c.Public = true |
||||
c.Title = title.String |
||||
} |
||||
|
||||
p.extractData() |
||||
p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content))) |
||||
fp := p.processPost() |
||||
if isCollectionPost { |
||||
fp.Collection = &CollectionObj{Collection: *c} |
||||
} |
||||
|
||||
posts = append(posts, fp) |
||||
ap[c.Alias]++ |
||||
} |
||||
|
||||
return posts, nil |
||||
} |
||||
|
||||
func viewLocalTimelineAPI(app *app, w http.ResponseWriter, r *http.Request) error { |
||||
updateTimelineCache(app.timeline) |
||||
|
||||
skip, _ := strconv.Atoi(r.FormValue("skip")) |
||||
|
||||
posts := []PublicPost{} |
||||
for i := skip; i < skip+tlAPIPageLimit && i < len(*app.timeline.posts); i++ { |
||||
posts = append(posts, (*app.timeline.posts)[i]) |
||||
} |
||||
|
||||
return impart.WriteSuccess(w, posts, http.StatusOK) |
||||
} |
||||
|
||||
func viewLocalTimeline(app *app, w http.ResponseWriter, r *http.Request) error { |
||||
if !app.cfg.App.LocalTimeline { |
||||
return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} |
||||
} |
||||
|
||||
vars := mux.Vars(r) |
||||
var p int |
||||
page := 1 |
||||
p, _ = strconv.Atoi(vars["page"]) |
||||
if p > 0 { |
||||
page = p |
||||
} |
||||
|
||||
return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"]) |
||||
} |
||||
|
||||
func updateTimelineCache(tl *localTimeline) { |
||||
// Fetch posts if enough time has passed since last cache
|
||||
if tl.posts == nil || tl.m.Invalidate() { |
||||
log.Info("[READ] Updating post cache") |
||||
var err error |
||||
var postsInterfaces interface{} |
||||
postsInterfaces, err = tl.m.Get() |
||||
if err != nil { |
||||
log.Error("[READ] Unable to cache posts: %v", err) |
||||
} else { |
||||
castPosts := postsInterfaces.([]PublicPost) |
||||
tl.posts = &castPosts |
||||
} |
||||
} |
||||
} |
||||
|
||||
func showLocalTimeline(app *app, w http.ResponseWriter, r *http.Request, page int, author, tag string) error { |
||||
updateTimelineCache(app.timeline) |
||||
|
||||
pl := len(*(app.timeline.posts)) |
||||
ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage))) |
||||
|
||||
start := 0 |
||||
if page > 1 { |
||||
start = app.timeline.postsPerPage * (page - 1) |
||||
if start > pl { |
||||
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/read/p/%d", ttlPages)} |
||||
} |
||||
} |
||||
end := app.timeline.postsPerPage * page |
||||
if end > pl { |
||||
end = pl |
||||
} |
||||
var posts []PublicPost |
||||
if author != "" { |
||||
posts = []PublicPost{} |
||||
for _, p := range *app.timeline.posts { |
||||
if author == "anonymous" { |
||||
if p.Collection == nil { |
||||
posts = append(posts, p) |
||||
} |
||||
} else if p.Collection != nil && p.Collection.Alias == author { |
||||
posts = append(posts, p) |
||||
} |
||||
} |
||||
} else if tag != "" { |
||||
posts = []PublicPost{} |
||||
for _, p := range *app.timeline.posts { |
||||
if p.HasTag(tag) { |
||||
posts = append(posts, p) |
||||
} |
||||
} |
||||
} else { |
||||
posts = *app.timeline.posts |
||||
posts = posts[start:end] |
||||
} |
||||
|
||||
d := &readPublication{ |
||||
pageForReq(app, r), |
||||
&posts, |
||||
page, |
||||
ttlPages, |
||||
} |
||||
|
||||
err := templates["read"].ExecuteTemplate(w, "base", d) |
||||
if err != nil { |
||||
log.Error("Unable to render reader: %v", err) |
||||
fmt.Fprintf(w, ":(") |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// NextPageURL provides a full URL for the next page of collection posts
|
||||
func (c *readPublication) NextPageURL(n int) string { |
||||
return fmt.Sprintf("/read/p/%d", n+1) |
||||
} |
||||
|
||||
// PrevPageURL provides a full URL for the previous page of collection posts,
|
||||
// returning a /page/N result for pages >1
|
||||
func (c *readPublication) PrevPageURL(n int) string { |
||||
if n == 2 { |
||||
// Previous page is 1; no need for /p/ prefix
|
||||
return "/read" |
||||
} |
||||
return fmt.Sprintf("/read/p/%d", n-1) |
||||
} |
||||
|
||||
// handlePostIDRedirect handles a route where a post ID is given and redirects
|
||||
// the user to the canonical post URL.
|
||||
func handlePostIDRedirect(app *app, w http.ResponseWriter, r *http.Request) error { |
||||
vars := mux.Vars(r) |
||||
postID := vars["post"] |
||||
p, err := app.db.GetPost(postID, 0) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if !p.CollectionID.Valid { |
||||
// No collection; send to normal URL
|
||||
// NOTE: not handling single user blogs here since this handler is only used for the Reader
|
||||
return impart.HTTPError{http.StatusFound, app.cfg.App.Host + "/" + postID + ".md"} |
||||
} |
||||
|
||||
c, err := app.db.GetCollectionBy("id = ?", fmt.Sprintf("%d", p.CollectionID.Int64)) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Retrieve collection information and send user to canonical URL
|
||||
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + p.Slug.String} |
||||
} |
||||
|
||||
func viewLocalTimelineFeed(app *app, w http.ResponseWriter, req *http.Request) error { |
||||
if !app.cfg.App.LocalTimeline { |
||||
return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} |
||||
} |
||||
|
||||
updateTimelineCache(app.timeline) |
||||
|
||||
feed := &Feed{ |
||||
Title: app.cfg.App.SiteName + " Reader", |
||||
Link: &Link{Href: app.cfg.App.Host}, |
||||
Description: "Read the latest posts from " + app.cfg.App.SiteName + ".", |
||||
Created: time.Now(), |
||||
} |
||||
|
||||
c := 0 |
||||
var title, permalink, author string |
||||
for _, p := range *app.timeline.posts { |
||||
if c == tlFeedLimit { |
||||
break |
||||
} |
||||
|
||||
title = p.PlainDisplayTitle() |
||||
permalink = p.CanonicalURL() |
||||
if p.Collection != nil { |
||||
author = p.Collection.Title |
||||
} else { |
||||
author = "Anonymous" |
||||
permalink += ".md" |
||||
} |
||||
i := &Item{ |
||||
Id: app.cfg.App.Host + "/read/a/" + p.ID, |
||||
Title: title, |
||||
Link: &Link{Href: permalink}, |
||||
Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>", |
||||
Content: applyMarkdown([]byte(p.Content)), |
||||
Author: &Author{author, ""}, |
||||
Created: p.Created, |
||||
Updated: p.Updated, |
||||
} |
||||
feed.Items = append(feed.Items, i) |
||||
c++ |
||||
} |
||||
|
||||
rss, err := feed.ToRss() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
fmt.Fprint(w, rss) |
||||
return nil |
||||
} |
@ -0,0 +1,127 @@ |
||||
{{define "head"}}<title>{{.SiteName}} Reader</title> |
||||
|
||||
<link rel="alternate" type="application/rss+xml" title="{{.SiteName}} Reader" href="/read/feed/" /> |
||||
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .CurrentPage}}">{{end}} |
||||
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .CurrentPage}}">{{end}} |
||||
|
||||
<meta name="description" content="Read the latest posts from {{.SiteName}}."> |
||||
<meta itemprop="name" content="{{.SiteName}} Reader"> |
||||
<meta itemprop="description" content="Read the latest posts from {{.SiteName}}."> |
||||
<meta name="twitter:card" content="summary_large_image"> |
||||
<meta name="twitter:title" content="{{.SiteName}} Reader"> |
||||
<meta name="twitter:description" content="Read the latest posts from {{.SiteName}}."> |
||||
<meta property="og:title" content="{{.SiteName}} Reader" /> |
||||
<meta property="og:type" content="object" /> |
||||
<meta property="og:description" content="Read the latest posts from {{.SiteName}}." /> |
||||
|
||||
<style> |
||||
.heading h1 { |
||||
font-weight: 300; |
||||
text-align: center; |
||||
margin: 3em 0 0; |
||||
} |
||||
.heading p { |
||||
text-align: center; |
||||
margin: 1.5em 0 4.5em; |
||||
font-size: 1.1em; |
||||
color: #777; |
||||
} |
||||
#wrapper { |
||||
font-size: 1.2em; |
||||
} |
||||
.preview { |
||||
max-height: 180px; |
||||
overflow: hidden; |
||||
position: relative; |
||||
} |
||||
.preview .over { |
||||
position: absolute; |
||||
top: 5em; |
||||
bottom: 0; |
||||
left: 0; |
||||
right: 0; |
||||
/* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#ffffff+0,ffffff+100&0+0,1+100 */ |
||||
background: -moz-linear-gradient(top, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%); /* FF3.6-15 */ |
||||
background: -webkit-linear-gradient(top, rgba(255,255,255,0) 0%,rgba(255,255,255,1) 100%); /* Chrome10-25,Safari5.1-6 */ |
||||
background: linear-gradient(to bottom, rgba(255,255,255,0) 0%,rgba(255,255,255,1) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ |
||||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#00ffffff', endColorstr='#ffffff',GradientType=0 ); /* IE6-9 */ |
||||
} |
||||
p.source { |
||||
font-size: 0.86em; |
||||
margin-top: 0.25em; |
||||
margin-bottom: 0; |
||||
} |
||||
.attention-box { |
||||
text-align: center; |
||||
font-size: 1.1em; |
||||
} |
||||
.attention-box hr { margin: 4rem auto; } |
||||
hr { max-width: 40rem; } |
||||
header { |
||||
padding: 0 !important; |
||||
text-align: left !important; |
||||
margin: 1em !important; |
||||
max-width: 100% !important; |
||||
} |
||||
body#collection header nav { |
||||
display: inline !important; |
||||
margin: 0 0 0 1em !important; |
||||
} |
||||
header nav#user-nav { |
||||
margin-left: 0 !important; |
||||
} |
||||
</style> |
||||
{{end}} |
||||
{{define "body-attrs"}}id="collection"{{end}} |
||||
{{define "content"}} |
||||
<div class="content-container snug" style="max-width: 40rem;"> |
||||
<h1 style="text-align:center">Reader</h1> |
||||
<p>Read the latest posts from {{.SiteName}}. {{if .Username}}To showcase your writing here, go to your <a href="/me/c/">blog</a> settings and select the <em>Public</em> option.{{end}}</p> |
||||
</div> |
||||
<div id="wrapper"> |
||||
{{ if gt (len .Posts) 0 }} |
||||
<section itemscope itemtype="http://schema.org/Blog"> |
||||
{{range .Posts}}<article class="{{.Font}} h-entry" itemscope itemtype="http://schema.org/BlogPosting"> |
||||
{{if .Title.String}}<h2 class="post-title" itemprop="name" class="p-name"><a href="{{if .Slug.String}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}" itemprop="url" class="u-url">{{.PlainDisplayTitle}}</a></h2> |
||||
<time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{.Collection.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time> |
||||
{{else}} |
||||
<h2 class="post-title" itemprop="name"><time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time></h2> |
||||
{{end}} |
||||
<p class="source">{{if .Collection}}from <a href="{{.Collection.CanonicalURL}}">{{.Collection.DisplayTitle}}</a>{{else}}<em>Anonymous</em>{{end}}</p> |
||||
{{if .Excerpt}}<div class="p-summary" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{.Excerpt}}</div> |
||||
|
||||
<a class="read-more" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div class="e-content preview" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{ if not .HTMLContent }}<p id="post-body" class="e-content preview">{{.Content}}</p>{{ else }}{{.HTMLContent}}{{ end }}<div class="over"> </div></div> |
||||
|
||||
<a class="read-more maybe" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{end}}</article> |
||||
{{end}} |
||||
</section> |
||||
{{ else }} |
||||
<div class="attention-box"> |
||||
<p>No posts here yet!</p> |
||||
</div> |
||||
{{ end }} |
||||
|
||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix"> |
||||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .CurrentPage}}">⇠ Older</a>{{end}} |
||||
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .CurrentPage}}">Newer ⇢</a>{{end}} |
||||
</nav>{{end}} |
||||
|
||||
</div> |
||||
|
||||
<script type="text/javascript"> |
||||
(function() { |
||||
var $articles = document.querySelectorAll('article'); |
||||
for (var i=0; i<$articles.length; i++) { |
||||
var $art = $articles[i]; |
||||
var $more = $art.querySelector('.read-more.maybe'); |
||||
if ($more != null) { |
||||
if ($art.querySelector('.e-content.preview').clientHeight < 180) { |
||||
$more.parentNode.removeChild($more); |
||||
var $overlay = $art.querySelector('.over'); |
||||
$overlay.parentNode.removeChild($overlay); |
||||
} |
||||
} |
||||
} |
||||
})(); |
||||
</script> |
||||
{{end}} |
Loading…
Reference in new issue