Permalink
Browse files

Add support for marking a batch of entries as read

  • Loading branch information...
lovett committed Nov 8, 2018
1 parent fc4866e commit 6f69cea49b3b03cb3429e4065a014a8e531bf42e
@@ -0,0 +1,55 @@
/** @module entry/user-batch-update */
'use strict';

/**
* Callback for the entry-batch-update event.
*
* @callback entryBatchUpdateCallback
* @param {error} [err] - Database error.
*/

/**
* Batch-update a user's view of several entries.
*
* @param {Number} userId - The id of the user making the update.
* @param {Number[]} entryIds - A list of entry IDs.
* @param {Object} props - Keys reflect the schema of the userEntries table.
* @param {entryBatchUpdateCallback} callback - A function to invoke on success or failure.
* @event entry-user-batch-update
*/
module.exports = function (userId, entryIds, props, callback = () => {}) {
const self = this;

if (entryIds.length === 0) {
callback(null);
return;
}

const updatableFields = ['read', 'saved', 'score'];

const sqlPlaceholdersAndValues = Object.keys(props).reduce((accumulator, key) => {
if (updatableFields.indexOf(key) === -1) {
return accumulator;
}

accumulator.placeholders.push(`${key}=?`);
accumulator.values.push(props[key]);
return accumulator;
}, {placeholders: [], values: []});

const sqlEntryIdPlaceholders = entryIds.map(id => '?');

self.db.run(
`UPDATE userEntries SET ${sqlPlaceholdersAndValues.placeholders.join(',')}
WHERE userId=? AND entryId IN (${sqlEntryIdPlaceholders.join(',')})`,
sqlPlaceholdersAndValues.values.concat(userId, entryIds),
function (err) {
if (err) {
callback(err);
return;
}

callback(null);
}
);
};
@@ -34,7 +34,7 @@ module.exports = function (feedId, userId, unreadOnly, limit, offset, callback =
this.db.all(
`SELECT e.id, e.url, e.title, e.author, e.body, e.extras,
CAST(strftime('%s', e.created) AS INTEGER) as created,
ue.saved
ue.read, ue.saved
FROM userEntries ue, entries e ON ue.entryId=e.id
WHERE ue.userId=?
AND ue.feedId=?
@@ -132,6 +132,7 @@ emitter.on('stats-by-feed', require('./stats/by-feed'));
// Entries
emitter.on('entry-store', require('./entry/store'));
emitter.on('entry-user-update', require('./entry/user-update'));
emitter.on('entry-user-batch-update', require('./entry/user-batch-update'));
emitter.on('entry:assign', require('./entry/assign'));

// Discussions
@@ -0,0 +1,25 @@
'use strict';

const dispatcher = require('../../dispatcher');
const errors = require('restify-errors');

module.exports = (req, res, next) => {
if (Array.isArray(req.body.entryIds) === false) {
return next(new errors.BadRequestError('Expected a list of entry ids'));
}

if (!req.body.fields) {
return next(new errors.BadRequestError('No fields specified'));
}

const entryIds = req.body.entryIds.map(id => parseInt(id, 10) || 0).filter(id => id > 0);

dispatcher.emit('entry-user-batch-update', 1, entryIds, req.body.fields, (err) => {
if (err) {
return next(new errors.InternalServerError(err.message));
}

res.send(204);
next();
});
};
@@ -16,6 +16,12 @@ module.exports = (req, res, next) => {
return accumulator;
}, []);

if (entryIds.length === 0) {
res.send([]);
next();
return;
}

dispatcher.emit('discussion:list', entryIds, (err, discussions) => {
let discussionsByEntry = discussions.reduce((accumulator, discussion) => {
let entryId = discussion.entryId;
@@ -74,13 +80,17 @@ module.exports = (req, res, next) => {
value: entry.discussions,
label: 'Discussions'
},
read: {
value: Boolean(entry.read),
label: 'Read'
},
saved: {
value: Boolean(entry.saved),
label: 'Saved',
},
_links: {
save: `/entry/${entry.id}/save`,
unsave: `/entry/${entry.id}/unsave`
save_entry: `/entry/${entry.id}/save`,
unsave_entry: `/entry/${entry.id}/unsave`,
}
};
});
@@ -45,6 +45,12 @@ module.exports = (req, res, next) => {
value: feed.fetched,
label: 'Last fetch',
treat_as: 'date'
},
_links: {
mark_entries_read: {
method: 'PATCH',
url: '/entry'
}
}
};
});
@@ -60,6 +60,7 @@ server.post('/feed', require('./routes/feed-create'));
server.put('/feed', require('./routes/feed-update'));
server.del('/feed', require('./routes/feed-destroy'));

server.patch('/entry', require('./routes/entry-batch-update'));
server.patch('/entry/:entryId/save', require('./routes/entry-save'));
server.patch('/entry/:entryId/unsave', require('./routes/entry-unsave'));

@@ -18,6 +18,7 @@ export default class Entry extends PopulateMixin(DateTimeMixin(Base)) {
this.keywords = null;
this.discussions = null;
this.saved = false;
this.read = false;
this.populate(data);
}

@@ -32,7 +33,7 @@ export default class Entry extends PopulateMixin(DateTimeMixin(Base)) {
save() {
return m.request({
method: 'PATCH',
url: this.links.save,
url: this.links.save_entry,
withCredentials: true,
}).then(res => {
this.saved = true;
@@ -44,7 +45,7 @@ export default class Entry extends PopulateMixin(DateTimeMixin(Base)) {
unsave() {
return m.request({
method: 'PATCH',
url: this.links.unsave,
url: this.links.unsave_entry,
withCredentials: true,
}).then(res => {
this.saved = false;
@@ -26,15 +26,12 @@ export default class Feed extends PopulateMixin(DateTimeMixin(Base)) {
}

load() {
if (this.entries.length > 0) {
return;
}

return m.request({
method: 'GET',
url: `/feed/${this.id}`,
withCredentials: true,
type: Entry
type: Entry,
background: true
}).then((entries) => {
this.entries = entries;
}).catch(e => {
@@ -43,15 +40,12 @@ export default class Feed extends PopulateMixin(DateTimeMixin(Base)) {
}

loadHistory() {
if (this.history.length > 0) {
return;
}

return m.request({
method: 'GET',
url: `/history/${this.id}`,
withCredentials: true,
type: FetchStat
type: FetchStat,
background: true
}).then(history => {
this.history = history;
}).catch(e => {
@@ -68,6 +62,27 @@ export default class Feed extends PopulateMixin(DateTimeMixin(Base)) {
return this._nextFetch < referenceDate;
}

markAllRead() {
const ids = this.unreadEntries.map(entry => entry.id);

const route = this.links.mark_entries_read;
return m.request({
method: route.method,
url: route.url,
withCredentials: true,
data: {
entryIds: ids,
fields: {
read: true
}
}
}).then(res => {
this.unreadEntries.forEach(entry => entry.read = true);
}).catch(e => {
console.log(e);
});
}

set nextFetch(value) {
this._nextFetch = new Date(value * 1000);
}
@@ -91,4 +106,8 @@ export default class Feed extends PopulateMixin(DateTimeMixin(Base)) {
get subscribed() {
return this.toTimeOrDate(this._subscribed);
}

get unreadEntries() {
return this.entries.filter(entry => entry.read === false);
}
}
@@ -0,0 +1,19 @@
'use strict';

import m from 'mithril';

export default {
view: function (vnode) {
const feed = vnode.attrs.feed;

let onclick = () => feed.save();
let label = 'Done with these';

if (entry.saved) {
onclick = () => entry.unsave();
label = 'unsave';
}

return m('button', {onclick,}, label);
}
};
@@ -13,13 +13,7 @@ export default {
let node = null, nodes = [];

node = m(EntryListHeader, {
url: feed.url,
siteUrl: feed.siteUrl,
url: feed.url,
subscribed: feed.subscribed,
fetched: feed.fetched,
id: feed.id,
nextFetch: feed.nextFetch
feed,
});
nodes.push(node);

@@ -4,32 +4,38 @@ import m from 'mithril';

export default {
view: function (vnode) {
const feed = vnode.attrs.feed;
let node = null, nodes = [];

node = m('p', [
m('a', {
target: '_blank',
rel: 'external noopener noreferrer',
href: vnode.attrs.siteUrl
href: feed.siteUrl
}, 'Visit site')
]);
nodes.push(node);

node = m('p', `Fetched on ${vnode.attrs.fetched}`);
node = m('p', `Fetched on ${feed.fetched}`);
nodes.push(node);

node = m('p', `Next fetch: ${vnode.attrs.nextFetch}`);
node = m('p', `Next fetch: ${feed.nextFetch}`);
nodes.push(node);

node = m('p', `Subscribed since ${vnode.attrs.subscribed}`);
node = m('p', `Subscribed since ${feed.subscribed}`);
nodes.push(node);

node = m('a', {
href: `/feed/${vnode.attrs.id}/history`,
href: `/feed/${feed.id}/history`,
oncreate: m.route.link
}, 'History');
nodes.push(node);

node = m('button', {
onclick: () => feed.markAllRead(),
}, 'Mark all read');
nodes.push(node);


return m('header', nodes);
return nodes;
@@ -7,6 +7,11 @@ import SaveButton from './SaveButton';
export default {
view: function (vnode) {
const entry = vnode.attrs.entry;

if (entry.read) {
return null;
}

const linkAttrs = {
'href': entry.url,
'target': '_blank',

0 comments on commit 6f69cea

Please sign in to comment.