From 208fd0d9afcce2bf4be129b2aec4c5dcba21a6b6 Mon Sep 17 00:00:00 2001 From: Alexandre Rossi Date: Sun, 17 Feb 2019 20:37:24 +0100 Subject: [PATCH] new lastread filter Introduces a new filter: last read items, that is items ordered by the last time they were read, excluding the items that were skipped using 'Mark Visible Read'. --- controllers/Items.php | 7 ++- daos/mysql/Database.php | 6 +++ daos/mysql/Items.php | 62 +++++++++++++++++++------- daos/pgsql/Database.php | 15 +++++++ daos/sqlite/Database.php | 6 +++ public/js/selfoss-base.js | 5 ++- public/js/selfoss-db.js | 45 +++++++++++++------ public/js/selfoss-events-navigation.js | 2 + public/js/selfoss-events.js | 2 +- public/lang/en.json | 1 + public/lang/fr.json | 1 + templates/home.phtml | 1 + 12 files changed, 119 insertions(+), 34 deletions(-) diff --git a/controllers/Items.php b/controllers/Items.php index d77f58ad23..d27d8d1199 100644 --- a/controllers/Items.php +++ b/controllers/Items.php @@ -30,14 +30,17 @@ public function mark(Base $f3, array $params) { $lastid = $_POST['ids']; } + $skipped = array_key_exists('skipped', $_POST) && $_POST['skipped'] == 'true'; + $itemDao = new \daos\Items(); // validate id or ids if (!$itemDao->isValid('id', $lastid)) { + \F3::get('logger')->debug('invalid id: ' . var_export($lastid, true)); $this->view->error('invalid id'); } - $itemDao->mark($lastid); + $itemDao->mark($lastid, $skipped); $return = [ 'success' => true @@ -238,8 +241,10 @@ public function sync() { $sync['newItems'][] = [ 'id' => $newItem['id'], 'datetime' => \helpers\ViewHelper::date_iso8601($newItem['datetime']), + 'updatetime' => \helpers\ViewHelper::date_iso8601($newItem['updatetime']), 'unread' => $newItem['unread'], 'starred' => $newItem['starred'], + 'skipped' => $newItem['skipped'], 'html' => $this->view->render('templates/item.phtml'), 'source' => $newItem['source'], 'tags' => array_keys($newItem['tags']) diff --git a/daos/mysql/Database.php b/daos/mysql/Database.php index ec76d744df..5b92fa642f 100644 --- a/daos/mysql/Database.php +++ b/daos/mysql/Database.php @@ -243,6 +243,12 @@ public function __construct() { 'INSERT INTO ' . \F3::get('db_prefix') . 'version (version) VALUES (12)' ]); } + if (strnatcmp($version, '13') < 0) { + \F3::get('db')->exec([ + 'ALTER TABLE ' . \F3::get('db_prefix') . 'items ADD skipped BOOL NOT NULL DEFAULT 0;', + 'INSERT INTO ' . \F3::get('db_prefix') . 'version (version) VALUES (13)' + ]); + } } // just initialize once diff --git a/daos/mysql/Items.php b/daos/mysql/Items.php index 9ee661a0df..97108ef32c 100644 --- a/daos/mysql/Items.php +++ b/daos/mysql/Items.php @@ -23,17 +23,31 @@ class Items extends Database { * * @return void */ - public function mark($id) { - if ($this->isValid('id', $id) === false) { + public function mark($id, $skipped=false) { + if (is_array($id)) { + $in_ids = $this->stmt->intRowMatches('id', $id); + foreach($id as $i) { + \F3::get('logger')->debug('statusUpdate: ' . $i . ' marked as read'); + } + } else { + $intId = (int) $id; + if ($intId > 0) { + $in_ids = 'id = ' . $intId; + \F3::get('logger')->debug('statusUpdate: ' . $intId . ' marked as read'); + } else { + $in_ids = null; + } + } + if (is_null($in_ids)) { return; } - if (is_array($id)) { - $id = implode(',', $id); + $update = array($this->stmt->isFalse('unread')); + if ($skipped) { + $update[] = $this->stmt->isTrue('skipped'); } - // i used string concatenation after validating $id - \F3::get('db')->exec('UPDATE ' . \F3::get('db_prefix') . "items SET unread=? WHERE id IN ($id)", false); + \F3::get('db')->exec('UPDATE ' . \F3::get('db_prefix') . 'items SET ' . implode(',', $update) . ' WHERE ' . $in_ids . ';'); } /** @@ -215,6 +229,7 @@ public function get($options = []) { $params = []; $where = [$this->stmt->bool(true)]; $order = 'DESC'; + $orderDatetime = 'items.datetime'; // only starred if (isset($options['type']) && $options['type'] === 'starred') { @@ -229,6 +244,14 @@ public function get($options = []) { } } + // recently read + elseif (isset($options['type']) && $options['type'] == 'lastread') { + $orderDatetime = 'items.updatetime'; + $where[] = $this->stmt->isFalse('unread'); + $where[] = $this->stmt->isFalse('skipped'); + $order = 'DESC'; + } + // search if (isset($options['search']) && strlen($options['search']) > 0) { $search = implode('%', \helpers\Search::splitTerms($options['search'])); @@ -282,8 +305,8 @@ public function get($options = []) { // Because of sqlite lack of tuple comparison support, we use a // more complicated condition. - $where[] = "(items.datetime $ltgt :offset_from_datetime OR - (items.datetime = :offset_from_datetime2 AND + $where[] = "($orderDatetime $ltgt :offset_from_datetime OR + ($orderDatetime = :offset_from_datetime2 AND items.id $ltgt :offset_from_id) )"; } @@ -325,7 +348,7 @@ public function get($options = []) { items.id, datetime, items.title AS title, content, unread, starred, source, thumbnail, icon, uid, link, updatetime, author, sources.title as sourcetitle, sources.tags as tags FROM ' . \F3::get('db_prefix') . 'items AS items, ' . \F3::get('db_prefix') . 'sources AS sources WHERE items.source=sources.id AND'; - $order_sql = 'ORDER BY items.datetime ' . $order . ', items.id ' . $order; + $order_sql = 'ORDER BY ' . $orderDatetime . ' ' . $order . ', items.id ' . $order; if ($where_ids !== '') { // This UNION is required for the extra explicitely requested items @@ -377,7 +400,7 @@ public function hasMore() { */ public function sync($sinceId, DateTime $notBefore, DateTime $since, $howMany) { $query = 'SELECT - items.id, datetime, items.title AS title, content, unread, starred, source, thumbnail, icon, uid, link, updatetime, author, sources.title as sourcetitle, sources.tags as tags + items.id, datetime, items.title AS title, content, unread, starred, skipped, source, thumbnail, icon, uid, link, updatetime, author, sources.title as sourcetitle, sources.tags as tags FROM ' . \F3::get('db_prefix') . 'items AS items, ' . \F3::get('db_prefix') . 'sources AS sources WHERE items.source=sources.id AND (' . $this->stmt->isTrue('unread') . @@ -399,6 +422,7 @@ public function sync($sinceId, DateTime $notBefore, DateTime $since, $howMany) { 'id' => \daos\PARAM_INT, 'unread' => \daos\PARAM_BOOL, 'starred' => \daos\PARAM_BOOL, + 'skipped' => \daos\PARAM_BOOL, 'source' => \daos\PARAM_INT ]); } @@ -617,15 +641,17 @@ public function lastUpdate() { * * @return array of unread, starred, etc. status of specified items */ - public function statuses(DateTime $since) { - $res = \F3::get('db')->exec('SELECT id, unread, starred + public function statuses(Datetime $since) { + $res = \F3::get('db')->exec('SELECT id, unread, starred, skipped, + updatetime FROM ' . \F3::get('db_prefix') . 'items WHERE ' . \F3::get('db_prefix') . 'items.updatetime > :since;', [':since' => [$since->format(DateTime::ATOM), \PDO::PARAM_STR]]); $res = $this->stmt->ensureRowTypes($res, [ 'id' => \daos\PARAM_INT, 'unread' => \daos\PARAM_BOOL, - 'starred' => \daos\PARAM_BOOL + 'starred' => \daos\PARAM_BOOL, + 'skipped' => \daos\PARAM_BOOL ]); return $res; @@ -647,7 +673,7 @@ public function bulkStatusUpdate(array $statuses) { $statusUpdate = null; // sanitize statuses - foreach (['unread', 'starred'] as $sk) { + foreach (['unread', 'starred', 'skipped'] as $sk) { if (array_key_exists($sk, $status)) { if ($status[$sk] == 'true') { $statusUpdate = [ @@ -700,12 +726,14 @@ public function bulkStatusUpdate(array $statuses) { foreach ($sql as $id => $q) { $params = [ ':id' => [$id, \PDO::PARAM_INT], - ':statusUpdate' => [$q['datetime'], \PDO::PARAM_STR] + ':statusUpdate' => [$q['datetime'], \PDO::PARAM_STR], + ':statusUpdate2' => [$q['datetime'], \PDO::PARAM_STR] ]; $updated = \F3::get('db')->exec( 'UPDATE ' . \F3::get('db_prefix') . 'items - SET ' . implode(', ', array_values($q['updates'])) . ' - WHERE id = :id AND updatetime < :statusUpdate', $params); + SET ' . implode(', ', array_values($q['updates'])) . ', + updatetime=:statusUpdate + WHERE id = :id AND updatetime < :statusUpdate2', $params); if ($updated == 0) { // entry status was updated in between so updatetime must // be updated to ensure client side consistency of diff --git a/daos/pgsql/Database.php b/daos/pgsql/Database.php index 116859a7ec..59f08fd554 100644 --- a/daos/pgsql/Database.php +++ b/daos/pgsql/Database.php @@ -227,6 +227,21 @@ public function __construct() { 'INSERT INTO version (version) VALUES (12)' ]); } + if (strnatcmp($version, '13') < 0) { + \F3::get('db')->exec([ + 'ALTER TABLE items ADD skipped BOOLEAN NOT NULL DEFAULT FALSE;', + 'CREATE TABLE item_resources ( + id SERIAL PRIMARY KEY, + item_id INTEGER REFERENCES items ON DELETE CASCADE, + hash TEXT NOT NULL, + url TEXT NOT NULL + );', + 'CREATE UNIQUE INDEX uid_idx ON items USING btree (source, uid)', + 'CREATE INDEX datetime_idx ON items USING btree (datetime)', + 'CREATE INDEX updatetime_idx ON items USING btree (updatetime)', + 'INSERT INTO version (version) VALUES (13)' + ]); + } } // just initialize once diff --git a/daos/sqlite/Database.php b/daos/sqlite/Database.php index 0947d78d3f..aa41322c94 100644 --- a/daos/sqlite/Database.php +++ b/daos/sqlite/Database.php @@ -234,6 +234,12 @@ public function __construct() { 'INSERT INTO version (version) VALUES (11)' ]); } + if (strnatcmp($version, '13') < 0) { + \F3::get('db')->exec([ + 'ALTER TABLE items ADD skipped BOOL NOT NULL DEFAULT 0;', + 'INSERT INTO version (version) VALUES (13)' + ]); + } } // just initialize once diff --git a/public/js/selfoss-base.js b/public/js/selfoss-base.js index 48a402503d..3e8630b3ef 100644 --- a/public/js/selfoss-base.js +++ b/public/js/selfoss-base.js @@ -535,7 +535,7 @@ var selfoss = { if (selfoss.db.storage) { selfoss.refreshUnread(unreadstats); - selfoss.dbOffline.entriesMark(ids, false).then(displayNextUnread); + selfoss.dbOffline.entriesMark(ids, false, true).then(displayNextUnread); } $.ajax({ @@ -543,7 +543,8 @@ var selfoss = { type: 'POST', dataType: 'json', data: { - ids: ids + ids: ids, + skipped: true }, success: function() { selfoss.db.setOnline(); diff --git a/public/js/selfoss-db.js b/public/js/selfoss-db.js index b352518cdc..1ccab86161 100644 --- a/public/js/selfoss-db.js +++ b/public/js/selfoss-db.js @@ -133,6 +133,7 @@ selfoss.dbOnline = { var maxId = 0; data.newItems.forEach(function(item) { item.datetime = new Date(item.datetime); + item.updatetime = new Date(item.updatetime); maxId = Math.max(item.id, maxId); }); @@ -350,7 +351,7 @@ selfoss.dbOffline = { selfoss.db.storage = new Dexie('selfoss'); selfoss.db.storage.version(1).stores({ - entries: '&id,*datetime,[datetime+id]', + entries: '&id,*datetime,[datetime+id],*updatetime,[updatetime+id]', statusq: '++id,*entryId', stamps: '&name,datetime', stats: '&name', @@ -544,7 +545,13 @@ selfoss.dbOffline = { var hasMore = false; var ascOrder = selfoss.db.ascOrder(); - var entries = selfoss.db.storage.entries.orderBy('[datetime+id]'); + var orderDatetime = 'datetime'; + if (selfoss.filter.type == 'lastread') { + orderDatetime = 'updatetime'; + } + var entries = selfoss.db.storage.entries.orderBy( + '[' + orderDatetime + '+id]' + ); if (!ascOrder) { entries = entries.reverse(); } @@ -569,6 +576,8 @@ selfoss.dbOffline = { return entry.starred; } else if (selfoss.filter.type == 'unread') { return entry.unread; + } else if (selfoss.filter.type == 'lastread') { + return !entry.unread && !entry.skipped; } return true; @@ -582,13 +591,18 @@ selfoss.dbOffline = { // seek pagination isMore = !seek; if (seek) { + var entryOrderDatetime = entry.datetime; + if (selfoss.filter.type == 'lastread') { + entryOrderDatetime = entry.updatetime; + } + if (ascOrder) { - isMore = entry.datetime > fromDatetime - || (entry.datetime.getTime() == fromDatetime.getTime() + isMore = entryOrderDatetime > fromDatetime + || (entryOrderDatetime.getTime() == fromDatetime.getTime() && entry.id > fromId); } else { - isMore = entry.datetime < fromDatetime - || (entry.datetime.getTime() == fromDatetime.getTime() + isMore = entryOrderDatetime < fromDatetime + || (entryOrderDatetime.getTime() == fromDatetime.getTime() && entry.id < fromId); } } @@ -741,7 +755,9 @@ selfoss.dbOffline = { // update entries statuses itemStatuses.forEach(function(itemStatus) { - var newStatus = {}; + var newStatus = { + updatetime: new Date(itemStatus.updatetime) + }; selfoss.db.entryStatusNames.forEach(function(statusName) { if (statusName in itemStatus) { @@ -777,7 +793,8 @@ selfoss.dbOffline = { if (updateStats) { for (var statusName in statsDiff) { - if (statsDiff.hasOwnProperty(statusName)) { + if (statsDiff.hasOwnProperty(statusName) + && statusName != 'skipped') { selfoss.db.storage.stats.get(statusName, function(stat) { selfoss.db.storage.stats.put({ name: statusName, @@ -791,23 +808,25 @@ selfoss.dbOffline = { }, - entriesMark: function(itemIds, unread) { + entriesMark: function(itemIds, unread, skipped) { selfoss.dbOnline.statsDirty = true; var newStatuses = itemIds.map(function(itemId) { - return {id: itemId, unread: unread}; + return {id: itemId, unread: unread, skipped: skipped, + updatetime: new Date()}; }); return selfoss.dbOffline.storeEntryStatuses(newStatuses); }, entryMark: function(itemId, unread) { - return selfoss.dbOffline.entriesMark([itemId], unread); + return selfoss.dbOffline.entriesMark([itemId], unread, false); }, entryStar: function(itemId, starred) { return selfoss.dbOffline.storeEntryStatuses([{ id: itemId, + updatetime: new Date(), starred: starred }]); } @@ -822,7 +841,7 @@ selfoss.db = { storage: null, online: true, enableOffline: window.localStorage.getItem('enableOffline') === 'true', - entryStatusNames: ['unread', 'starred'], + entryStatusNames: ['unread', 'starred', 'skipped'], userWaiting: true, @@ -935,7 +954,7 @@ selfoss.db = { selfoss.filter.extraIds.push(selfoss.events.entryId); } - if (!append || selfoss.filter.type != 'newest') { + if (!append || selfoss.filter.type == 'starred' || selfoss.filter.type == 'unread') { selfoss.dbOffline.olderEntriesOnline = false; } diff --git a/public/js/selfoss-events-navigation.js b/public/js/selfoss-events-navigation.js index fdf76a312e..01c8218a89 100644 --- a/public/js/selfoss-events-navigation.js +++ b/public/js/selfoss-events-navigation.js @@ -42,6 +42,8 @@ selfoss.events.navigation = function() { selfoss.filter.type = 'unread'; } else if ($(this).hasClass('nav-filter-starred')) { selfoss.filter.type = 'starred'; + } else if ($(this).hasClass('nav-filter-lastread')) { + selfoss.filter.type = 'lastread'; } selfoss.events.reloadSamePath = true; diff --git a/public/js/selfoss-events.js b/public/js/selfoss-events.js index 60f198b51d..ab194c1421 100644 --- a/public/js/selfoss-events.js +++ b/public/js/selfoss-events.js @@ -159,7 +159,7 @@ selfoss.events = { // load items if ($.inArray(selfoss.events.section, - ['newest', 'unread', 'starred']) > -1) { + ['newest', 'unread', 'starred', 'lastread']) > -1) { selfoss.filter.type = selfoss.events.section; selfoss.filter.tag = ''; selfoss.filter.source = ''; diff --git a/public/lang/en.json b/public/lang/en.json index 2d53cee1ac..a323b92aaa 100644 --- a/public/lang/en.json +++ b/public/lang/en.json @@ -4,6 +4,7 @@ "lang_newest": "Newest", "lang_unread": "Unread", "lang_starred": "Starred", + "lang_lastread": "Last read", "lang_online_count": "Items available on the server", "lang_offline_count": "Items available locally", "lang_tags": "Tags", diff --git a/public/lang/fr.json b/public/lang/fr.json index e78b042996..5ee555a2a7 100644 --- a/public/lang/fr.json +++ b/public/lang/fr.json @@ -4,6 +4,7 @@ "lang_newest": "Le plus récent", "lang_unread": "Non lu", "lang_starred": "Favoris", + "lang_lastread": "Derniers lus", "lang_online_count": "Sur le serveur", "lang_offline_count": "En local", "lang_tags": "Tags", diff --git a/templates/home.phtml b/templates/home.phtml index 6a49d325d3..161fdc0cfc 100644 --- a/templates/home.phtml +++ b/templates/home.phtml @@ -151,6 +151,7 @@ +