'use strict'; var TITLE = document.title var FONTS = [ "Arial", "Courier New", "Georgia", "Times New Roman", "Verdana", ] var debounce = function(callback, wait) { var timeout return function() { var ctx = this, args = arguments clearTimeout(timeout) timeout = setTimeout(function() { callback.apply(ctx, args) }, wait) } } var sanitize = function(content, base) { // WILD: `item.link` is not always a valid url try { new URL(base) } catch(err) { base = null } var sanitizer = new DOMPurify sanitizer.addHook('afterSanitizeAttributes', function(node) { // set all elements owning target to target=_blank if ('target' in node) node.setAttribute('target', '_blank') // set non-HTML/MathML links to xlink:show=new if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) node.setAttribute('xlink:show', 'new') // set absolute urls if (base && node.attributes.href && node.attributes.href.value) node.href = new URL(node.attributes.href.value, base).toString() if (base && node.attributes.src && node.attributes.src.value) node.src = new URL(node.attributes.src.value, base).toString() }) return sanitizer.sanitize(content, {FORBID_TAGS: ['style'], FORBID_ATTR: ['style', 'class']}) } Vue.directive('scroll', { inserted: function(el, binding) { el.addEventListener('scroll', debounce(function(event) { binding.value(event, el) }, 200)) }, }) Vue.component('drag', { props: ['width'], template: '
', mounted: function() { var self = this var startX = undefined var initW = undefined var onMouseMove = function(e) { var offset = e.clientX - startX var newWidth = initW + offset self.$emit('resize', newWidth) } var onMouseUp = function(e) { document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseup', onMouseUp) } this.$el.addEventListener('mousedown', function(e) { startX = e.clientX initW = self.width document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) }) }, }) function dateRepr(d) { var sec = (new Date().getTime() - d.getTime()) / 1000 if (sec < 2700) // less than 45 minutes return Math.round(sec / 60) + 'm' else if (sec < 86400) // less than 24 hours return Math.round(sec / 3600) + 'h' else if (sec < 604800) // less than a week return Math.round(sec / 86400) + 'd' else return d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"}) } Vue.component('relative-time', { props: ['val'], data: function() { var d = new Date(this.val) return { 'date': d, 'formatted': dateRepr(d), 'interval': null, } }, template: '', mounted: function() { this.interval = setInterval(function() { this.formatted = dateRepr(this.date) }.bind(this), 600000) // every 10 minutes }, destroyed: function() { clearInterval(this.interval) }, }) var vm = new Vue({ el: '#app', created: function() { api.settings.get().then(function(data) { vm.feedSelected = data.feed vm.filterSelected = data.filter vm.itemSortNewestFirst = data.sort_newest_first vm.feedListWidth = data.feed_list_width || 300 vm.itemListWidth = data.item_list_width || 300 vm.theme.name = data.theme_name vm.theme.font = data.theme_font vm.theme.size = data.theme_size vm.refreshItems() }) this.refreshFeeds() this.refreshStats() }, data: function() { return { 'filterSelected': null, 'folders': [], 'feeds': [], 'feedSelected': null, 'feedListWidth': null, 'feedNewChoice': [], 'feedNewChoiceSelected': '', 'items': [], 'itemsPage': { 'cur': 1, 'num': 1, }, 'itemSelected': null, 'itemSelectedDetails': {}, 'itemSelectedReadability': '', 'itemSearch': '', 'itemSortNewestFirst': null, 'itemListWidth': null, 'filteredFeedStats': {}, 'filteredFolderStats': {}, 'filteredTotalStats': null, 'settings': 'create', 'loading': { 'feeds': 0, 'newfeed': false, 'items': false, 'readability': false, }, 'fonts': FONTS, 'feedStats': {}, 'theme': { 'name': 'light', 'font': '', 'size': 1, }, } }, computed: { foldersWithFeeds: function() { var feedsByFolders = this.feeds.reduce(function(folders, feed) { if (!folders[feed.folder_id]) folders[feed.folder_id] = [feed] else folders[feed.folder_id].push(feed) return folders }, {}) var folders = this.folders.slice().map(function(folder) { folder.feeds = feedsByFolders[folder.id] return folder }) folders.push({id: null, feeds: feedsByFolders[null]}) return folders }, feedsById: function() { return this.feeds.reduce(function(acc, feed) { acc[feed.id] = feed; return acc }, {}) }, itemsById: function() { return this.items.reduce(function(acc, item) { acc[item.id] = item; return acc }, {}) }, itemSelectedContent: function() { if (!this.itemSelected) return '' if (this.itemSelectedReadability) return this.itemSelectedReadability var content = '' if (this.itemSelectedDetails.content) content = this.itemSelectedDetails.content else if (this.itemSelectedDetails.description) content = this.itemSelectedDetails.description return sanitize(content, this.itemSelectedDetails.link) }, }, watch: { 'theme': { deep: true, handler: function(theme) { document.body.classList.value = 'theme-' + theme.name api.settings.update({ theme_name: theme.name, theme_font: theme.font, theme_size: theme.size, }) }, }, 'feedStats': { deep: true, handler: debounce(function() { var title = TITLE var unreadCount = Object.values(this.feedStats).reduce(function(acc, stat) { return acc + stat.unread }, 0) if (unreadCount) { title += ' ('+unreadCount+')' } document.title = title this.computeStats() }, 500), }, 'filterSelected': function(newVal, oldVal) { if (oldVal === null) return // do nothing, initial setup api.settings.update({filter: newVal}).then(this.refreshItems.bind(this)) this.itemSelected = null this.computeStats() }, 'feedSelected': function(newVal, oldVal) { if (oldVal === null) return // do nothing, initial setup api.settings.update({feed: newVal}).then(this.refreshItems.bind(this)) this.itemSelected = null if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0 }, 'itemSelected': function(newVal, oldVal) { this.itemSelectedReadability = '' if (newVal === null) { this.itemSelectedDetails = null return } if (this.$refs.content) this.$refs.content.scrollTop = 0 this.itemSelectedDetails = this.itemsById[newVal] if (this.itemSelectedDetails.status == 'unread') { this.itemSelectedDetails.status = 'read' this.feedStats[this.itemSelectedDetails.feed_id].unread -= 1 api.items.update(this.itemSelectedDetails.id, {status: this.itemSelectedDetails.status}) } }, 'itemSearch': debounce(function(newVal) { this.refreshItems() }, 500), 'itemSortNewestFirst': function(newVal, oldVal) { if (oldVal === null) return api.settings.update({sort_newest_first: newVal}).then(this.refreshItems.bind(this)) }, 'feedListWidth': debounce(function(newVal, oldVal) { if (oldVal === null) return api.settings.update({feed_list_width: newVal}) }, 1000), 'itemListWidth': debounce(function(newVal, oldVal) { if (oldVal === null) return api.settings.update({item_list_width: newVal}) }, 1000), }, methods: { refreshStats: function() { api.status().then(function(data) { vm.loading.feeds = data.running if (data.running) { setTimeout(vm.refreshStats.bind(vm), 500) } vm.feedStats = data.stats.reduce(function(acc, stat) { acc[stat.feed_id] = stat return acc }, {}) }) }, getItemsQuery: function() { var query = {} if (this.feedSelected) { var parts = this.feedSelected.split(':', 2) var type = parts[0] var guid = parts[1] if (type == 'feed') { query.feed_id = guid } else if (type == 'folder') { query.folder_id = guid } } if (this.filterSelected) { query.status = this.filterSelected } if (this.itemSearch) { query.search = this.itemSearch } if (!this.itemSortNewestFirst) { query.oldest_first = true } return query }, refreshFeeds: function() { return Promise .all([api.folders.list(), api.feeds.list()]) .then(function(values) { vm.folders = values[0] vm.feeds = values[1] }) }, refreshItems: function() { var query = this.getItemsQuery() this.loading.items = true api.items.list(query).then(function(data) { vm.items = data.list vm.itemsPage = data.page vm.loading.items = false }) }, loadMoreItems: function(event, el) { if (this.itemsPage.cur >= this.itemsPage.num) return if (this.loading.items) return var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50 if (closeToBottom) { this.loading.moreitems = true var query = this.getItemsQuery() query.page = this.itemsPage.cur + 1 api.items.list(query).then(function(data) { vm.items = vm.items.concat(data.list) vm.itemsPage = data.page vm.loading.items = false }) } }, markItemsRead: function() { var query = this.getItemsQuery() api.items.mark_read(query).then(function() { vm.items = [] vm.itemsPage = {'cur': 1, 'num': 1} vm.refreshStats() }) }, toggleFolderExpanded: function(folder) { folder.is_expanded = !folder.is_expanded api.folders.update(folder.id, {is_expanded: folder.is_expanded}) }, formatDate: function(datestr) { var options = { year: "numeric", month: "long", day: "numeric", hour: '2-digit', minute: '2-digit', } return new Date(datestr).toLocaleDateString(undefined, options) }, moveFeed: function(feed, folder) { var folder_id = folder ? folder.id : null api.feeds.update(feed.id, {folder_id: folder_id}).then(function() { feed.folder_id = folder_id }) }, moveFeedToNewFolder: function(feed) { var title = prompt('Enter folder name:') if (!title) return api.folders.create({'title': title}).then(function(folder) { api.feeds.update(feed.id, {folder_id: folder.id}).then(function() { vm.refreshFeeds() }) }) }, createNewFeedFolder: function() { var title = prompt('Enter folder name:') if (!title) return api.folders.create({'title': title}).then(function(result) { vm.refreshFeeds().then(function() { vm.$nextTick(function() { if (vm.$refs.newFeedFolder) { vm.$refs.newFeedFolder.value = result.id } }) }) }) }, renameFolder: function(folder) { var newTitle = prompt('Enter new title', folder.title) if (newTitle) { api.folders.update(folder.id, {title: newTitle}).then(function() { folder.title = newTitle }) } }, deleteFolder: function(folder) { if (confirm('Are you sure you want to delete ' + folder.title + '?')) { api.folders.delete(folder.id).then(function() { if (vm.feedSelected === 'folder:'+folder.id) { vm.items = [] vm.feedSelected = '' } vm.refreshStats() vm.refreshFeeds() }) } }, renameFeed: function(feed) { var newTitle = prompt('Enter new title', feed.title) if (newTitle) { api.feeds.update(feed.id, {title: newTitle}).then(function() { feed.title = newTitle }) } }, deleteFeed: function(feed) { if (confirm('Are you sure you want to delete ' + feed.title + '?')) { api.feeds.delete(feed.id).then(function() { if (vm.feedSelected === 'feed:'+feed.id) { vm.items = [] vm.feedSelected = '' } vm.refreshStats() vm.refreshFeeds() }) } }, createFeed: function(event) { var form = event.target var data = { url: form.querySelector('input[name=url]').value, folder_id: parseInt(form.querySelector('select[name=folder_id]').value) || null, } if (this.feedNewChoiceSelected) { data.url = this.feedNewChoiceSelected } this.loading.newfeed = true api.feeds.create(data).then(function(result) { if (result.status === 'success') { vm.refreshFeeds() vm.refreshStats() vm.$bvModal.hide('settings-modal') } else if (result.status === 'multiple') { vm.feedNewChoice = result.choice vm.feedNewChoiceSelected = result.choice[0].url } else { alert('No feeds found at the given url.') } vm.loading.newfeed = false }) }, toggleItemStarred: function(item) { if (item.status == 'starred') { item.status = 'read' this.feedStats[item.feed_id].starred -= 1 } else if (item.status != 'starred') { item.status = 'starred' this.feedStats[item.feed_id].starred += 1 } api.items.update(item.id, {status: item.status}) }, toggleItemRead: function(item) { if (item.status == 'unread') { item.status = 'read' this.feedStats[item.feed_id].unread -= 1 } else if (item.status == 'read') { item.status = 'unread' this.feedStats[item.feed_id].unread += 1 } api.items.update(item.id, {status: item.status}) }, importOPML: function(event) { var input = event.target var form = document.querySelector('#opml-import-form') this.$refs.menuDropdown.hide() api.upload_opml(form).then(function() { input.value = '' vm.refreshFeeds() vm.refreshStats() }) }, getReadable: function(item) { if (this.itemSelectedReadability) { this.itemSelectedReadability = null return } if (item.link) { this.loading.readability = true api.crawl(item.link).then(function(body) { vm.loading.readability = false if (!body.length) return var bodyClean = sanitize(body, item.link) var doc = new DOMParser().parseFromString(bodyClean, 'text/html') var parsed = new Readability(doc).parse() if (parsed && parsed.content) { vm.itemSelectedReadability = parsed.content } }) } }, showSettings: function(settings) { this.settings = settings this.$bvModal.show('settings-modal') }, resizeFeedList: function(width) { this.feedListWidth = Math.min(Math.max(200, width), 700) }, resizeItemList: function(width) { this.itemListWidth = Math.min(Math.max(200, width), 700) }, resetFeedChoice: function() { this.feedNewChoice = [] this.feedNewChoiceSelected = '' }, incrFont: function(x) { this.theme.size = +(this.theme.size + (0.1 * x)).toFixed(1) }, fetchAllFeeds: function() { api.feeds.refresh().then(this.refreshStats.bind(this)) }, computeStats: function() { var filter = this.filterSelected if (!filter) { this.filteredFeedStats = {} this.filteredFolderStats = {} this.filteredTotalStats = null return } var statsFeeds = {}, statsFolders = {}, statsTotal = 0 for (var i = 0; i < this.feeds.length; i++) { var feed = this.feeds[i] if (!this.feedStats[feed.id]) continue var n = vm.feedStats[feed.id][filter] || 0 if (!statsFolders[feed.folder_id]) statsFolders[feed.folder_id] = 0 statsFeeds[feed.id] = n statsFolders[feed.folder_id] += n statsTotal += n } this.filteredFeedStats = statsFeeds this.filteredFolderStats = statsFolders this.filteredTotalStats = statsTotal }, } })