fix sync missing articles
This commit is contained in:
+2
-123
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||||
use chrono::{DateTime, Duration, Local, NaiveDateTime};
|
use chrono::{DateTime, Local, NaiveDateTime};
|
||||||
use dateparser::parse;
|
use dateparser::parse;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use rss::Item;
|
use rss::Item;
|
||||||
@@ -127,28 +127,9 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only read items are purged, and only once they're old — unread items are
|
|
||||||
// kept regardless of age so infrequently-updated feeds (or infrequent syncs)
|
|
||||||
// don't lose articles the user hasn't seen yet. Items without a `created_ts`
|
|
||||||
// (e.g. ones inserted before this column existed, or whose feed didn't
|
|
||||||
// provide a publish date) are left alone — `lt` never matches NULL.
|
|
||||||
fn delete_old_feed_items(connection: &mut PgConnection) {
|
|
||||||
let cutoff = Local::now().naive_local() - Duration::days(14);
|
|
||||||
let result = diesel::delete(
|
|
||||||
feed_item::table
|
|
||||||
.filter(feed_item::read.eq(true))
|
|
||||||
.filter(feed_item::created_ts.lt(cutoff)),
|
|
||||||
)
|
|
||||||
.execute(connection);
|
|
||||||
|
|
||||||
log::info!("Deleted old read feed items (older than 2 weeks): {:?}", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responder {
|
pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responder {
|
||||||
let mut connection: diesel::PgConnection = establish_connection();
|
let mut connection: diesel::PgConnection = establish_connection();
|
||||||
|
|
||||||
delete_old_feed_items(&mut connection);
|
|
||||||
|
|
||||||
let req_user_id: i32 = data.user_id;
|
let req_user_id: i32 = data.user_id;
|
||||||
|
|
||||||
let feeds: Vec<Feed> = feed::table
|
let feeds: Vec<Feed> = feed::table
|
||||||
@@ -183,6 +164,7 @@ mod tests {
|
|||||||
use crate::models::user::rss_user::User;
|
use crate::models::user::rss_user::User;
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
use crate::test_helpers::unique_suffix;
|
use crate::test_helpers::unique_suffix;
|
||||||
|
use chrono::Duration;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -270,109 +252,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
|
||||||
async fn delete_old_feed_items_removes_only_old_read_items() {
|
|
||||||
let mut connection = establish_connection();
|
|
||||||
let suffix = unique_suffix();
|
|
||||||
|
|
||||||
let new_user = NewUser::new(
|
|
||||||
format!("cleanup_test_{suffix}"),
|
|
||||||
format!("cleanup_{suffix}@example.test"),
|
|
||||||
"secret".to_string(),
|
|
||||||
);
|
|
||||||
let user: User = diesel::insert_into(users::table)
|
|
||||||
.values(&new_user)
|
|
||||||
.get_result(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let new_feed = NewFeed::new(
|
|
||||||
format!("Cleanup test feed {suffix}"),
|
|
||||||
format!("https://example.test/feed/{suffix}"),
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
let feed: Feed = diesel::insert_into(feed::table)
|
|
||||||
.values(&new_feed)
|
|
||||||
.get_result(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let now = Local::now().naive_local();
|
|
||||||
let old_read_item = NewFeedItem::new(
|
|
||||||
feed.id,
|
|
||||||
"old read content".to_string(),
|
|
||||||
format!("Old read article {suffix}"),
|
|
||||||
format!("https://example.test/article/old-read-{suffix}"),
|
|
||||||
Some(now - Duration::days(20)),
|
|
||||||
);
|
|
||||||
let old_unread_item = NewFeedItem::new(
|
|
||||||
feed.id,
|
|
||||||
"old unread content".to_string(),
|
|
||||||
format!("Old unread article {suffix}"),
|
|
||||||
format!("https://example.test/article/old-unread-{suffix}"),
|
|
||||||
Some(now - Duration::days(20)),
|
|
||||||
);
|
|
||||||
let recent_read_item = NewFeedItem::new(
|
|
||||||
feed.id,
|
|
||||||
"recent content".to_string(),
|
|
||||||
format!("Recent article {suffix}"),
|
|
||||||
format!("https://example.test/article/recent-{suffix}"),
|
|
||||||
Some(now - Duration::days(1)),
|
|
||||||
);
|
|
||||||
|
|
||||||
let old_read: FeedItem = diesel::insert_into(feed_item::table)
|
|
||||||
.values(&old_read_item)
|
|
||||||
.get_result(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
diesel::update(&old_read)
|
|
||||||
.set(feed_item::read.eq(true))
|
|
||||||
.execute(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let old_unread: FeedItem = diesel::insert_into(feed_item::table)
|
|
||||||
.values(&old_unread_item)
|
|
||||||
.get_result(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let recent: FeedItem = diesel::insert_into(feed_item::table)
|
|
||||||
.values(&recent_read_item)
|
|
||||||
.get_result(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
diesel::update(&recent)
|
|
||||||
.set(feed_item::read.eq(true))
|
|
||||||
.execute(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
delete_old_feed_items(&mut connection);
|
|
||||||
|
|
||||||
let remaining: Vec<FeedItem> = feed_item::table
|
|
||||||
.filter(feed_id.eq(feed.id))
|
|
||||||
.load(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let remaining_ids: Vec<i32> = remaining.iter().map(|item| item.id).collect();
|
|
||||||
assert!(
|
|
||||||
!remaining_ids.contains(&old_read.id),
|
|
||||||
"old read item should have been deleted"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
remaining_ids.contains(&old_unread.id),
|
|
||||||
"old unread item should be kept"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
remaining_ids.contains(&recent.id),
|
|
||||||
"recent item should be kept"
|
|
||||||
);
|
|
||||||
|
|
||||||
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
|
||||||
.execute(&mut connection)
|
|
||||||
.ok();
|
|
||||||
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
|
|
||||||
.execute(&mut connection)
|
|
||||||
.ok();
|
|
||||||
diesel::delete(users::table.filter(users::id.eq(user.id)))
|
|
||||||
.execute(&mut connection)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn create_feed_item_inserts_articles_older_than_two_weeks() {
|
async fn create_feed_item_inserts_articles_older_than_two_weeks() {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class FakeIntersectionObserver {
|
|||||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
describe('useFeeds', () => {
|
describe('useFeeds', () => {
|
||||||
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable } = useFeeds()
|
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection } = useFeeds()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -91,6 +91,31 @@ describe('useFeeds', () => {
|
|||||||
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
|
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks the correct articles read when several scroll out of view in one batch', async () => {
|
||||||
|
feeds.value = [
|
||||||
|
{ id: 101, title: 'First' },
|
||||||
|
{ id: 102, title: 'Second' },
|
||||||
|
{ id: 103, title: 'Third' },
|
||||||
|
]
|
||||||
|
setInitialLoad(true)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
// Both the first and second articles scrolled above the viewport in the
|
||||||
|
// same IntersectionObserver callback — their `target.id` reflects their
|
||||||
|
// original render-time indices (0 and 1).
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/101', null, expect.anything())
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/102', null, expect.anything())
|
||||||
|
expect(axios.put).not.toHaveBeenCalledWith('/api/v1/article/read/103', null, expect.anything())
|
||||||
|
expect(feeds.value).toEqual([{ id: 103, title: 'Third' }])
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
|
it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
|
||||||
feeds.value = [{
|
feeds.value = [{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref, unref, nextTick } from 'vue';
|
import { ref, nextTick } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Readability } from '@mozilla/readability';
|
import { Readability } from '@mozilla/readability';
|
||||||
|
|
||||||
@@ -155,21 +155,25 @@ function setupIntersectionObserver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleIntersection(entries) {
|
async function handleIntersection(entries) {
|
||||||
// The callback function for when the target element enters or exits the viewport
|
|
||||||
for (const entry of entries) {
|
|
||||||
// An article that has scrolled above the viewport (not intersecting,
|
// An article that has scrolled above the viewport (not intersecting,
|
||||||
// bounding box above the top edge) has been read — mark it and remove it.
|
// bounding box above the top edge) has been read. Resolve all affected
|
||||||
if (initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) {
|
// feeds up front, before any removal — splicing `feeds` while iterating
|
||||||
await markRead(feeds.value[entry.target.id].id)
|
// would shift the array indices that later entries' `target.id` refer to,
|
||||||
removeFeed(entry.target.id)
|
// causing the wrong item to be marked read and removed.
|
||||||
document.getElementById(0)?.scrollIntoView()
|
const readFeeds = entries
|
||||||
}
|
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0)
|
||||||
}
|
.map(entry => feeds.value[entry.target.id])
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (readFeeds.length === 0) return
|
||||||
|
|
||||||
|
for (const feed of readFeeds) {
|
||||||
|
await markRead(feed.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFeed(index) {
|
const readIds = new Set(readFeeds.map(feed => feed.id))
|
||||||
const array = unref(feeds);
|
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||||
array.splice(index, 1);
|
document.getElementById(0)?.scrollIntoView()
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInitialLoad(value) {
|
function setInitialLoad(value) {
|
||||||
@@ -263,7 +267,7 @@ export function useFeeds() {
|
|||||||
markAllRead,
|
markAllRead,
|
||||||
showMessageForXSeconds,
|
showMessageForXSeconds,
|
||||||
setupIntersectionObserver,
|
setupIntersectionObserver,
|
||||||
removeFeed,
|
|
||||||
setInitialLoad,
|
setInitialLoad,
|
||||||
|
handleIntersection,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user