Compare commits

...

16 Commits

Author SHA1 Message Date
Nazar Kanaev
95aa3313bf [wip] 2024-12-04 22:02:35 +00:00
Nazar Kanaev
5254df53dc update docs 2024-12-04 21:44:10 +00:00
tillcash
7301eab99c Add Dynamic Disable Functionality to Navigation Buttons 2024-12-04 21:42:36 +00:00
Nazar Kanaev
ad138c3017 show article content in the list if the title is missing 2024-12-04 21:36:44 +00:00
Nazar Kanaev
b09c95d7ea navigation buttons (+ fix keyboard navigation in 1-pane view) 2024-12-04 15:57:34 +00:00
Nazar Kanaev
64611a0dd3 update changelog 2024-12-04 14:28:10 +00:00
Donovan Glover
321ad7608f fix: use noreferrer for external links
Based on the existing noreferrer code in the code base.
2024-11-28 12:58:00 +00:00
Nazar Kanaev
2a8b6ea935 update changelog 2024-11-25 21:50:38 +00:00
Nazar Kanaev
e9cbea500b autogenerate feed item guids 2024-11-25 21:35:04 +00:00
Nazar Kanaev
223039b2c6 do not send referrer for external images 2024-11-25 21:01:45 +00:00
funkycode
7402dfc4e6 fix the build 2024-11-09 23:10:43 +00:00
Tim Shaffer
6b12715506 Only read config dir if db is not provided. 2024-11-01 10:27:59 +00:00
Aidan Holm
2dc58c5c8e Fix user agent using hardcoded 1.0 version. 2024-10-30 09:49:33 +00:00
Nazar Kanaev
0cef51c6ac add sample links 2024-10-07 22:22:56 +01:00
Nazar Kanaev
2a4d974965 go fmt 2024-10-07 12:20:45 +01:00
dependabot[bot]
f71792d6a5 Bump actions/download-artifact from 2 to 4.1.7 in /.github/workflows
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 2 to 4.1.7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v2...v4.1.7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-29 21:36:53 +01:00
28 changed files with 591 additions and 226 deletions

View File

@ -103,7 +103,7 @@ jobs:
draft: true
prerelease: true
- name: Download Artifacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4.1.7
with:
path: .
- name: Preparation

View File

@ -13,6 +13,7 @@ import (
"github.com/nkanaev/yarr/src/platform"
"github.com/nkanaev/yarr/src/server"
"github.com/nkanaev/yarr/src/storage"
"github.com/nkanaev/yarr/src/worker"
)
var Version string = "0.0"
@ -89,12 +90,12 @@ func main() {
log.SetOutput(os.Stdout)
}
configPath, err := os.UserConfigDir()
if err != nil {
log.Fatal("Failed to get config dir: ", err)
}
if db == "" {
configPath, err := os.UserConfigDir()
if err != nil {
log.Fatal("Failed to get config dir: ", err)
}
storagePath := filepath.Join(configPath, "yarr")
if err := os.MkdirAll(storagePath, 0755); err != nil {
log.Fatal("Failed to create app config dir: ", err)
@ -105,6 +106,7 @@ func main() {
log.Printf("using db file %s", db)
var username, password string
var err error
if authfile != "" {
f, err := os.Open(authfile)
if err != nil {
@ -131,6 +133,7 @@ func main() {
log.Fatal("Failed to initialise database: ", err)
}
worker.SetVersion(Version)
srv := server.NewServer(store, addr)
if basepath != "" {

View File

@ -3,6 +3,7 @@
- (new) Fever API support (thanks to @icefed)
- (new) editable feed link (thanks to @adaszko)
- (new) switch to feed by clicking the title in the article page (thanks to @tarasglek for suggestion)
- (new) support multiple media links
- (fix) duplicate articles caused by the same feed addition (thanks to @adaszko)
- (fix) relative article links (thanks to @adazsko for the report)
- (fix) atom article links stored in id element (thanks to @adazsko for the report)
@ -10,6 +11,12 @@
- (fix) sorting same-day batch articles (thanks to @lamescholar for the report)
- (fix) showing login page in the selected theme (thanks to @feddiriko for the report)
- (fix) parsing atom feeds with html elements (thanks to @tillcash & @toBeOfUse for the report, @krkk for the fix)
- (fix) parsing feeds with missing guids (thanks to @hoyii for the report)
- (fix) sending actual client version to servers (thanks to @aidanholm)
- (fix) error caused by missing config dir (thanks to @timster)
- (etc) load external images with no-referrer policy (thanks to @tillcash for the report)
- (etc) open external links with no-referrer policy (thanks to @donovanglover)
- (etc) show article content in the list if title is missing (thanks to @asimpson for suggestion)
# v2.4 (2023-08-15)

68
doc/samples.yml Normal file
View File

@ -0,0 +1,68 @@
- site: https://vimeo.com/channels/staffpicks/videos
feed: https://vimeo.com/channels/staffpicks/videos/rss
tags: [vimeo, image]
- site: https://www.youtube.com/@everyframeapainting/videos
feed: https://www.youtube.com/feeds/videos.xml?channel_id=UCjFqcJQXGZ6T6sxyFB-5i6A"
tags: [youtube, image]
- site: https://iwdrm.tumblr.com/
feed: https://iwdrm.tumblr.com/rss
tags: [tumblr, image]
- site: https://falseknees.tumblr.com/
feed: https://falseknees.tumblr.com/rss
tags: [tumblr, image]
- site: https://accidentallyquadratic.tumblr.com/
feed: https://accidentallyquadratic.tumblr.com/rss
info: text blog with code sections
tags: [tumblr, text, code]
- site: https://www.flickr.com/photos/maratsafin/
feed: https://www.flickr.com/services/feeds/photos_public.gne?id=59021497@N07&lang=en-us&format=atom
tags: [flickr, image]
- site: https://www.reddit.com/r/comics
feed: https://www.reddit.com/r/comics.rss
tags: [reddit, image]
- site: https://www.reddit.com/r/AITAH
feed: https://www.reddit.com/r/AITAH.rss
tags: [reddit, text]
- site: https://idothei.wordpress.com/
feed: https://idothei.wordpress.com/feed/
tags: [wordpress, text]
- site: https://www.vidarholen.net/contents/blog/
feed: https://www.vidarholen.net/contents/blog/?feed=rss2
tags: [wordpress, text]
- site: https://blog.posthaven.com/
feed: https://blog.posthaven.com/posts.atom
tags: [posthaven, text]
- site: https://medium.com/@dailynewsletter
feed: https://medium.com/feed/@dailynewsletter
tags: [medium, text]
- site: https://thereveal.substack.com/
feed: https://thereveal.substack.com/feed
tags: [substack, text]
- site: https://tema.livejournal.com/
feed: https://tema.livejournal.com/data/rss
tags: [livejournal, text]
- site: https://mametter.hatenablog.com/
feed: https://mametter.hatenablog.com/feed
tags: [hatena, text]
- site: https://juliepowell.blogspot.com/
feed: https://juliepowell.blogspot.com/feeds/posts/default
tags: [blogger, text]
- site: https://micro.blog/val
feed: https://micro.blog/posts/val
tags: [json, microblog]

2
go.mod
View File

@ -1,6 +1,6 @@
module github.com/nkanaev/yarr
go 1.17
go 1.18
require (
github.com/mattn/go-sqlite3 v1.14.7

View File

@ -1,6 +1,7 @@
VERSION=2.4
GITHASH=$(shell git rev-parse --short=8 HEAD)
GO_TAGS = sqlite_foreign_keys sqlite_json
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
export GOARCH ?= amd64
@ -8,26 +9,26 @@ export CGO_ENABLED = 1
build_default:
mkdir -p _output
go build -tags "sqlite_foreign_keys" -ldflags="$(GO_LDFLAGS)" -o _output/yarr ./cmd/yarr
go build -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)" -o _output/yarr ./cmd/yarr
build_macos:
mkdir -p _output/macos
GOOS=darwin go build -tags "sqlite_foreign_keys macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr ./cmd/yarr
GOOS=darwin go build -tags "$(GO_TAGS) macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr ./cmd/yarr
cp src/platform/icon.png _output/macos/icon.png
go run ./cmd/package_macos -outdir _output/macos -version "$(VERSION)"
build_linux:
mkdir -p _output/linux
GOOS=linux go build -tags "sqlite_foreign_keys linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr ./cmd/yarr
GOOS=linux go build -tags "$(GO_TAGS) linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr ./cmd/yarr
build_windows:
mkdir -p _output/windows
go run ./cmd/generate_versioninfo -version "$(VERSION)" -outfile src/platform/versioninfo.rc
windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
GOOS=windows go build -tags "sqlite_foreign_keys windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe ./cmd/yarr
GOOS=windows go build -tags "$(GO_TAGS) windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe ./cmd/yarr
serve:
go run -tags "sqlite_foreign_keys" ./cmd/yarr -db local.db
go run -tags "$(GO_TAGS)" ./cmd/yarr -db local.db
test:
go test -tags "sqlite_foreign_keys" ./...
go test -tags "$(GO_TAGS)" ./...

View File

@ -207,11 +207,11 @@
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<header class="dropdown-header">{{ current.feed.title }}</header>
<a class="dropdown-item" :href="current.feed.link" target="_blank" v-if="current.feed.link">
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
<span class="icon mr-1">{% inline "globe.svg" %}</span>
Website
</a>
<a class="dropdown-item" :href="current.feed.feed_link" target="_blank" v-if="current.feed.feed_link">
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
<span class="icon mr-1">{% inline "rss.svg" %}</span>
Feed Link
</a>
@ -326,10 +326,16 @@
title="Read Here">
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
</button>
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" title="Open Link">
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" title="Open Link">
<span class="icon">{% inline "external-link.svg" %}</span>
</a>
<div class="flex-grow-1"></div>
<button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="itemSelected == items[0].id">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="itemSelected == items[items.length - 1].id">
<span class="icon">{% inline "chevron-right.svg" %}</span>
</button>
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
<span class="icon">{% inline "x.svg" %}</span>
</button>
@ -351,8 +357,13 @@
</div>
<hr>
<div v-if="!itemSelectedReadability">
<img :src="itemSelectedDetails.image" v-if="itemSelectedDetails.image" class="mb-3">
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
<div v-if="contentImages.length">
<figure v-for="media in contentImages">
<img :src="media.url" loading="lazy">
<figcaption v-if="media.description" v-html="media.description"></figcaption>
</figure>
</div>
<audio class="w-100" controls v-for="media in contentAudios" :src="media.url"></audio>
</div>
<div v-html="itemSelectedContent"></div>
</div>

View File

@ -2,6 +2,26 @@
var TITLE = document.title
function scrollto(target, scroll) {
var padding = 10
var targetRect = target.getBoundingClientRect()
var scrollRect = scroll.getBoundingClientRect()
// target
var relativeOffset = targetRect.y - scrollRect.y
var absoluteOffset = relativeOffset + scroll.scrollTop
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
var newPos = scroll.scrollTop
if (relativeOffset < padding) {
newPos = absoluteOffset - padding
} else {
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
}
scroll.scrollTop = Math.round(newPos)
}
var debounce = function(callback, wait) {
var timeout
return function() {
@ -278,6 +298,14 @@ var vm = new Vue({
return this.itemSelectedDetails.content || ''
},
contentImages: function() {
if (!this.itemSelectedDetails) return []
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'image')
},
contentAudios: function() {
if (!this.itemSelectedDetails) return []
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'audio')
}
},
watch: {
'theme': {
@ -407,7 +435,7 @@ var vm = new Vue({
vm.feeds = values[1]
})
},
refreshItems: function(loadMore) {
refreshItems: function(loadMore = false) {
if (this.feedSelected === null) {
vm.items = []
vm.itemsHasMore = false
@ -420,7 +448,7 @@ var vm = new Vue({
}
this.loading.items = true
api.items.list(query).then(function(data) {
return api.items.list(query).then(function(data) {
if (loadMore) {
vm.items = vm.items.concat(data.list)
} else {
@ -443,13 +471,17 @@ var vm = new Vue({
var scale = (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) / 16
var el = this.$refs.itemlist
if (el.scrollHeight === 0) return false // element is invisible (responsive design)
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < bottomSpace * scale
return closeToBottom
},
loadMoreItems: function(event, el) {
if (!this.itemsHasMore) return
if (this.loading.items) return
if (this.itemListCloseToBottom()) this.refreshItems(true)
if (this.itemListCloseToBottom()) return this.refreshItems(true)
if (this.itemSelected && this.itemSelected === this.items[this.items.length - 1].id) return this.refreshItems(true)
},
markItemsRead: function() {
var query = this.getItemsQuery()
@ -683,6 +715,65 @@ var vm = new Vue({
this.filteredFolderStats = statsFolders
this.filteredTotalStats = statsTotal
},
// navigation helper, navigate relative to selected item
navigateToItem: function(relativePosition) {
let vm = this
if (vm.itemSelected == null) {
// if no item is selected, select first
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var itemPosition = vm.items.findIndex(function(x) { return x.id === vm.itemSelected })
if (itemPosition === -1) {
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var newPosition = itemPosition + relativePosition
if (newPosition < 0 || newPosition >= vm.items.length) return
vm.itemSelected = vm.items[newPosition].id
vm.$nextTick(function() {
var scroll = document.querySelector('#item-list-scroll')
var handle = scroll.querySelector('input[type=radio]:checked')
var target = handle && handle.parentElement
if (target && scroll) scrollto(target, scroll)
vm.loadMoreItems()
})
},
// navigation helper, navigate relative to selected feed
navigateToFeed: function(relativePosition) {
let vm = this
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
.map(function(r) { return r.value })
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
if (currentFeedPosition == -1) {
vm.feedSelected = ''
return
}
var newPosition = currentFeedPosition+relativePosition
if (newPosition < 0 || newPosition >= navigationList.length) return
vm.feedSelected = navigationList[newPosition]
vm.$nextTick(function() {
var scroll = document.querySelector('#feed-list-scroll')
var handle = scroll.querySelector('input[type=radio]:checked')
var target = handle && handle.parentElement
if (target && scroll) scrollto(target, scroll)
})
},
}
})

View File

@ -1,79 +1,4 @@
function scrollto(target, scroll) {
var padding = 10
var targetRect = target.getBoundingClientRect()
var scrollRect = scroll.getBoundingClientRect()
// target
var relativeOffset = targetRect.y - scrollRect.y
var absoluteOffset = relativeOffset + scroll.scrollTop
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
var newPos = scroll.scrollTop
if (relativeOffset < padding) {
newPos = absoluteOffset - padding
} else {
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
}
scroll.scrollTop = Math.round(newPos)
}
var helperFunctions = {
// navigation helper, navigate relative to selected item
navigateToItem: function(relativePosition) {
if (vm.itemSelected == null) {
// if no item is selected, select first
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var itemPosition = vm.items.findIndex(function(x) { return x.id === vm.itemSelected })
if (itemPosition === -1) {
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
return
}
var newPosition = itemPosition + relativePosition
if (newPosition < 0 || newPosition >= vm.items.length) return
vm.itemSelected = vm.items[newPosition].id
vm.$nextTick(function() {
var scroll = document.querySelector('#item-list-scroll')
var handle = scroll.querySelector('input[type=radio]:checked')
var target = handle && handle.parentElement
if (target && scroll) scrollto(target, scroll)
})
},
// navigation helper, navigate relative to selected feed
navigateToFeed: function(relativePosition) {
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
.map(function(r) { return r.value })
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
if (currentFeedPosition == -1) {
vm.feedSelected = ''
return
}
var newPosition = currentFeedPosition+relativePosition
if (newPosition < 0 || newPosition >= navigationList.length) return
vm.feedSelected = navigationList[newPosition]
vm.$nextTick(function() {
var scroll = document.querySelector('#feed-list-scroll')
var handle = scroll.querySelector('input[type=radio]:checked')
var target = handle && handle.parentElement
if (target && scroll) scrollto(target, scroll)
})
},
scrollContent: function(direction) {
var padding = 40
var scroll = document.querySelector('.content')
@ -92,7 +17,7 @@ var helperFunctions = {
var shortcutFunctions = {
openItemLink: function() {
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
window.open(vm.itemSelectedDetails.link, '_blank')
window.open(vm.itemSelectedDetails.link, '_blank', 'noopener,noreferrer')
}
},
toggleReadability: function() {
@ -118,16 +43,16 @@ var shortcutFunctions = {
document.getElementById("searchbar").focus()
},
nextItem(){
helperFunctions.navigateToItem(+1)
vm.navigateToItem(+1)
},
previousItem() {
helperFunctions.navigateToItem(-1)
vm.navigateToItem(-1)
},
nextFeed(){
helperFunctions.navigateToFeed(+1)
vm.navigateToFeed(+1)
},
previousFeed() {
helperFunctions.navigateToFeed(-1)
vm.navigateToFeed(-1)
},
scrollForward: function() {
helperFunctions.scrollContent(+1)

View File

@ -4,6 +4,7 @@ import (
"bytes"
"regexp"
"strings"
"unicode"
"golang.org/x/net/html"
)
@ -61,3 +62,16 @@ func ExtractText(content string) string {
text = whitespaceRegex.ReplaceAllLiteralString(text, " ")
return text
}
func TruncateText(input string, size int) string {
runes := []rune(input)
if len(runes) <= size {
return input
}
for i := size - 1; i > 0; i-- {
if unicode.IsSpace(runes[i]) {
return string(runes[:i]) + " ..."
}
}
return input
}

View File

@ -24,3 +24,21 @@ func TestExtractText(t *testing.T) {
}
}
}
func TestTruncateText(t *testing.T) {
input := "Lorem ipsum — классический текст-«рыба»"
size := 30
want := "Lorem ipsum — классический ..."
have := TruncateText(input, size)
if want != have {
t.Errorf("\nsize: %d\nwant: %#v\nhave: %#v", size, want, have)
}
size = 1000
want = input
have = TruncateText(input, size)
if want != have {
t.Errorf("\nsize: %d\nwant: %#v\nhave: %#v", size, want, have)
}
}

View File

@ -167,7 +167,7 @@ func getExtraAttributes(tagName string) ([]string, []string) {
case "iframe":
return []string{"sandbox", "loading"}, []string{`sandbox="allow-scripts allow-same-origin allow-popups"`, `loading="lazy"`}
case "img":
return []string{"loading"}, []string{`loading="lazy"`}
return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
default:
return nil, nil
}

View File

@ -89,15 +89,16 @@ func ParseAtom(r io.Reader) (*Feed, error) {
guidFromID = srcitem.ID + "::" + srcitem.Updated
}
mediaLinks := srcitem.mediaLinks()
link := firstNonEmpty(srcitem.OrigLink, srcitem.Links.First("alternate"), srcitem.Links.First(""), linkFromID)
dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
URL: link,
Title: srcitem.Title.Text(),
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
ImageURL: srcitem.firstMediaThumbnail(),
AudioURL: "",
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
URL: link,
Title: srcitem.Title.Text(),
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
MediaLinks: mediaLinks,
})
}
return dstfeed, nil

View File

@ -40,13 +40,11 @@ func TestAtom(t *testing.T) {
SiteURL: "http://example.org/",
Items: []Item{
{
GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
Date: time.Unix(1071340202, 0).UTC(),
URL: "http://example.org/2003/12/13/atom03.html",
Title: "Atom-Powered Robots Run Amok",
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
ImageURL: "",
AudioURL: "",
GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
Date: time.Unix(1071340202, 0).UTC(),
URL: "http://example.org/2003/12/13/atom03.html",
Title: "Atom-Powered Robots Run Amok",
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
},
},
}
@ -141,9 +139,15 @@ func TestAtomImageLink(t *testing.T) {
</entry>
</feed>
`))
have := feed.Items[0].ImageURL
want := `https://example.com/image.png?width=100&height=100`
if want != have {
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatalf("Expected 1 media link, got: %#v", feed.Items[0].MediaLinks)
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: `https://example.com/image.png?width=100&height=100`,
Type: "image",
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("item.image_url doesn't match\nwant: %#v\nhave: %#v\n", want, have)
}
}
@ -165,8 +169,8 @@ func TestAtomImageLinkDuplicated(t *testing.T) {
if want != have {
t.Fatalf("want: %#v\nhave: %#v\n", want, have)
}
if feed.Items[0].ImageURL != "" {
t.Fatal("item.image_url must be unset if present in the content")
if len(feed.Items[0].MediaLinks) != 0 {
t.Fatal("item media link must be excluded if present in the content")
}
}
@ -192,23 +196,23 @@ func TestAtomLinkInID(t *testing.T) {
have := feed.Items
want := []Item{
Item{
GUID: "https://example.com/posts/1::2003-12-13T09:17:51",
Date: time.Date(2003, time.December, 13, 9, 17, 51, 0, time.UTC),
URL: "https://example.com/posts/1",
Title: "one updated",
},
GUID: "https://example.com/posts/1::2003-12-13T09:17:51",
Date: time.Date(2003, time.December, 13, 9, 17, 51, 0, time.UTC),
URL: "https://example.com/posts/1",
Title: "one updated",
},
Item{
GUID: "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6",
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), URL: "",
Title: "two",
},
Item{
GUID: "https://example.com/posts/1::",
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
URL: "https://example.com/posts/1",
Title: "one",
Content: "",
},
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), URL: "",
Title: "two",
},
Item{
GUID: "https://example.com/posts/1::",
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
URL: "https://example.com/posts/1",
Title: "one",
Content: "",
},
}
if !reflect.DeepEqual(want, have) {
t.Fatalf("\nwant: %#v\nhave: %#v\n", want, have)

View File

@ -2,6 +2,7 @@ package parser
import (
"bytes"
"crypto/sha256"
"encoding/xml"
"errors"
"fmt"
@ -119,6 +120,7 @@ func ParseAndFix(r io.Reader, baseURL, fallbackEncoding string) (*Feed, error) {
}
feed.TranslateURLs(baseURL)
feed.SetMissingDatesTo(time.Now())
feed.SetMissingGUIDs()
return feed, nil
}
@ -132,11 +134,14 @@ func (feed *Feed) cleanup() {
feed.Items[i].Title = strings.TrimSpace(htmlutil.ExtractText(item.Title))
feed.Items[i].Content = strings.TrimSpace(item.Content)
if item.ImageURL != "" && strings.Contains(item.Content, item.ImageURL) {
feed.Items[i].ImageURL = ""
}
if item.AudioURL != "" && strings.Contains(item.Content, item.AudioURL) {
feed.Items[i].AudioURL = ""
if len(feed.Items[i].MediaLinks) > 0 {
mediaLinks := make([]MediaLink, 0)
for _, link := range item.MediaLinks {
if !strings.Contains(item.Content, link.URL) {
mediaLinks = append(mediaLinks, link)
}
}
feed.Items[i].MediaLinks = mediaLinks
}
}
}
@ -168,3 +173,12 @@ func (feed *Feed) TranslateURLs(base string) error {
}
return nil
}
func (feed *Feed) SetMissingGUIDs() {
for i, item := range feed.Items {
if item.GUID == "" {
id := strings.Join([]string{item.Title, item.Date.Format(time.RFC3339), item.URL}, ";;")
feed.Items[i].GUID = fmt.Sprintf("%x", sha256.Sum256([]byte(id)))
}
}
}

View File

@ -150,3 +150,32 @@ func TestParseCleanIllegalCharsInNonUTF8(t *testing.T) {
t.Fatalf("invalid feed, got: %v", feed)
}
}
func TestParseMissingGUID(t *testing.T) {
data := `
<?xml version="1.0" encoding="windows-1251"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<item>
<title>foo</title>
</item>
<item>
<title>bar</title>
</item>
</channel>
</rss>
`
feed, err := ParseAndFix(strings.NewReader(data), "", "")
if err != nil {
t.Fatal(err)
}
if len(feed.Items) != 2 {
t.Fatalf("expected 2 items, got %d", len(feed.Items))
}
if feed.Items[0].GUID == "" || feed.Items[1].GUID == "" {
t.Fatalf("item GUIDs are missing, got %#v", feed.Items)
}
if feed.Items[0].GUID == feed.Items[1].GUID {
t.Fatalf("item GUIDs are not unique, got %#v", feed.Items)
}
}

View File

@ -1,5 +1,9 @@
package parser
import (
"strings"
)
type media struct {
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
@ -8,12 +12,17 @@ type media struct {
}
type mediaGroup struct {
MediaContent []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
}
type mediaContent struct {
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaType string `xml:"type,attr"`
MediaMedium string `xml:"medium,attr"`
MediaURL string `xml:"url,attr"`
MediaDescription mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
}
type mediaThumbnail struct {
@ -21,8 +30,8 @@ type mediaThumbnail struct {
}
type mediaDescription struct {
Type string `xml:"type,attr"`
Description string `xml:",chardata"`
Type string `xml:"type,attr"`
Text string `xml:",chardata"`
}
func (m *media) firstMediaThumbnail() string {
@ -44,12 +53,67 @@ func (m *media) firstMediaThumbnail() string {
func (m *media) firstMediaDescription() string {
for _, d := range m.MediaDescriptions {
return plain2html(d.Description)
return plain2html(d.Text)
}
for _, g := range m.MediaGroups {
for _, d := range g.MediaDescriptions {
return plain2html(d.Description)
return plain2html(d.Text)
}
}
return ""
}
func (m *media) mediaLinks() []MediaLink {
links := make([]MediaLink, 0)
for _, thumbnail := range m.MediaThumbnails {
links = append(links, MediaLink{URL: thumbnail.URL, Type: "image"})
}
for _, group := range m.MediaGroups {
for _, thumbnail := range group.MediaThumbnails {
links = append(links, MediaLink{
URL: thumbnail.URL,
Type: "image",
})
}
}
for _, content := range m.MediaContents {
if content.MediaURL != "" {
if content.MediaMedium == "image" || strings.HasPrefix(content.MediaType, "image/") {
links = append(links, MediaLink{
URL: content.MediaURL,
Type: "image",
Description: content.MediaDescription.Text,
})
} else if content.MediaMedium == "audio" || strings.HasPrefix(content.MediaType, "audio/") {
links = append(links, MediaLink{
URL: content.MediaURL,
Type: "audio",
Description: content.MediaDescription.Text,
})
} else if content.MediaMedium == "video" || strings.HasPrefix(content.MediaType, "video/") {
links = append(links, MediaLink{
URL: content.MediaURL,
Type: "video",
Description: content.MediaDescription.Text,
})
} else {
if len(content.MediaThumbnails) > 0 {
links = append(links, MediaLink{
URL: content.MediaThumbnails[0].URL,
Type: "image",
})
}
}
}
for _, thumbnail := range content.MediaThumbnails {
links = append(links, MediaLink{
URL: thumbnail.URL,
Type: "image",
})
}
}
if len(links) == 0 {
return nil
}
return links
}

View File

@ -14,7 +14,12 @@ type Item struct {
URL string
Title string
Content string
ImageURL string
AudioURL string
Content string
MediaLinks []MediaLink
}
type MediaLink struct {
URL string
Type string
Description string
}

View File

@ -74,14 +74,14 @@ func ParseRSS(r io.Reader) (*Feed, error) {
SiteURL: srcfeed.Link,
}
for _, srcitem := range srcfeed.Items {
podcastURL := ""
mediaLinks := srcitem.mediaLinks()
for _, e := range srcitem.Enclosures {
if strings.HasPrefix(e.Type, "audio/") {
podcastURL = e.URL
podcastURL := e.URL
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
podcastURL = srcitem.OrigEnclosureLink
}
mediaLinks = append(mediaLinks, MediaLink{URL: podcastURL, Type: "audio"})
break
}
}
@ -92,13 +92,12 @@ func ParseRSS(r io.Reader) (*Feed, error) {
}
dstfeed.Items = append(dstfeed.Items, Item{
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
Title: srcitem.Title,
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
AudioURL: podcastURL,
ImageURL: srcitem.firstMediaThumbnail(),
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
Title: srcitem.Title,
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description, srcitem.firstMediaDescription()),
MediaLinks: mediaLinks,
})
}
return dstfeed, nil

View File

@ -75,9 +75,15 @@ func TestRSSMediaContentThumbnail(t *testing.T) {
</channel>
</rss>
`))
have := feed.Items[0].ImageURL
want := "https://i.vimeocdn.com/video/1092705247_960.jpg"
if have != want {
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatalf("Expected 1 media link, got %#v", feed.Items[0].MediaLinks)
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: "https://i.vimeocdn.com/video/1092705247_960.jpg",
Type: "image",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
@ -127,9 +133,15 @@ func TestRSSPodcast(t *testing.T) {
</channel>
</rss>
`))
have := feed.Items[0].AudioURL
want := "http://example.com/audio.ext"
if want != have {
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatal("Invalid media links")
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: "http://example.com/audio.ext",
Type: "audio",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
@ -147,9 +159,15 @@ func TestRSSOpusPodcast(t *testing.T) {
</channel>
</rss>
`))
have := feed.Items[0].AudioURL
want := "http://example.com/audio.ext"
if want != have {
if len(feed.Items[0].MediaLinks) != 1 {
t.Fatal("Invalid media links")
}
have := feed.Items[0].MediaLinks[0]
want := MediaLink{
URL: "http://example.com/audio.ext",
Type: "audio",
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.FailNow()
@ -176,8 +194,9 @@ func TestRSSPodcastDuplicated(t *testing.T) {
if want != have {
t.Fatalf("content doesn't match\nwant: %#v\nhave: %#v\n", want, have)
}
if feed.Items[0].AudioURL != "" {
t.Fatal("item.audio_url must be unset if present in the content")
if len(feed.Items[0].MediaLinks) != 0 {
t.Fatal("item media must be excluded if present in the content")
}
}
@ -223,8 +242,43 @@ func TestRSSIsPermalink(t *testing.T) {
},
}
for i := 0; i < len(want); i++ {
if want[i] != have[i] {
if !reflect.DeepEqual(want, have) {
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
}
}
}
func TestRSSMultipleMedia(t *testing.T) {
feed, _ := Parse(strings.NewReader(`
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<item>
<guid isPermaLink="true">http://example.com/posts/1</guid>
<media:content url="https://example.com/path/to/image1.png" type="image/png" fileSize="1000" medium="image">
<media:description type="plain">description 1</media:description>
</media:content>
<media:content url="https://example.com/path/to/image2.png" type="image/png" fileSize="2000" medium="image">
<media:description type="plain">description 2</media:description>
</media:content>
</item>
</channel>
</rss>
`))
have := feed.Items
want := []Item{
{
GUID: "http://example.com/posts/1",
URL: "http://example.com/posts/1",
MediaLinks: []MediaLink{
{URL:"https://example.com/path/to/image1.png", Type:"image", Description:"description 1"},
{URL:"https://example.com/path/to/image2.png", Type:"image", Description:"description 2"},
},
},
}
if !reflect.DeepEqual(want, have) {
t.Logf("want: %#v", want)
t.Logf("have: %#v", have)
t.Fatal("invalid rss")
}
}

View File

@ -51,12 +51,12 @@ func (m *Middleware) Handler(c *router.Context) {
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
"username": username,
"error": "Invalid username/password",
"settings": m.DB.GetSettings(),
"settings": m.DB.GetSettings(),
})
return
}
}
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
"settings": m.DB.GetSettings(),
})
"settings": m.DB.GetSettings(),
})
}

View File

@ -35,7 +35,7 @@ func (s *Server) handler() http.Handler {
Username: s.Username,
Password: s.Password,
Public: []string{"/static", "/fever"},
DB: s.db,
DB: s.db,
}
r.Use(a.Handler)
}
@ -329,6 +329,9 @@ func (s *Server) handleItem(c *router.Context) {
}
item.Content = sanitizer.Sanitize(item.Link, item.Content)
for i, link := range item.MediaLinks {
item.MediaLinks[i].Description = sanitizer.Sanitize(item.Link, link.Description)
}
c.JSON(http.StatusOK, item)
} else if c.Req.Method == "PUT" {
@ -371,12 +374,19 @@ func (s *Server) handleItemList(c *router.Context) {
}
newestFirst := query.Get("oldest_first") != "true"
items := s.db.ListItems(filter, perPage+1, newestFirst, false)
items := s.db.ListItems(filter, perPage+1, newestFirst, true)
hasMore := false
if len(items) == perPage+1 {
hasMore = true
items = items[:perPage]
}
for i, item := range items {
if item.Title == "" {
text := htmlutil.ExtractText(item.Content)
items[i].Title = htmlutil.TruncateText(text, 140)
}
}
c.JSON(http.StatusOK, map[string]interface{}{
"list": items,
"has_more": hasMore,

View File

@ -1,6 +1,7 @@
package storage
import (
"database/sql/driver"
"encoding/json"
"fmt"
"log"
@ -44,17 +45,35 @@ func (s *ItemStatus) UnmarshalJSON(b []byte) error {
return nil
}
type MediaLink struct {
URL string `json:"url"`
Type string `json:"type"`
Description string `json:"description,omitempty"`
}
type MediaLinks []MediaLink
func (m *MediaLinks) Scan(src any) error {
if data, ok := src.([]byte); ok {
return json.Unmarshal(data, m)
}
return nil
}
func (m MediaLinks) Value() (driver.Value, error) {
return json.Marshal(m)
}
type Item struct {
Id int64 `json:"id"`
GUID string `json:"guid"`
FeedId int64 `json:"feed_id"`
Title string `json:"title"`
Link string `json:"link"`
Content string `json:"content,omitempty"`
Date time.Time `json:"date"`
Status ItemStatus `json:"status"`
ImageURL *string `json:"image"`
AudioURL *string `json:"podcast_url"`
Id int64 `json:"id"`
GUID string `json:"guid"`
FeedId int64 `json:"feed_id"`
Title string `json:"title"`
Link string `json:"link"`
Content string `json:"content,omitempty"`
Date time.Time `json:"date"`
Status ItemStatus `json:"status"`
MediaLinks MediaLinks `json:"media_links"`
}
type ItemFilter struct {
@ -79,22 +98,21 @@ type MarkFilter struct {
type ItemList []Item
func (list ItemList) Len() int {
return len(list)
return len(list)
}
func (list ItemList) SortKey(i int) string {
return list[i].Date.Format(time.RFC3339) + "::" + list[i].GUID
return list[i].Date.Format(time.RFC3339) + "::" + list[i].GUID
}
func (list ItemList) Less(i, j int) bool {
return list.SortKey(i) < list.SortKey(j)
return list.SortKey(i) < list.SortKey(j)
}
func (list ItemList) Swap(i, j int) {
list[i], list[j] = list[j], list[i]
list[i], list[j] = list[j], list[i]
}
func (s *Storage) CreateItems(items []Item) bool {
tx, err := s.db.Begin()
if err != nil {
@ -104,20 +122,24 @@ func (s *Storage) CreateItems(items []Item) bool {
now := time.Now().UTC()
itemsSorted := ItemList(items)
sort.Sort(itemsSorted)
itemsSorted := ItemList(items)
sort.Sort(itemsSorted)
for _, item := range itemsSorted {
_, err = tx.Exec(`
insert into items (
guid, feed_id, title, link, date,
content, image, podcast_url,
content, media_links,
date_arrived, status
)
values (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', ?), ?, ?, ?, ?, ?)
values (
?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', ?),
?, ?,
?, ?
)
on conflict (feed_id, guid) do nothing`,
item.GUID, item.FeedId, item.Title, item.Link, item.Date,
item.Content, item.ImageURL, item.AudioURL,
item.Content, item.MediaLinks,
now, UNREAD,
)
if err != nil {
@ -232,7 +254,7 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool, with
order = "i.id desc"
}
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.image, i.podcast_url"
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.media_links"
if withContent {
selectCols += ", i.content"
} else {
@ -255,7 +277,7 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool, with
err = rows.Scan(
&x.Id, &x.GUID, &x.FeedId,
&x.Title, &x.Link, &x.Date,
&x.Status, &x.ImageURL, &x.AudioURL, &x.Content,
&x.Status, &x.MediaLinks, &x.Content,
)
if err != nil {
log.Print(err)
@ -271,12 +293,12 @@ func (s *Storage) GetItem(id int64) *Item {
err := s.db.QueryRow(`
select
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
i.date, i.status, i.image, i.podcast_url
i.date, i.status, i.media_links
from items i
where i.id = ?
`, id).Scan(
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
&i.Date, &i.Status, &i.ImageURL, &i.AudioURL,
&i.Date, &i.Status, &i.MediaLinks,
)
if err != nil {
log.Print(err)

View File

@ -77,12 +77,12 @@ func getItem(db *Storage, guid string) *Item {
err := db.db.QueryRow(`
select
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
i.date, i.status, i.image, i.podcast_url
i.date, i.status, i.media_links
from items i
where i.guid = ?
`, guid).Scan(
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
&i.Date, &i.Status, &i.ImageURL, &i.AudioURL,
&i.Date, &i.Status, &i.MediaLinks,
)
if err != nil {
log.Fatal(err)

View File

@ -16,16 +16,17 @@ var migrations = []func(*sql.Tx) error{
m06_fill_missing_dates,
m07_add_feed_size,
m08_normalize_datetime,
m09_change_item_index,
m09_change_item_index,
m10_add_item_medialinks,
}
var maxVersion = int64(len(migrations))
func migrate(db *sql.DB) error {
var version int64
if err := db.QueryRow("pragma user_version").Scan(&version); err != nil {
return err
}
if err := db.QueryRow("pragma user_version").Scan(&version); err != nil {
return err
}
if version >= maxVersion {
return nil
@ -306,3 +307,28 @@ func m09_change_item_index(tx *sql.Tx) error {
_, err := tx.Exec(sql)
return err
}
func m10_add_item_medialinks(tx *sql.Tx) error {
sql := `
alter table items add column media_links blob;
update items set media_links =
iif(
coalesce(image, '') != '' and coalesce(podcast_url, '') != '',
json_array(json_object('type', 'image', 'url', image), json_object('type', 'audio', 'url', podcast_url)),
iif(
coalesce(image, '') != '',
json_array(json_object('type', 'image', 'url', image)),
iif(
coalesce(podcast_url, '') != '',
json_array(json_object('type', 'audio', 'url', podcast_url)),
null
)
)
);
alter table items drop column image;
alter table items drop column podcast_url;
`
_, err := tx.Exec(sql)
return err
}

View File

@ -14,10 +14,10 @@ type Storage struct {
func New(path string) (*Storage, error) {
if pos := strings.IndexRune(path, '?'); pos == -1 {
params := "_journal=WAL&_sync=NORMAL&_busy_timeout=5000&cache=shared"
log.Printf("opening db with params: %s", params)
path = path + "?" + params
}
params := "_journal=WAL&_sync=NORMAL&_busy_timeout=5000&cache=shared"
log.Printf("opening db with params: %s", params)
path = path + "?" + params
}
db, err := sql.Open("sqlite3", path)
if err != nil {

View File

@ -32,6 +32,10 @@ func (c *Client) getConditional(url, lastModified, etag string) (*http.Response,
var client *Client
func SetVersion(num string) {
client.userAgent = "Yarr/" + num
}
func init() {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,

View File

@ -143,24 +143,19 @@ func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
result := make([]storage.Item, len(items))
for i, item := range items {
item := item
var audioURL *string = nil
if item.AudioURL != "" {
audioURL = &item.AudioURL
}
var imageURL *string = nil
if item.ImageURL != "" {
imageURL = &item.ImageURL
mediaLinks := make(storage.MediaLinks, 0)
for _, link := range item.MediaLinks {
mediaLinks = append(mediaLinks, storage.MediaLink(link))
}
result[i] = storage.Item{
GUID: item.GUID,
FeedId: feed.Id,
Title: item.Title,
Link: item.URL,
Content: item.Content,
Date: item.Date,
Status: storage.UNREAD,
ImageURL: imageURL,
AudioURL: audioURL,
GUID: item.GUID,
FeedId: feed.Id,
Title: item.Title,
Link: item.URL,
Content: item.Content,
Date: item.Date,
Status: storage.UNREAD,
MediaLinks: mediaLinks,
}
}
return result