From a92d12623f1ce467ad62d0a848d351107929bb03 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 22 Feb 2024 22:06:06 +0100 Subject: [PATCH] Let's add a new layer, shall we? --- src/dao/channels.rs | 585 +++++++++++++++++++++++++++++ src/dao/fixtures/base_fixtures.sql | 167 ++++++++ src/dao/items.rs | 414 ++++++++++++++++++++ src/dao/mod.rs | 3 + src/dao/users.rs | 178 +++++++++ src/lib.rs | 1 + 6 files changed, 1348 insertions(+) create mode 100644 src/dao/channels.rs create mode 100644 src/dao/fixtures/base_fixtures.sql create mode 100644 src/dao/items.rs create mode 100644 src/dao/mod.rs create mode 100644 src/dao/users.rs diff --git a/src/dao/channels.rs b/src/dao/channels.rs new file mode 100644 index 0000000..d8721b3 --- /dev/null +++ b/src/dao/channels.rs @@ -0,0 +1,585 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use sqlx::Result; +use tracing::{debug, info, instrument}; + +use crate::common::model::Channel; +use crate::common::model::ChannelError; +use crate::common::model::PagedResult; +use crate::common::model::UsersChannel; +use crate::common::rss::check_feed; + +pub struct ChannelDao { + db: PgPool, +} + +impl ChannelDao { + pub fn new(db: PgPool) -> Self { + ChannelDao { db } + } + + /// Returns the whole list of errors associated to the given channel id. + #[instrument(skip(self))] + pub async fn select_errors_by_chan_id( + &self, + channel_id: i32, + user_id: i32, + ) -> Result> { + let result = sqlx::query_as!( + ChannelError, + r#" + SELECT channels_errors.id, + channels_errors.channel_id, + channels_errors.error_timestamp, + channels_errors.error_reason, + channels.name AS channel_name + FROM channels_errors + JOIN channels ON channels_errors.channel_id = channels.id + JOIN channel_users ON channels_errors.channel_id = channel_users.channel_id + WHERE channels_errors.channel_id = $1 + AND channel_users.user_id = $2 + "#, + channel_id, + user_id + ) + .fetch_all(&self.db) + .await?; + + Ok(result) + } + + /// Returns an optional given channel with the given user's metadata. + #[instrument(skip(self))] + pub async fn select_by_id_and_user_id( + &self, + channel_id: i32, + user_id: i32, + ) -> Result> { + let result = sqlx::query_as!( + UsersChannel, + r#" + SELECT "channels"."id", + "channel_users"."name", + "channel_users"."notes", + "channels"."url", + "channels"."registration_timestamp", + "channels"."last_update", + "channels"."disabled", + "channels"."failure_count", + COUNT("users_items"."item_id") AS "items_count", + SUM(CAST("read" AS integer)) AS "items_read" + FROM "channels" + RIGHT JOIN "channel_users" ON "channels"."id" = "channel_users"."channel_id" + LEFT JOIN "users_items" ON "channels"."id" = "users_items"."channel_id" + WHERE "channel_users"."user_id" = $2 + AND "channel_users"."channel_id" = $1 + GROUP BY "channels"."id", "channel_users"."name", "channel_users"."notes" + "#, + channel_id, + user_id + ) + .fetch_optional(&self.db) + .await?; + + Ok(result) + } + + /// Mark the given channel as read for the given user + #[instrument(skip(self))] + pub async fn mark_channel_as_read(&self, channel_id: i32, user_id: i32) -> Result<()> { + self.mark_channel(channel_id, user_id, true).await + } + + /// Mark the given channel as unread for the given user + #[instrument(skip(self))] + pub async fn mark_channel_as_unread(&self, channel_id: i32, user_id: i32) -> Result<()> { + self.mark_channel(channel_id, user_id, false).await + } + + /// Select all the channels of a user, along side the total number of items + #[instrument(skip(self))] + pub async fn select_page_by_user_id( + &self, + user_id: i32, + page_number: u64, + page_size: u64, + ) -> Result> { + let content = sqlx::query_as!( + UsersChannel, + r#" + SELECT "channels"."id", + "channel_users"."name", + "channel_users"."notes", + "channels"."url", + "channels"."registration_timestamp", + "channels"."last_update", + "channels"."disabled", + "channels"."failure_count", + COUNT("users_items"."item_id") AS "items_count", + SUM(CAST("read" AS integer)) AS "items_read" + FROM "channels" + RIGHT JOIN "channel_users" ON "channels"."id" = "channel_users"."channel_id" + LEFT JOIN "users_items" ON "channels"."id" = "users_items"."channel_id" + WHERE "channel_users"."user_id" = $1 + GROUP BY "channels"."id", "channel_users"."registration_timestamp", "channel_users"."name", "channel_users"."notes" + ORDER BY "channel_users"."registration_timestamp" DESC + LIMIT $2 OFFSET $3 + "#, + user_id, + page_size as i64, + (page_number as i64 - 1) * page_size as i64 + ) + .fetch_all(&self.db) + .await?; + + let total_items = sqlx::query_scalar!( + r#" + SELECT COUNT(*) AS num_items + FROM (SELECT "channels"."id", + "channels"."name", + "channels"."url", + "channels"."registration_timestamp", + "channels"."last_update", + "channels"."disabled", + "channels"."failure_count", + COUNT("users_items"."item_id") AS "items_count", + SUM(CAST("read" AS integer)) AS "items_read" + FROM "channels" + RIGHT JOIN "channel_users" ON "channels"."id" = "channel_users"."channel_id" + LEFT JOIN "users_items" ON "channels"."id" = "users_items"."channel_id" + WHERE "channel_users"."user_id" = $1 + GROUP BY "channels"."id", "channel_users"."registration_timestamp" + ORDER BY "channel_users"."registration_timestamp" DESC) AS "sub_query" + "#, + user_id + ) + .fetch_one(&self.db) + .await? + .unwrap_or(0) as u64; + + Ok(PagedResult::new( + content, + total_items, + page_size, + page_number, + )) + } + + /// Create or linked an existing channel to a user, returning the channel id + #[instrument(skip(self))] + pub async fn create_or_link_channel( + &self, + url: &str, + name: Option, + notes: Option, + user_id: i32, + ) -> Result { + // Retrieve or create the channel + let (channel_id, channel_name) = match sqlx::query!( + r#" + SELECT id, name FROM channels WHERE url = $1 + "#, + url + ) + .fetch_optional(&self.db) + .await? + { + Some(result) => (result.id, result.name), + None => self.create_new_channel(url).await?, + }; + + // Insert the channel in the users registered channel + sqlx::query!( + r#" + INSERT INTO channel_users (channel_id, user_id, name, registration_timestamp, notes) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING + "#, + channel_id, + user_id, + name.unwrap_or(channel_name), + Utc::now().into(), + notes + ) + .execute(&self.db) + .await?; + + // Link all the existing items of the channel to the user + sqlx::query_scalar!( + r#" + INSERT INTO users_items (user_id, item_id, channel_id, read, starred) + SELECT $2, id, $1, false, false + from items + where channel_id = $1 + ON CONFLICT DO NOTHING + "#, + channel_id, + user_id + ) + .execute(&self.db) + .await?; + + Ok(channel_id) + } + + /// Enable a channel and reset it's failure count + #[instrument(skip(self))] + pub async fn enable_channel(&self, channel_id: i32) -> Result<()> { + sqlx::query!( + r#" + UPDATE channels SET disabled = false, failure_count = 0 WHERE channels.id = $1 + "#, + channel_id + ) + .execute(&self.db) + .await?; + + Ok(()) + } + + /// Disable a channel + #[instrument(skip(self))] + pub async fn disable_channel(&self, channel_id: i32) -> Result<()> { + sqlx::query!( + r#" + UPDATE channels SET disabled = true WHERE channels.id = $1 + "#, + channel_id + ) + .execute(&self.db) + .await?; + + Ok(()) + } + + /// Disable channels whom failure count is higher than the given threshold + #[instrument(skip(self))] + pub async fn disable_channels(&self, threshold: u32) -> Result<()> { + let disabled_channels = sqlx::query!( + r#" + UPDATE channels SET disabled = true WHERE disabled = false AND failure_count >= $1 + "#, + threshold as i32 + ) + .execute(&self.db) + .await?; + + tracing::debug!("Disabled {} channels", disabled_channels.rows_affected()); + + Ok(()) + } + + /// Return the list of user IDs of of a given channel + #[instrument(skip(self))] + pub async fn get_user_ids_of_channel(&self, channel_id: i32) -> Result> { + sqlx::query_scalar!( + r#" + SELECT user_id FROM channel_users WHERE channel_id = $1 + "#, + channel_id + ) + .fetch_all(&self.db) + .await + } + + /// Return the list of all enabled channels + #[instrument(skip(self))] + pub async fn get_all_enabled_channels(&self) -> Result> { + sqlx::query_as!( + Channel, + r#" + SELECT * FROM channels + "# + ) + .fetch_all(&self.db) + .await + } + + /// Update the last fetched timestamp of a channel + #[instrument(skip(self))] + pub async fn update_last_fetched(&self, channel_id: i32, date: &DateTime) -> Result<()> { + sqlx::query!( + r#" + UPDATE channels SET last_update = $2 WHERE id = $1 + "#, + channel_id, + date.into() + ) + .execute(&self.db) + .await?; + + Ok(()) + } + + /// Retrieve the last update of channel + #[instrument(skip(self))] + pub async fn get_last_update(&self, channel_id: &i32) -> Result>> { + let last_update = sqlx::query!( + r#" + SELECT last_update FROM channels WHERE id = $1 + "#, + channel_id + ) + .fetch_one(&self.db) + .await?; + + Ok(last_update.last_update) + } + + /// Update the failure count of the given channel and insert the error in the dedicated table + #[instrument(skip(self))] + pub async fn fail_channel(&self, channel_id: i32, error_cause: &str) -> Result<()> { + let mut transaction = self.db.begin().await?; + sqlx::query!( + r#" + UPDATE channels SET failure_count = failure_count + 1 WHERE id = $1 + "#, + channel_id + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + r#" + INSERT INTO channels_errors (channel_id, error_timestamp, error_reason) VALUES ($1, $2, $3) + "#, + channel_id, + Utc::now().into(), + error_cause + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + Ok(()) + } + + /// # Create a new channel in the database, returning the created channel id + #[instrument(skip(self))] + async fn create_new_channel(&self, channel_url: &str) -> Result<(i32, String)> { + let feed = check_feed(channel_url) + .await + .map_err(|_| sqlx::Error::RowNotFound)?; //TODO: Bad error type + + let channel = sqlx::query_as!( + Channel, + r#" + INSERT INTO channels (name, url) VALUES ($1, $2) RETURNING * + "#, + feed.title.map(|x| x.content).unwrap_or(channel_url.into()), + channel_url + ) + .fetch_one(&self.db) + .await?; + + let channel_id = channel.id; + let channel_name = channel.name.clone(); + + Ok((channel_id, channel_name)) + } + + async fn mark_channel(&self, channel_id: i32, user_id: i32, read: bool) -> Result<()> { + sqlx::query!( + r#" + UPDATE users_items SET read = $3 WHERE users_items.channel_id = $1 AND users_items.user_id = $2 + "#, + channel_id, + user_id, + read + ) + .execute(&self.db) + .await?; + + Ok(()) + } + + /// Unsubscribe a user from a channel + #[instrument(skip(self))] + pub async fn unsubscribe_channel(&self, channel_id: i32, user_id: i32) -> Result<()> { + let mut transaction = self.db.begin().await?; + + let result = sqlx::query!( + r#" + DELETE FROM channel_users WHERE channel_id = $1 and user_id = $2 + "#, + channel_id, + user_id + ) + .execute(&mut *transaction) + .await?; + + if result.rows_affected() == 0 { + return Err(sqlx::Error::RowNotFound); + } + + debug!("User {} unsubscribed fron channel {}", user_id, channel_id); + + // If no user remains subscribed, delete the whole chan + let result = sqlx::query!( + r#" + DELETE FROM channels WHERE id = $1 AND (SELECT count(*) FROM channel_users WHERE channel_id = $1) = 0 + "#, + channel_id + ) + .execute(&mut *transaction) + .await?; + + if result.rows_affected() == 1 { + info!("Deleted channel id {} from database", channel_id); + } + + transaction.commit().await?; + + Ok(()) + } +} +#[cfg(test)] +mod tests { + use speculoos::prelude::*; + + use super::*; + + fn init(db: PgPool) -> ChannelDao { + ChannelDao { db } + } + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn test_no_conflict_on_existing_channel_insertion(pool: PgPool) -> Result<()> { + let service = init(pool); + + let channel_id = service + .create_or_link_channel("https://www.canardpc.com/feed", None, None, 1) // 1 is root + .await + .unwrap(); + + assert_that!(channel_id).is_equal_to(1); + + Ok(()) + } + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn test_user_get_items_on_registration(pool: PgPool) -> Result<()> { + let service = init(pool); + let _channel_id = service + .create_or_link_channel("https://www.canardpc.com/feed", None, None, 2) // 2 is john_doe + .await + .unwrap(); + + // assert_that!(channel_id).is_equal_to(1); + // let items = service + // .get_items_of_user(Some(1), None, None, 2, 1, 400) + // .await + // .unwrap(); + // asserting!("John doe now has the 60 items of channel 1") + // .that(items.content()) + // .has_length(60); + + Ok(()) + } + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn test_user_registration_on_empty_channel(pool: PgPool) -> Result<()> { + let service = init(pool); + let _channel_id = service + .create_or_link_channel( + "https://rss.slashdot.org/Slashdot/slashdotMain", + None, + None, + 1, + ) // 1 is root + .await + .unwrap(); + + // assert_that!(channel_id).is_equal_to(3); + // let items = get_items_of_user(&pool, Some(3), None, None, 1, 1, 400) // Channel 3 is empty + // .await + // .unwrap(); + // asserting!("List of items is empty") + // .that(items.content()) + // .is_empty(); + + Ok(()) + } + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn test_channel_unsubscribe(pool: PgPool) -> Result<()> { + let service = init(pool.clone()); + + // Register the same channel for two users + let channel_id_u1 = service + .create_or_link_channel("https://www.canardpc.com/feed", None, None, 1) + .await + .unwrap(); + + let channel_id_u2 = service + .create_or_link_channel("https://www.canardpc.com/feed", None, None, 2) + .await + .unwrap(); + assert_eq!(channel_id_u1, channel_id_u2); + + // Unsubscribe user 1 from channel and check. + service.unsubscribe_channel(channel_id_u1, 1).await.unwrap(); + let result = sqlx::query_scalar!( + "SELECT COUNT(*) FROM channel_users WHERE channel_id = $1 AND user_id = $2", + channel_id_u1, + 1, + ) + .fetch_one(&pool) + .await; + assert_eq!(Some(0i64), result.unwrap()); + + // Unsubscribe user 2 from channel and check. + service.unsubscribe_channel(channel_id_u1, 2).await.unwrap(); + let result = sqlx::query_scalar!( + "SELECT COUNT(*) FROM channel_users WHERE channel_id = $1 AND user_id = $2", + channel_id_u1, + 1, + ) + .fetch_one(&pool) + .await; + assert_eq!(Some(0i64), result.unwrap()); + + // Check that the channel have been completely removed + let result = sqlx::query_scalar!( + "SELECT count(id) as count FROM channels WHERE id = $1", + channel_id_u1 + ) + .fetch_one(&pool) + .await; + assert_eq!(Some(0i64), result.unwrap()); + + Ok(()) + } + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn test_add_notes_and_custom_name(pool: PgPool) -> Result<()> { + let service = init(pool); + let channel_id = service + .create_or_link_channel( + "https://www.canardpc.com/feed", + Some("My custom name".to_owned()), + Some("My custom notes".to_owned()), + 2, + ) + .await + .unwrap(); + + let channel = service + .select_by_id_and_user_id(channel_id, 2) + .await + .unwrap() + .unwrap(); + + assert_eq!("My custom name", channel.name); + assert_that!(channel.notes).is_equal_to(Some("My custom notes".to_owned())); + + let channel_from_other_user = service + .select_by_id_and_user_id(channel_id, 1) + .await + .unwrap() + .unwrap(); + assert_eq!("Canard PC", channel_from_other_user.name); + assert_that!(channel_from_other_user.notes).is_equal_to(None); + + Ok(()) + } +} diff --git a/src/dao/fixtures/base_fixtures.sql b/src/dao/fixtures/base_fixtures.sql new file mode 100644 index 0000000..b545b53 --- /dev/null +++ b/src/dao/fixtures/base_fixtures.sql @@ -0,0 +1,167 @@ +INSERT INTO channels (id, name, url, registration_timestamp, last_update, disabled, failure_count) VALUES (1, 'Canard PC', 'https://www.canardpc.com/feed', '2023-07-30 19:51:59.125724 +00:00', '2023-07-30 22:46:54.506650 +00:00', false, 0); +INSERT INTO channels (id, name, url, registration_timestamp, last_update, disabled, failure_count) VALUES (2, 'Le monde', 'https://www.lemonde.fr/rss/une.xml', '2023-07-30 22:44:48.513419 +00:00', '2023-07-30 22:46:54.605712 +00:00', false, 0); +INSERT INTO channels (id, name, url, registration_timestamp, last_update, disabled, failure_count) VALUES (3, 'Slashdot', 'https://rss.slashdot.org/Slashdot/slashdotMain', '2023-07-30 22:44:48.513419 +00:00', '2023-07-30 22:46:54.605712 +00:00', false, 0); + +INSERT INTO channel_users (channel_id, user_id, name, registration_timestamp) VALUES (1, 1, 'Canard PC', '2023-07-30 19:52:17.557439 +00:00'); +INSERT INTO channel_users (channel_id, user_id, name, registration_timestamp) VALUES (2, 1, 'Slashdot', '2023-07-30 23:00:31.251762 +00:00'); + +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (4, 'https://www.canardpc.com/?p=51293', 'Notre sélection de jeux de poche pour l’été', 'https://www.canardpc.com/jeu-de-plateau/dossier-jeu-de-plateau/notre-selection-de-jeux-de-poche-pour-lete/', 'Si vous avez un tant soit peu roulé votre bosse dans le paysage ludique, et que vous vous considérez un « vrai » joueur, alors vous avez forcément fait face au dilemme déchirant de savoir quel jeu emmener en partant en vacances. Les forcenés embarquent leur Battlestar Galactica pour partir à l’autre bout du monde (ne riez pas, mon pote Magouille l’a vraiment fait), les plus raisonnables se contentent d’une boîte ou deux plus modestes. Mais pour ceux qui aspirent à la variété sans sacrifier la place de leurs sous-vêtements, il existe quelques pépites qui méritent qu’on s’y attarde un peu.', '2023-07-30 19:55:54.061077 +00:00', '2023-07-30 06:00:58.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (5, 'https://www.canardpc.com/?p=51924', 'PlayPunk a du chien', 'https://www.canardpc.com/jeu-de-plateau/rencontre/playpunk-a-du-chien/', 'Résumer quelqu’un par une seule de ses réalisations est toujours hasardeux et réducteur. Pourtant, dire d’Antoine Bauza qu’il est l’auteur de 7 Wonders ou de Thomas Provoost, qu’il a fondé Repos Production et importé Time’s Up ! en Europe peut suffire à prouver une chose : ces deux-là connaissent sacrément bien leur boulot.', '2023-07-30 19:55:54.061084 +00:00', '2023-07-28 06:00:44.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (6, 'https://www.canardpc.com/?p=51746', 'En Indonésie, les jeux narratifs font la Java', 'https://www.canardpc.com/jeu-video/dossier-jeu-video/en-indonesie-les-jeux-narratifs-font-la-java/', 'Coffee Talk, A Space for the Unbound... Regroupée derrière la bannière de l’éditeur Toge Productions, une nouvelle vague indonésienne de jeux narratifs et intimistes semble trouver depuis 2020 un public conséquent et fidèle à travers le monde entier. Portrait d’une scène blessée dans son âme, qui n’indonhésite une seconde à parler de sujets difficiles, comme la mort.', '2023-07-30 19:55:54.061086 +00:00', '2023-07-28 06:00:08.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (7, 'https://www.canardpc.com/?p=51713', 'Avowed', 'https://www.canardpc.com/jeu-video/a-venir-jeu-video/avowed/', 'Jusqu’ici, il s’était montré très pudique, avec juste une toute petite bande-annonce en trois ans. Mais à l’E3 on a enfin vu tourner Avowed, le grand jeu de rôle médiéval-fantastique d’Obsidian. Entre-temps, il a quand même changé. Par exemple, maintenant quand on dit « Skyrim », il fait un bruit de friture avec sa bouche et il raccroche.', '2023-07-30 19:55:54.061087 +00:00', '2023-07-27 05:00:14.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (8, 'https://www.canardpc.com/?p=51722', 'The Book Walker : Thief of Tales', 'https://www.canardpc.com/jeu-video/test-jeu-video/the-book-walker-thief-of-tales/', 'The Book Walker : Thief of Tales (que je vais désormais appeler TBW, dans une ultime tentative de faire entendre aux studios qu’il faut cesser avec les titres à rallonge) nous entraîne dans un monde où il est possible de voyager à travers les livres, et où les derniers écrivains en activité pillent allègrement leurs idées dans les œuvres des autres. Avec sa direction artistique qui rappelle furieusement Disco Elysium et son intrigue qui évoque Richard au pays des livres magiques, j’avais peur de pouvoir en dire autant des développeurs à l’origine du jeu.', '2023-07-30 19:55:54.061089 +00:00', '2023-07-26 06:00:42.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (9, 'https://www.canardpc.com/?p=51837', 'Michigan : Report from Hell', 'https://www.canardpc.com/retrogaming/l-oeil-dans-le-retro/michigan-report-from-hell/', 'Sorti en 2004, Michigan : Report from Hell est un survival horror oublié de la PS2, de ceux qu’un archéologue du futur pourrait exhumer en se disant qu’il s’agit probablement de l’artefact qui a signé la fin de notre civilisation. C’est aussi un titre qui a le mérite d’annoncer très rapidement la couleur : c’est un énorme nanar avec la subtilité d’une enclume surmontée d’un éléphant, lui-même dominé par une armoire blindée et un piano à queue.', '2023-07-30 19:55:54.061093 +00:00', '2023-07-25 06:00:35.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (10, 'https://www.canardpc.com/?p=51668', 'Forever Skies', 'https://www.canardpc.com/jeu-video/a-venir-jeu-video/forever-skies/', 'Le vent. Tout autour, si doux, si fort, si vivant. Seul sur le pont, je le laisse m’englober et j’observe l’émouvant ballet des débris pris dans son étreinte. L’esprit baguenaude et formule ce que les lèvres n’osent : « La vache, qu’est-ce qu’on s’emmerde. »', '2023-07-30 19:55:54.061094 +00:00', '2023-07-24 06:00:03.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (11, 'https://www.canardpc.com/?p=51473', 'Une petite histoire des environnements destructibles', 'https://www.canardpc.com/jeu-video/dossier-jeu-video/lhistoire-des-environnements-destructibles/', 'Il paraît que lorsque Alfred Wegener a présenté, en 1912, sa théorie de la dérive des continents, les gens se sont moqués de lui. Je les comprends. Ça peut sembler bête aujourd''hui, mais accepter que de gros trucs en apparence immobiles étaient en fait en mouvement n''a pas dû être facile. Regardez : pendant des années, on a pensé que les décors de FPS étaient condamnés à être statiques. Puis les choses ont commencé à changer.', '2023-07-30 19:55:54.061096 +00:00', '2023-07-21 06:00:49.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (12, 'https://www.canardpc.com/?p=51859', 'Le Clan des loups', 'https://www.canardpc.com/jeu-de-plateau/test-jeu-de-plateau/le-clan-des-loups/', 'Pour marquer mon territoire, je pisse régulièrement autour des tables de jeu. Allez savoir pourquoi, mais ça choque. Là, j’ai une excuse : « Ah mais pardon, c’est thématique. »', '2023-07-30 19:55:54.061097 +00:00', '2023-07-21 06:00:40.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (13, 'https://www.canardpc.com/?p=51626', 'Les gros glaçons', 'https://www.canardpc.com/jeu-video/papier-culture/les-gros-glacons/', 'Ça s’est toujours mal passé entre moi et les glaçons, ces trucs impossibles à démouler de leur bac en plastique rigide et qui, lorsqu’on réussit, fondent instantanément dans l’eau.', '2023-07-30 19:55:54.061099 +00:00', '2023-07-21 06:00:20.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (14, 'https://www.canardpc.com/?p=51496', 'The Finals', 'https://www.canardpc.com/jeu-video/a-venir-jeu-video/the-finals/', 'Depuis que j’ai posé les mains sur Apex Legends en 2019, il est rare que je sois happé par un autre FPS. À côté d’Apex, tout est trop lent, trop brouillon, trop archaïque. Je n’attendais donc pas grand-chose de The Finals, auquel j’avais accès en avant-première pendant dix jours. Et puis vlan, la glissade. Dix jours à lancer le jeu à chacune de mes pauses…', '2023-07-30 19:55:54.061101 +00:00', '2023-07-20 06:00:24.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (15, 'https://www.canardpc.com/?p=51322', 'Station to Station', 'https://www.canardpc.com/jeu-video/a-venir-jeu-video/station-to-station/', 'Les premières vidéos et captures d''écran de Station to Station ont suscité un vif émoi dans la communauté des fans de tchou-tchou. Ouh, de jolis petits trains à vapeur ! Ah, des paysages de maquette ferroviaire en voxels tout colorés ! Wow, des villages et des fermes croquignolets ! Mais le jeu de train, pour moi, c''est une affaire sérieuse.', '2023-07-30 19:55:54.061102 +00:00', '2023-07-19 06:00:34.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (16, 'https://www.canardpc.com/?p=51818', 'Nvidia DLSS, AMD FSR et Starfield : la guerre des étoiles n’est pas celle qu’on croit', 'https://www.canardpc.com/hardware/dossier-hardware/nvidia-dlss-amd-fsr-et-starfield-la-guerre-des-etoiles-nest-pas-celle-quon-croit/', 'Dans le hardware comme partout ailleurs, c’est rarement en plein été qu’on trouve les actus les plus croustillantes à se mettre sous la dent. Heureusement, on peut toujours compter sur une petite polémique inattendue pour s’occuper. Les protagonistes de la dernière en date : Starfield, AMD, et la volonté supposée de ce dernier de faire tout ce qu’il peut pour empêcher les développeurs d’implémenter dans leurs jeux la très populaire technologie DLSS de Nvidia.', '2023-07-30 19:55:54.061103 +00:00', '2023-07-18 13:00:53.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (17, 'https://www.canardpc.com/?p=51631', 'Microsoft et Activision Blizzard : un juge unique pour les lier', 'https://www.canardpc.com/jeu-video/au-coin-du-jeu/microsoft-activision-blizzard-un-juge-unique-pour-les-lier-ou-pas/', 'La cour fédérale de la juge Jacqueline Scott Corley a entendu pendant cinq jours les arguments pour ou contre le rachat d’Activision Blizzard par Microsoft. Elle doit désormais rendre seule sa décision.', '2023-07-30 19:55:54.061105 +00:00', '2023-07-18 06:00:34.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (18, 'https://www.canardpc.com/?p=51712', 'Ghost Trick : Détective Fantôme', 'https://www.canardpc.com/jeu-video/test-jeu-video/ghost-trick-detective-fantome-2/', 'Il y a toujours quelque chose d’excitant à commencer un jeu dix ans après tout le monde. L’attente patiemment construite à force de discussions, de rumeurs, de recommandations, de lectures. Le plaisir de savoir à quoi s’attendre tout en ayant sciemment choisi de ne pas tout se spoiler. L’incertitude, aussi : est-ce qu’il me plaira autant que je me l’imagine ? Me serais-je trompée à son sujet ?', '2023-07-30 19:55:54.061106 +00:00', '2023-07-18 06:00:25.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (19, 'https://www.canardpc.com/?p=51666', 'Gang of Dice', 'https://www.canardpc.com/jeu-de-plateau/test-jeu-de-plateau/gang-of-dice/', 'J’aime le risque et l’adrénaline de la mise en danger. Un jour, je suis entré dans un casino et j’ai joué toute ma fortune sur un seul coup. Tout jusqu’au dernier centime, les 243,60 euros. Je suis un gros dingo.', '2023-07-30 19:55:54.061107 +00:00', '2023-07-17 13:00:13.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (20, 'https://www.canardpc.com/?p=51446', 'Papier peint et icônes : un peu de dignité', 'https://www.canardpc.com/jeu-video/bureaulogie/papier-peint-et-icones-un-peu-de-dignite/', 'Samantha a enfin accepté votre invitation à dîner. Elle déambule dans votre appartement, inspectant la décoration : « Oh, j''aime bien ton canapé taupe, ça va bien avec la couleur du tapis. » Puis, elle s''approche de votre bureau informatique. Un chef-d''œuvre, avec un plateau en hêtre de Norvège, un clavier mécanique custom, un cable management impérial et 300 € de bibelots déco steampunk chinés sur Etsy. Elle est subjuguée par tant de classe, d''élégance. Elle effleure la souris. Et c''est le drame.', '2023-07-30 19:55:54.061109 +00:00', '2023-07-17 06:00:43.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (21, 'https://www.canardpc.com/?p=51824', 'Amnesia : The Bunker', 'https://www.canardpc.com/jeu-video/test-jeu-video/amnesia-the-bunker/', 'Dans un jeu d’horreur, je trouve qu’il n’y a rien de plus terrifiant qu’un monstre qui vous traque sans relâche et dont les apparitions ne sont pas scriptées, comme le Xénomorphe dans Alien : Isolation et Mr. X dans Resident Evil 2. Enfin si, il y a plus terrifiant : ce moment d’incertitude où vous n’avez encore jamais entrevu votre ennemi, mais où vous sentez sa présence et essayez de vous en faire une image mentale, uniquement fondée sur ses grognements et le bruit lourd de ses pas.', '2023-07-30 19:55:54.061111 +00:00', '2023-07-17 06:00:30.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (22, 'https://www.canardpc.com/?p=51620', 'The Pez Outlaw', 'https://www.canardpc.com/jeu-video/papier-culture/the-pez-outlaw/', 'L’histoire du plus grand bad boy américain des trente dernières années. Un homme dangereux, qui a fait trembler une multinationale avec son trafic de… de distributeurs de bonbons PEZ ? Mais c’est quoi ce pitch ?!', '2023-07-30 19:55:54.061113 +00:00', '2023-07-14 06:00:42.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (23, 'https://www.canardpc.com/?p=51801', 'D’Orge et de blé', 'https://www.canardpc.com/jeu-de-plateau/test-jeu-de-plateau/dorge-et-de-ble/', 'Je n’ai qu’une chose à dire sur ce jeu à l’allemande pas complètement allemand : Die schönste imitation wird zu einer hommage, wenn sie mit respekt und einfallsreichtum gemacht wird. J’aime être clair et limpide.', '2023-07-30 19:55:54.061114 +00:00', '2023-07-14 06:00:16.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (24, 'https://www.canardpc.com/?p=51643', 'Le jeu vidéo à l’ère de l’IA', 'https://www.canardpc.com/jeu-video/dossier-jeu-video/le-jeu-video-a-lere-de-lia/', 'Terre, 2029. Les jeux ne sont plus créés par des êtres humains mais uniquement par des IA. À l''aide de la machine à voyager dans le temps conservée dans le sous-sol de la rédaction, nous avons pu explorer ce sombre avenir du jeu vidéo et, échappant aux drones de la société Skynet (Nasdaq : SKNT), de vous en ramener quelques fragments.', '2023-07-30 19:55:54.061115 +00:00', '2023-07-14 06:00:07.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (25, 'https://www.canardpc.com/?p=51654', 'Affaire des photomontages : la lutte entre Quantic Dream et un ancien salarié continue', 'https://www.canardpc.com/jeu-video/dossier-jeu-video/affaire-des-photomontages-la-lutte-entre-quantic-dream-et-un-ancien-salarie-continue/', 'Quelques jours à peine après avoir professé « les affaires sont loin derrière nous » sur BFM ou Notre temps, le directeur général de Quantic Dream, Guillaume de Fondaumière, était pourtant bien présent lors d’une audience à la cour d’appel de Paris, le 27 juin dernier. Une présence habituelle pour le numéro deux du studio français, qui a arpenté les tribunaux au cours de ces cinq dernières années. L’affaire des photomontages documentée en 2018 dans les colonnes de Mediapart, Le Monde et Canard PC aura en effet été le catalyseur de nombreuses procédures.', '2023-07-30 19:55:54.061117 +00:00', '2023-07-13 06:00:13.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (26, 'https://www.canardpc.com/?post_type=news&p=51754', 'Les jouets du patrimoine', 'https://www.canardpc.com/news/les-jouets-du-patrimoine/', 'La Video Game History Foundation (attention, c''est du lourd, le gratin des gamers historiens, des types qui portent une veste en tweed avec des patchs aux coudes par-dessus leur T-shirt Super Mario) vient de publier une étude selon laquelle seuls 13 % des jeux vidéo sortis avant 2010 seraient encore disponibles sur des canaux officiels. Le seul maigre espoir de mettre la main dessus restant le piratage (hors grands classiques, bon courage pour trouver les titres qui ont plus de dix ans) et les instituts de conservation type BNF (ce qui nécessite d''avoir accès à leurs collections). Détail amusant, le taux de survie des jeux d''avant 2010 est à peu près égal à celui des films muets. LFS.', '2023-07-30 19:55:54.061118 +00:00', '2023-07-12 08:00:05.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (27, 'https://www.canardpc.com/?p=51734', 'Oxenfree 2 : Lost Signals', 'https://www.canardpc.com/jeu-video/test-jeu-video/oxenfree-2-lost-signals-2/', 'Il y a sept ans, Oxenfree insufflait un peu de nouveauté au walking sim (que je vais arbitrairement traduire par « simulateur de balade ») en inventant le concept de walking and talking sim (que je vais arbitrairement traduire par « simulateur de balade et de blabla »). Laissez-moi désormais vous parler avec l''arrogance si caractéristique des gens qui viennent d''inventer un nouveau terme que personne n''utilisera jamais : Oxenfree 2 ne révolutionnera pas le genre du simulateur de balade et de blabla.', '2023-07-30 19:55:54.061119 +00:00', '2023-07-12 07:00:50.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (28, 'https://www.canardpc.com/?p=51579', 'Le bureau du mois de juillet 2023', 'https://www.canardpc.com/jeu-video/bureaulogie/le-bureau-du-mois-de-juillet-2023/', 'Chaque mois, nous mettons à l''honneur le bureau d''un de nos lecteurs et en profitons pour le disséquer. Le bureau, pas le lecteur. Ce mois-ci : « Lumineuse simplicité » par Gimpster_Jovial.', '2023-07-30 19:55:54.061121 +00:00', '2023-07-12 06:00:14.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (49, 'https://www.canardpc.com/?p=50891', 'Curiosity Stream + Nebula', 'https://www.canardpc.com/jeu-video/papier-culture/curiosity-stream-nebula/', 'L''algorithme de YouTube étant ce qu''il est (c''est-à-dire une machine à promouvoir du contenu destiné aux adolescents en échec scolaire), j''ai de plus en plus de mal à y trouver ce que j''aime : des documentaires et des vidéos un peu soignées sur des sujets comme la science, l''ingénierie, l''histoire.', '2023-07-30 19:55:54.061149 +00:00', '2023-06-30 06:00:47.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (29, 'https://www.canardpc.com/?post_type=news&p=51773', 'IA pas le feu au lac', 'https://www.canardpc.com/news/ia-pas-le-feu-au-lac/', 'Il y a quelques jours, Steam a banni un jeu contenant des assets générés par une IA. C’est du moins ce que rapporte un développeur sur Reddit, qui explique avoir reçu un message de la part de la plateforme : « Si nous cherchons à publier la plupart des jeux qui nous sont soumis, nous ne pouvons pas valider de titres pour lesquels le développeur ne possède pas tous les droits nécessaires », pouvait-on notamment y lire. Une prise de position tempérée par la porte-parole de Valve, Kaci Boyle, auprès de Gizmodo : « Nous savons que [l’IA] est une technologie en constante évolution, et notre but n’est pas d’en décourager l’utilisation sur Steam. Au contraire, nous cherchons à l’intégrer dans notre politique d’évaluation déjà existante. » Je suis franchement déçue, car j’aurais au moins aimé avoir une petite pensée réconfortante en ce bas monde en sachant que même Gabe Newell était capable de choix éthiques – et que dès que j’écumerais les tréfonds de Steam, ses clones foireux et ses multiples simulateurs de drague immondes impliquant de batifoler avec des dictateurs, j’aurais au moins la certitude que ces derniers auront été façonnés par un être humain. ER', '2023-07-30 19:55:54.061122 +00:00', '2023-07-11 17:30:12.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (30, 'https://www.canardpc.com/?p=51523', 'BattleBit Remastered', 'https://www.canardpc.com/jeu-video/en-chantier/battlebit-remastered/', 'BattleBit Remastered est bien parti pour être le Vampire Survivors de 2023 : un jeu inattendu, tout moche, sans budget marketing, bricolé par des inconnus, qui se vend à des millions d''exemplaires en narguant les productions AAA.', '2023-07-30 19:55:54.061123 +00:00', '2023-07-11 06:00:58.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (31, 'https://www.canardpc.com/?p=51553', 'Starship Troopers : Extermination', 'https://www.canardpc.com/jeu-video/en-chantier/starship-troopers-extermination/', '« Rien n''est plus puissant qu''une idée dont le temps est venu », écrivait Victor Hugo, qui par ailleurs écrivait aussi, dans Les Travailleurs de la mer, « Elle a un seul orifice. Est-ce l''anus ? Est-ce la bouche ? C''est les deux », alors bon, je ne sais pas si on peut faire confiance à ce genre de vieux crado. Toujours est-il que le temps des adaptations de Starship Troopers est venu. Après Terran Command et le SNU, voici venu Extermination.', '2023-07-30 19:55:54.061124 +00:00', '2023-07-10 06:00:59.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (32, 'https://www.canardpc.com/?post_type=news&p=51616', 'Bande d’Hippocrates', 'https://www.canardpc.com/news/bande-dhippocrates/', 'Chaque année, de pauvres gens sont frappés d’une grave maladie. Cette affection, la molyneusite, gâche des vies. Mensonges, exagération permanente, addiction aux NFT, les symptômes sont lourds et nombreux. Peter est né avec, il a dû subir le regard des autres, mais il sait son état et se soigne. Interrogé par Gamereactor, il montre ses incroyables progrès : « Dans le passé, j’aurais commencé par vous parler de l’ensemble du jeu, de sa conception et des raisons pour lesquelles il allait être le plus brillant du monde […], je ne vais pas faire cela. » Mais la réalité est implacable et la rechute inévitable lorsqu’il évoque le futur projet de 22Cans et parle d’une mécanique « qui n’a jamais été vue dans un jeu auparavant ». Ne lâche rien Peter, la recherche continue et nos chercheurs ne renoncent pas. Envoyez vos dons à la Molyneusite Yankee Team Healthcare Organisation (MYTHO). Aidez-nous, aidez-les. P.', '2023-07-30 19:55:54.061126 +00:00', '2023-07-07 13:00:20.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (33, 'https://www.canardpc.com/?p=51584', 'Champions !', 'https://www.canardpc.com/jeu-de-plateau/test-jeu-de-plateau/champions/', 'Il faut toujours laisser sa chance au produit. Cette maxime que me martelait Kahn Lusth ne m’a jamais quitté. Il avait juste oublié de me dire « bon, après y a des fois où le produit il veut vraiment pas la prendre, sa chance ».', '2023-07-30 19:55:54.061127 +00:00', '2023-07-07 06:00:27.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (34, 'https://www.canardpc.com/?p=51466', 'Sans filtre', 'https://www.canardpc.com/jeu-video/papier-culture/sans-filtre/', 'J’ai un petit souci avec le réalisateur suédois Ruben Östlund, notamment connu pour The Square, Snow Therapy et tout un tas de films placés sous le signe de l''humour noir qui se moquent allègrement de la bourgeoisie (pour résumer grossièrement).', '2023-07-30 19:55:54.061128 +00:00', '2023-07-07 06:00:10.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (35, 'https://www.canardpc.com/?post_type=news&p=51576', 'Télex', 'https://www.canardpc.com/news/51576/', 'Voilà ce qui se passe à trop faire attendre les gens : ils se débrouillent. Alors qu’on est toujours sans nouvelles d’Hollow Knight : Silksong, des fans sortent gratuitement et légalement Pale Court, un DLC complet et impressionnant. Une bonne leçon : ce qu’on gagne en coûts de communication, on le perd en frais d’avocat. P.', '2023-07-30 19:55:54.061130 +00:00', '2023-07-07 06:00:01.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (36, 'https://www.canardpc.com/?p=49783', 'Derelicts', 'https://www.canardpc.com/jeu-video/a-venir-jeu-video/derelicts/', 'Les studios indés aiment beaucoup se trouver des noms un peu rigolos, décalés, genre Crazy Capybara Games ou YOLO 420 Entertainment. Le créateur français de Derelicts, lui, ne s''est pas embêté. Sur la page Steam du jeu, à la case Développeur, il a juste mis « Romain », son prénom. Et son éditeur ? « Romain » aussi, comme ça, c''est plus simple.', '2023-07-30 19:55:54.061131 +00:00', '2023-07-07 06:00:00.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (37, 'https://www.canardpc.com/?post_type=news&p=51573', 'BattleBit Pic', 'https://www.canardpc.com/news/battlebit-pic/', 'BattleBit Remastered, le Battlefield en low poly dégraissé jusqu’à la couenne – et c’est un compliment – cartonne. Début juillet, le jeu en accès anticipé s’était vendu à 1,8 million d’exemplaires, en à peine quinze jours. C’est en réalité le fruit de sept ans de travail, par trois développeurs, et de beaucoup de modifications, bêtas et remises à plat. Le site howtomarketagame a discuté avec l’un d’eux et revient sur tous ces points, pour découvrir la recette du succès. Sans surprise, on y lit qu’il faut que le jeu soit bon, que son look doit coller avec son gameplay, qu’il faut « garder les attentes [des joueurs] basses et les surprendre », et ne pas hésiter à faire des changements drastiques, même lorsque c’est un crève-cœur. Ajoutez l’absence de microtransactions et le bouche-à-oreille et c’est cuit. Interrogé, Yves Guillemot déclare : « Pardon ? J’ai pas suivi après "garder les attentes des joueurs basses" ». P.', '2023-07-30 19:55:54.061134 +00:00', '2023-07-06 13:00:54.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (38, 'https://www.canardpc.com/?p=51516', 'The Outlast Trials', 'https://www.canardpc.com/jeu-video/en-chantier/the-outlast-trials/', 'J''ai su que j''étais devenu vieux quand j''ai découvert American Horror Story. C''est donc ça, me suis-je dit, le genre d''horreur qu''aiment les jeunes ? Couche après couche de scènes gore et malsaines, montées et jouées de façon frénétique ? Manque de bol, l''ambiance de The Outlast Trials m''a immédiatement fait penser à la série de Ryan Murphy. Ce qui suffit à en faire un très mauvais jeu d''horreur. Tant mieux, car c''est un excellent jeu d''infiltration.', '2023-07-30 19:55:54.061135 +00:00', '2023-07-06 06:00:35.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (71, 'https://www.lemonde.fr/international/article/2023/07/30/incendie-sur-un-cargo-au-large-des-pays-bas-le-remorquage-a-debute_6183920_3210.html', 'Incendie sur un cargo au large des Pays-Bas : le remorquage a débuté', 'https://www.lemonde.fr/international/article/2023/07/30/incendie-sur-un-cargo-au-large-des-pays-bas-le-remorquage-a-debute_6183920_3210.html', 'Un incendie s’est déclaré dans la nuit de mercredi 26 juillet sur le navire qui transporte plus de 3 700 voitures. Prévu samedi, le remorquage avait dû être décalé en raison du vent.', '2023-07-30 22:45:28.233885 +00:00', '2023-07-30 18:07:04.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (39, 'https://www.canardpc.com/?post_type=news&p=51568', 'Annapurna Wintour', 'https://www.canardpc.com/news/annapurna-wintour/', 'Annapurna Interactive a tenu sa conférence estivale pour montrer des nouveautés et donner des nouvelles des trucs qu’on avait notés-mais-oubliés-oulala-ma-bonne-dame-ma-mémoire-c’est-plus-ce-que-c’était. Entre autres choses, on a enfin une date pour Thirsty Suitors, qui mélange toujours combat, skateboard, cuisine et Bollywood : ce sera le 2 novembre. De nouveaux personnages, scénarios et challenges pour Storyteller arrivent, gratos, le 26 septembre, et le jeu débarque sur Netflix. Le prochain Keita Takahashi (Katamari Damacy) n’a pas de date, mais remporte d’ores et déjà la palme du trailer le plus chelou de l’année : dans To a T, on incarnera un garçon coincé en T-pose. On aperçoit le jeu de vélo Ghost Bike, des Américains de Messhof (Nidhogg et Nidhogg 2), et un Blade Runner 2033 : Labyrinth, dont on n''a que deux infos : ce ne sera pas un party game et je harcèlerai Ellen Replay pour qu’il finisse dans mes mains avides. P.', '2023-07-30 19:55:54.061136 +00:00', '2023-07-05 13:00:06.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (40, 'https://www.canardpc.com/?p=51436', 'Mars First Logistics', 'https://www.canardpc.com/jeu-video/en-chantier/mars-first-logistics/', 'Je suis un enfant des années 1980, du socialisme réaliste, de la Mitterrandie. Ma jeunesse fut donc difficile. Je devais donner mon argent de poche aux orphelins soviétiques, et je n''avais pas droit aux cadeaux de Noël, car c''était « un concept bourgeois ».', '2023-07-30 19:55:54.061137 +00:00', '2023-07-05 06:00:55.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (41, 'https://www.canardpc.com/?post_type=news&p=51550', 'Télex', 'https://www.canardpc.com/news/telex-187/', 'Après la claque prise à la sortie de The Lord of the Rings : Gollum, Daedalic se sépare de 25 personnes sur un peu plus de 90, et va « se concentrer sur l’édition », abandonnant le développement. Tous les projets sont annulés, même une suite de… ah oui je comprends, une suite de The Lord of the Rings : Gollum. P.', '2023-07-30 19:55:54.061139 +00:00', '2023-07-04 13:00:22.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (42, 'https://www.canardpc.com/?post_type=news&p=51547', 'Que des bobarres !', 'https://www.canardpc.com/news/que-des-bobarres/', 'Au fond de vous, vous le saviez, mais n’osiez l’avouer à voix haute : oui, les barres de chargement des jeux vidéo sont plus pipeautées qu’une interview de PPDA, et depuis toujours. Pire, c’est souvent volontaire. Répondant à une blague du scénariste du très bon Unforeseen Incidents, des tas de développeurs ont admis et détaillé leurs petites combines. De Mike Bithell (Thomas was Alone), qui explique qu’il est obligé, car les joueurs « ne font pas confiance à une barre de chargement régulière » et qu’il faut des bégaiements et des pauses, à Rami Ismail qui ne se souvient pas avoir jamais codé une barre de chargement qui ne soit pas du bullshit, tout le monde rigole et ne réalise pas la portée de cette rupture du contrat de confiance. Et demain, va-t-on apprendre que les jeux ont des murs invisibles ? que les RPG ne se souviennent pas vraiment de nos choix ? que certaines scènes cachent des temps de chargement ? The cake is a lie. P.', '2023-07-30 19:55:54.061140 +00:00', '2023-07-04 06:00:22.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (43, 'https://www.canardpc.com/?p=50924', 'NieR : Automata', 'https://www.canardpc.com/jeu-video/on-y-joue-encore/nier-automata/', 'L’autre jour, en soirée, je parlais jeu vidéo avec une femme, et je lui ai glissé, toute heureuse de parler de mon expérience, que je passais mon temps sur NieR : Automata. Je m’attendais à beaucoup de réactions, mais pas à sa réponse : « Ah, c’est pas le jeu gênant où on avait un succès pour mater la culotte d’une meuf ? »', '2023-07-30 19:55:54.061141 +00:00', '2023-07-04 06:00:17.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (44, 'https://www.canardpc.com/?post_type=news&p=51544', 'Cyberpunk is not dead', 'https://www.canardpc.com/news/cyberpunk-is-not-dead-2/', 'Vous connaissez la recette du bonheur ? Pas le travail sur soi, pas la méditation, pas la remise en question. Non, la vraie recette, c’est réécrire la réalité pour l’adoucir. Prenez exemple sur Michał Platkow-Gilewski, vice-président des relations publiques chez CD Projekt. Revenant sur la sortie compliquée de Cyberpunk 2077 lors d’une interview chez Gamesindustry, il commence par reconnaître que la route a été « cahoteuse » et que la relation entre le studio et les joueurs est « à reconstruire », puis lâche prise : « Je pense que Cyberpunk était bien meilleur qu’il n’a été reçu au lancement, même les premières critiques étaient positives. Ensuite, c’est devenu cool de ne pas l’aimer. Nous sommes passés de héros à zéros très vite. » Les bugs partout, même sur la version PC, la moins abîmée de toutes ? Sans doute des features incomprises. Le retrait du jeu par Sony sur le PlayStation Store ? Vraisemblablement une blague potache. Le crunch ? Une légende urbaine. P.', '2023-07-30 19:55:54.061143 +00:00', '2023-07-03 13:00:53.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (45, 'https://www.canardpc.com/?post_type=news&p=51524', 'La fine esquive', 'https://www.canardpc.com/news/la-fine-esquive/', 'La bonne technique pour ne pas perdre une bataille reste d’éviter les gnons, et les Belges de Larian Studios viennent d’en faire la démonstration. Réalisant sans doute que sortir un jeu fin août, quelques jours avant la sortie du mastodonte Starfield, n’était pas une bonne idée, ils ont fouillé le grimoire de sorts de Baldur’s Gate 3, trouvé une incantation de manipulation du temps et annoncé que le jeu sortirait finalement d’accès anticipé… en avance (sur PC, les consoleux attendront la rentrée) ! C’est donc dès le 3 août, et non le 31, que vous pourrez vous replonger dans les Royaumes Oubliés et visiter Montprofond, le réseau de cavernes qui se cache dessous. On pourra alors enfin trancher le débat entre L.-F. Sébum et Izual, le premier estimant que mélanger D&D et Original Sin donnera une adaptation exceptionnelle, le second que c’est un clonage dépassé qui mérite le fouet. P.', '2023-07-30 19:55:54.061144 +00:00', '2023-07-03 06:00:09.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (46, 'https://www.canardpc.com/?p=51023', 'Planet of Lana', 'https://www.canardpc.com/jeu-video/test-jeu-video/planet-of-lana-2/', 'Planet of Lana m’a évoqué les internautes qui traînent dans la partie commentaires du site Marmiton. Prenez n’importe quelle recette (au hasard, celle du risotto de quinoa aux champignons), et vous trouverez toujours une personne pour commenter « Excellente recette ! J’ai modifié le quinoa par des pipe rigate, les champignons par de la poutargue et l’huile d’olive par du vinaigre balsamique, mais c’était vraiment délicieux ! ».', '2023-07-30 19:55:54.061145 +00:00', '2023-07-03 06:00:03.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (47, 'https://www.canardpc.com/?post_type=news&p=51471', 'Télex', 'https://www.canardpc.com/news/telex-186/', 'Alan Wake 2, prévu pour le 17 octobre, sera uniquement disponible en version dématérialisée. Pour des raisons de coût ? Pour économiser les ressources de notre mère Gaïa ? Du tout. Le démat'', explique Remedy, permettra au studio « d''avoir plus de temps pour finaliser le jeu ». Voilà qui sent bon le développement bien planifié et sans crunch. LFS.', '2023-07-30 19:55:54.061147 +00:00', '2023-06-30 12:00:21.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (48, 'https://www.canardpc.com/?p=50992', 'La caravane patche de juin 2023', 'https://www.canardpc.com/jeu-video/la-caravane-patche/la-caravane-patche-de-juin-2023/', 'Sous les projos : Minecraft. Peu d’encre coule dans ces pages (et heureusement, on s’en mettrait plein les doigts) à propos de Minecraft, un p’tit jeu mobile assez connu dans le milieu, qui s’offre en moyenne une mise à jour par an. La dernière vient de tomber, alors profitons-en pour le mettre à l’honneur.', '2023-07-30 19:55:54.061148 +00:00', '2023-06-30 06:00:50.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (50, 'https://www.canardpc.com/?post_type=news&p=51458', 'La rubrique des gens patients', 'https://www.canardpc.com/news/la-rubrique-des-gens-patients/', 'Dans cette permanente course à l''échalote (je ne sais pas si vous avez déjà essayé de rattraper une échalote, ça court vite ces machins) qui anime l''industrie du jeu vidéo, il peut parfois être bon de faire une pause et de regarder au loin, en plus ça nous fera du bien aux yeux, on est tous myopes à force de fixer des écrans toute la journée. Notons donc ces deux informations absolument pas urgentes. La première, c''est que The Elder Scrolls 6, sans surprise, ne sortira pas avant « au moins cinq ans », c''est Phil Spencer lui-même qui le confirme. La seconde information, trouvée dans des documents rendus publics lors de l''audience pour le rachat d''Activision-Blizzard, est que Microsoft n''attend pas la nouvelle génération de consoles avant 2028. LFS.', '2023-07-30 19:55:54.061150 +00:00', '2023-06-30 06:00:15.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (51, 'https://www.canardpc.com/?p=51304', 'Infinite Monkeys', 'https://www.canardpc.com/jeu-video/cabinet-de-curiosites/infinite-monkeys/', 'Combien de temps faudrait-il à un singe pour écrire les œuvres complètes de Shakespeare ? À cette célèbre question, j''ai envie de répondre « pas tant que ça », Shakespeare (qui était lui-même un primate, même s''il cherchait à le faire oublier en portant une énorme fraise) ayant réussi à le faire de son vivant alors qu''il est mort à seulement 52 ans. Mais on va encore me dire que je suis passé à côté du sujet, contrairement à Infinite Monkeys, qui nous intéresse aujourd''hui.', '2023-07-30 19:55:54.061152 +00:00', '2023-06-29 13:00:27.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (52, 'https://www.canardpc.com/?p=51313', 'Park Beyond', 'https://www.canardpc.com/jeu-video/test-jeu-video/park-beyond-3/', 'On croit qu’on est tous égaux à Canard PC, mais c’est faux. Il y a ceux qui ont vingt-cinq années d’expérience et il y a les autres. Par exemple, quand j’ai dit que j’étais chaud pour tester le si coloré Park Beyond, ackboo m’a regardé avec un sourire narquois. « Ben quoi, ça ne va pas être bien ? – Ah si si si, si si, vas-y fonce ! » Vingt-cinq années.', '2023-07-30 19:55:54.061153 +00:00', '2023-06-29 06:00:53.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (53, 'https://www.canardpc.com/?p=50949', 'Dead Cells', 'https://www.canardpc.com/jeu-video/autopsie/dead-cells-2/', 'Depuis sa sortie en accès anticipé en 2017, le jeu français Dead Cells s’est écoulé à plus de dix millions d’exemplaires. Il constitue un tournant majeur dans l’histoire du studio bordelais Motion Twin, fondé en 2001 sur le modèle d’une SCOP (pour « société coopérative de production », dont chaque salarié est associé), devenue un symbole de réussite et de différence dans une industrie réputée pour ses méthodes de travail parfois déplorables, entre crunchs à répétition et affaires de harcèlement.', '2023-07-30 19:55:54.061154 +00:00', '2023-06-29 06:00:52.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (54, 'https://www.canardpc.com/?post_type=news&p=51456', 'Stadia terminal', 'https://www.canardpc.com/news/stadia-terminal-2/', 'Un peu comme moi avec les restes pendant la semaine qui a suivi ma fête d''anniversaire, Google ne sait pas quoi faire de Stadia, qui encombre ses poubelles depuis un bon moment. Il faut dire que la technologie était plutôt au point, même si les clients n''ont pas suivi, et qu''il serait dommage de jeter tout ça, ça n''est pas périssable, contrairement à ces huîtres qui m''ont rendu malade mais passons. La dernière tentative porte le nom rigolo de « Playables » et prendrait la forme de jeux vidéo disponibles directement sur YouTube. Une idée pas bête sachant que (1) YouTube a une audience énorme, (2) c''est la plateforme parfaite pour diffuser de la vidéo en streaming et (3) réunir au même endroit les gamers et les gens qui écrivent des commentaires sur YouTube, soit les deux pires catégories d''êtres humains, tombe sous le sens. LFS.', '2023-07-30 19:55:54.061155 +00:00', '2023-06-29 06:00:32.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (55, 'https://www.canardpc.com/?post_type=news&p=51454', 'La nuit du tentacule', 'https://www.canardpc.com/news/la-nuit-du-tentacule/', 'Si vous ne savez pas quoi faire ce soir mais que vous avez bon goût en matière d''humour et de jeux vidéo, bonne nouvelle, j''ai une solution pour vous. OnARetroTip, youtubeur spécialisé dans le rétrogaming, a posté sur sa chaîne un documentaire de deux heures sur Day of the Tentacle. Vous y trouverez des images d''archive et des interviews de ses créateurs, qui parlent de l''écriture du jeu, du développement, de la musique et même des personnages qui devaient y figurer mais n''ont pas été retenus. Le tout présenté dans un format 4:3 d''époque pas agréable du tout car il serait dommage qu''un excellent travail ne soit pas gâché par une décision hasardeuse. LFS.', '2023-07-30 19:55:54.061157 +00:00', '2023-06-28 12:00:24.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (56, 'https://www.canardpc.com/?p=50966', 'Star Wars : Outlaws', 'https://www.canardpc.com/jeu-video/a-venir-jeu-video/star-wars-outlaws/', 'Épisode 2024 : un nouvel espoir. C''est une époque de guerre civile. Secoué par un rapport annuel catastrophique, l''Empire Ubisoft vient de connaître sa première grande défaite. Mais tout n''est pas perdu. Depuis leur base cachée de Malmö (Suède), les développeurs d''Ubisoft Massive travaillent en secret sur leur arme absolue : un nouveau jeu Star Wars, assez puissant pour redresser à lui seul le cours de l''action Ubisoft.', '2023-07-30 19:55:54.061158 +00:00', '2023-06-28 07:00:56.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (57, 'https://www.canardpc.com/?p=48851', 'Fog of Love', 'https://www.canardpc.com/jeu-de-plateau/test-jeu-de-plateau/fog-of-love/', 'Ah, l’amour ! Un thème rarement abordé par les jeux de plateau, sans doute parce qu’il est plus simple de modéliser la guerre ou la conquête que la fragile ligne du doute et l’épineuse question de la lunette des toilettes.', '2023-07-30 19:55:54.061159 +00:00', '2023-06-28 06:00:55.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (58, 'https://www.canardpc.com/?post_type=news&p=51452', 'I want to believe', 'https://www.canardpc.com/news/i-want-to-believe/', 'Cela n''aura pas échappé aux plus vieux d''entre vous, ainsi qu''aux amateurs de mode masculine. Lors de la dernière conférence Xbox, Phil Spencer portait un T-shirt Hexen. « Pourquoi ? », se sont demandé bien des spécialistes, qui remarquaient que le nom Hexen parlait autant aux jeunes que celui de Maurice Chevalier. Voici donc mon analyse de crackpot. Le possible rachat d''Activision par Microsoft réunirait au sein de la même entité les deux entreprises qui se partageaient la propriété de la licence : Raven (propriété d''Activision) et id Software (propriété de Bethesda), ce qui rendrait possible, vingt-cinq ans plus tard, la sortie d''un nouvel épisode. Si on ajoute à ça le fait que Spencer s''était réjoui d''avoir découvert Hexen au sein des licences acquises par Microsoft, vous comprendrez pourquoi I want to believe, comme on dit. LFS.', '2023-07-30 19:55:54.061160 +00:00', '2023-06-28 06:00:26.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (59, 'https://www.canardpc.com/?post_type=news&p=51461', 'Télex', 'https://www.canardpc.com/news/telex-185/', 'Si vous avez eu du mal à jouer à Diablo 4 fin juin, c''est normal, ses serveurs ont été victimes d''un DDOS bien violent. Qu''en conclure ? Qu''il est stupide de créer des jeux qui nécessitent une connexion pour jouer en solo. Et que Blizzard est devenu feignant : au lancement de Diablo 3, ils n''avaient eu besoin de l''aide de personne d''autre pour se DDOSer tout seuls. LFS.', '2023-07-30 19:55:54.061162 +00:00', '2023-06-27 12:00:39.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (60, 'https://www.canardpc.com/?p=50764', 'Lies of P', 'https://www.canardpc.com/jeu-video/a-venir-jeu-video/lies-of-p/', 'Qu’il s’agisse du conte de Carlo Collodi, du dessin animé de Walt Disney ou de la version de Guillermo del Toro, Pinocchio m’a toujours traumatisée. Comment pourrait-il en être autrement avec une histoire qui, au gré de ses adaptations, a impliqué une marionnette qui se fait pendre, envoyer dans une île peuplée d’enfants destinés à être transformés en ânes et vendus dans des cirques, puis avaler par une baleine ? J’étais donc naturellement prête à finir en position fœtale devant Lies of P.', '2023-07-30 19:55:54.061163 +00:00', '2023-06-27 06:00:08.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (61, 'https://www.canardpc.com/?post_type=news&p=51450', 'La tête dans les étoiles mais les pieds sur terre', 'https://www.canardpc.com/news/la-tete-dans-les-etoiles-mais-les-pieds-sur-terre/', 'On en apprend de belles au cours de l''audience concernant le rachat d''Activision Blizzard par Microsoft, notamment la raison qui a poussé ce dernier à se hâter d''acquérir Zenimax-Bethesda. Sony prenant de plus en plus l''habitude de verser des pourliches aux éditeurs pour s''assurer que leurs jeux seraient des exclusivités PlayStation, Microsoft commençait à prendre peur qu''il commence à reluquer du côté de Bethesda et lui propose une grosse mallette de billets pour ne pas sortir Starfield sur Xbox. Ne pouvant tolérer cela, l''ex-boîte à Bill Gates a préféré sortir son gigantesque chéquier et claquer sept milliards pour acheter Zenimax. Un move d''une audace jamais vue depuis ce jour où la maire de Puteaux avait fait acheter tous les exemplaires du Canard enchaîné de sa commune pour éviter que ses administrés n''y découvrent ses frasques. LFS.', '2023-07-30 19:55:54.061164 +00:00', '2023-06-27 06:00:05.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (62, 'https://www.canardpc.com/?p=50724', 'Les coups de projo de Canard PC', 'https://www.canardpc.com/jeu-video/a-venir-jeu-video/les-coups-de-projo-de-canard-pc/', 'Tandis que les feux de la rampe restent braqués sur les énormes blockbusters de l’E3, des jeux plus modestes se montrent timidement entre la bande-annonce d’un Mortal Kombat et une pub pour Porsche. Ce serait dommage qu’ils restent dans l’ombre, même si c’est l’été et qu’à l’ombre, il fait meilleur. Traînons-les donc face aux feux du soleil, après les avoir dûment couverts de crème solaire.', '2023-07-30 19:55:54.061165 +00:00', '2023-06-27 06:00:00.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (63, 'https://www.canardpc.com/?p=50325', 'Streaming musical : le blues du mélomane', 'https://www.canardpc.com/hardware/elucubrations/streaming-musical-le-blues-du-melomane/', 'En tant que passionné de musique classique, je peux l’affirmer : pour nous autres, le streaming n’est pas du tout le même genre de bénédiction que pour le reste du monde. Parce que nous ne sommes que des vieux cons réfractaires aux nouvelles technologies ? Mais non, allons. Enfin, pas tous. Non, c’est surtout parce que la façon dont on écoute du classique s’accorde mal avec l’essence même du streaming. Et cela, paradoxalement, le lancement fin mars d’Apple Music Classical l’a parfaitement mis en lumière.', '2023-07-30 19:55:54.061167 +00:00', '2023-06-26 12:00:56.000000 +00:00', 1); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (64, 'https://www.lemonde.fr/international/live/2023/07/30/guerre-en-ukraine-en-direct-invoquant-la-faim-dans-le-monde-le-pape-francois-appelle-la-russie-a-se-reengager-dans-l-accord-cerealier_6183864_3210.html', 'Guerre en Ukraine, en direct : « Progressivement, la guerre revient sur le territoire russe, c’est un processus inévitable », déclare Volodymyr Zelensky', 'https://www.lemonde.fr/international/live/2023/07/30/guerre-en-ukraine-en-direct-invoquant-la-faim-dans-le-monde-le-pape-francois-appelle-la-russie-a-se-reengager-dans-l-accord-cerealier_6183864_3210.html', 'Le président ukrainien s’est exprimé sur la messagerie Telegram, lors de son adresse quotidienne, en marge d’une visite à Ivano-Frankivsk, dans l’ouest du pays.', '2023-07-30 22:45:28.233871 +00:00', '2023-07-30 13:45:24.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (65, 'https://www.lemonde.fr/series-d-ete/article/2023/07/30/cannabis-les-mysteres-de-l-herbe-du-diable_6183918_3451060.html', 'Cannabis, les mystères de l’« herbe du diable »', 'https://www.lemonde.fr/series-d-ete/article/2023/07/30/cannabis-les-mysteres-de-l-herbe-du-diable_6183918_3451060.html', '« Le roman du cannabis » (1/6). « Le Monde » retrace, dans une série d’articles, la rencontre de l’Occident avec cette drogue. A la fois populaire et méconnue, elle est devenue, au fil du temps, un enjeu économique majeur et l’objet de bien des trafics.', '2023-07-30 22:45:28.233876 +00:00', '2023-07-30 18:00:00.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (66, 'https://www.lemonde.fr/les-decodeurs/article/2023/07/30/ambassade-francaise-ciblee-au-niger-attaques-de-drones-en-russie-mondiaux-de-natation-les-cinq-infos-a-retenir-du-week-end_6183910_4355770.html', 'Ambassade française ciblée au Niger, attaques de drones en Russie, Mondiaux de natation… Les cinq infos à retenir du week-end', 'https://www.lemonde.fr/les-decodeurs/article/2023/07/30/ambassade-francaise-ciblee-au-niger-attaques-de-drones-en-russie-mondiaux-de-natation-les-cinq-infos-a-retenir-du-week-end_6183910_4355770.html', 'Vous n’avez pas suivi l’actualité, samedi 29 et dimanche 30 juillet ? Voici ce qu’il s’est passé ces dernières quarante-huit heures.', '2023-07-30 22:45:28.233877 +00:00', '2023-07-30 16:35:36.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (67, 'https://www.lemonde.fr/afrique/article/2023/07/30/coup-d-etat-au-niger-des-milliers-de-manifestants-pro-putsch-rassembles-devant-l-ambassade-de-france-a-niamey_6183892_3212.html', 'Coup d’Etat au Niger : la Cedeao exige un retour à l’ordre d’ici une semaine, slogans antifrançais et drapeaux russes à Niamey', 'https://www.lemonde.fr/afrique/article/2023/07/30/coup-d-etat-au-niger-des-milliers-de-manifestants-pro-putsch-rassembles-devant-l-ambassade-de-france-a-niamey_6183892_3212.html', 'Un rassemblement soutenant le coup d’Etat militaire a ciblé l’ambassade française ce dimanche dans la capitale du Niger. La France a condamné les violences devant son ambassade, et appelé les autorités à assurer la sécurité.', '2023-07-30 22:45:28.233879 +00:00', '2023-07-30 10:30:54.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (68, 'https://www.lemonde.fr/series-d-ete/article/2023/07/30/donjons-dragons-face-a-une-double-menace-existentielle_6183914_3451060.html', 'Donjons & Dragons face à une double menace existentielle', 'https://www.lemonde.fr/series-d-ete/article/2023/07/30/donjons-dragons-face-a-une-double-menace-existentielle_6183914_3451060.html', '« Donjons & Dragons, la saga d’un jeu » (6/6). Inventé en 1974, le premier jeu de rôle affronte deux dangers en 2023 : la remise en cause de son modèle économique inspiré du logiciel libre et la critique virulente de ses biais culturels.', '2023-07-30 22:45:28.233880 +00:00', '2023-07-30 17:00:13.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (69, 'https://www.lemonde.fr/sport/article/2023/07/30/demi-vollering-remporte-le-tour-de-france-femmes-2023-titre-majeur-d-une-saison-exceptionnelle_6183915_3242.html', 'Demi Vollering remporte le Tour de France Femmes 2023, titre majeur d’une saison exceptionnelle', 'https://www.lemonde.fr/sport/article/2023/07/30/demi-vollering-remporte-le-tour-de-france-femmes-2023-titre-majeur-d-une-saison-exceptionnelle_6183915_3242.html', 'Pour la première fois de sa carrière, la Néerlandaise de 26 ans a remporté la Grande Boucle, dimanche, à Pau. la Suissesse Marlen Reusser s’est imposée lors de la dernière étape, un contre-la-montre de 22 kilomètres.', '2023-07-30 22:45:28.233883 +00:00', '2023-07-30 17:06:47.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (70, 'https://www.lemonde.fr/international/article/2023/07/30/centrafrique-a-bangui-une-journee-de-vote-sans-affluence-mais-sous-haute-securite_6183921_3210.html', 'Centrafrique : à Bangui, une journée de vote sans affluence mais sous haute sécurité', 'https://www.lemonde.fr/international/article/2023/07/30/centrafrique-a-bangui-une-journee-de-vote-sans-affluence-mais-sous-haute-securite_6183921_3210.html', 'Le référendum constitutionnel s’est déroulé sans heurts majeurs en République centrafricaine, où le président Touadera est accusé de vouloir s’octroyer « une présidence à vie ».', '2023-07-30 22:45:28.233884 +00:00', '2023-07-30 19:03:16.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (72, 'https://www.lemonde.fr/societe/article/2023/07/30/accident-de-la-route-trois-morts-et-plusieurs-blesses-dans-le-pas-de-calais_6183922_3224.html', 'Accident de la route : trois morts et plusieurs blessés dans le Pas-de-Calais', 'https://www.lemonde.fr/societe/article/2023/07/30/accident-de-la-route-trois-morts-et-plusieurs-blesses-dans-le-pas-de-calais_6183922_3224.html', 'Trois véhicules sont impliqués dans cette collision qui a eu lieu sur l’A26. Plusieurs personnes ont également été blessées, parmi lesquelles cinq sont « en urgence absolue ».', '2023-07-30 22:45:28.233886 +00:00', '2023-07-30 20:57:30.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (73, 'https://www.lemonde.fr/international/article/2023/07/30/le-xinjiang-region-ouigoure-qui-doit-devenir-chinoise-comme-les-autres_6183873_3210.html', 'Le Xinjiang, région ouïgoure qui doit devenir chinoise comme les autres', 'https://www.lemonde.fr/international/article/2023/07/30/le-xinjiang-region-ouigoure-qui-doit-devenir-chinoise-comme-les-autres_6183873_3210.html', 'Après l’internement massif de la minorité musulmane ouïgoure, la région semble entrer dans une nouvelle phase plus apaisée, que documente le photographe Gilles Sabrié. Les spécificités culturelles sont réduites au folklore. L’heure est à une sinisation accélérée.', '2023-07-30 22:45:28.233889 +00:00', '2023-07-30 04:00:28.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (74, 'https://www.lemonde.fr/politique/article/2023/07/30/reforme-des-retraites-publication-des-decrets-actant-la-fin-des-principaux-regimes-speciaux_6183888_823448.html', 'Réforme des retraites : les décrets actant la fin des principaux régimes spéciaux ont été publiés', 'https://www.lemonde.fr/politique/article/2023/07/30/reforme-des-retraites-publication-des-decrets-actant-la-fin-des-principaux-regimes-speciaux_6183888_823448.html', 'La suppression des régimes spéciaux sera effective à partir de septembre pour tous les nouveaux agents de la RATP, des industries électriques et gazières, des clercs de notaire et de la Banque de France. Les agents recrutés avant cette date continuent de bénéficier de ces régimes, mais ils n’échappent pas au report de l’âge de départ.', '2023-07-30 22:45:28.233890 +00:00', '2023-07-30 08:54:02.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (75, 'https://www.lemonde.fr/international/article/2023/07/30/pakistan-au-moins-39-personnes-tuees-lors-d-un-meeting-politique_6183903_3210.html', 'Pakistan : au moins 44 personnes tuées lors d’un meeting politique', 'https://www.lemonde.fr/international/article/2023/07/30/pakistan-au-moins-39-personnes-tuees-lors-d-un-meeting-politique_6183903_3210.html', 'Le bilan pourrait encore s’aggraver, avec plus d’une centaine de blessées après l’explosion dimanche d’une bombe dans le nord-ouest du pays.', '2023-07-30 22:45:28.233891 +00:00', '2023-07-30 13:55:44.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (76, 'https://www.lemonde.fr/culture/article/2023/07/30/the-weeknd-au-stade-de-france-un-lumineux-concert_6183899_3246.html', 'The Weeknd au Stade de France, un lumineux concert', 'https://www.lemonde.fr/culture/article/2023/07/30/the-weeknd-au-stade-de-france-un-lumineux-concert_6183899_3246.html', 'Le chanteur R’n’B canadien jouait à Paris, samedi, au Stade de France, et a offert la trentaine de tubes de son répertoire à un public conquis, dans une mise en scène spectaculaire.', '2023-07-30 22:45:28.233892 +00:00', '2023-07-30 12:50:51.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (77, 'https://www.lemonde.fr/series-d-ete/article/2023/07/29/je-n-aime-pas-ton-kit-lumiere-mais-heureusement-tu-es-jolie-alexia-alexi-regisseuse-en-lutte-contre-le-machisme-dans-la-musique_6183828_3451060.html', '« Je n’aime pas ton kit lumière, mais heureusement tu es jolie ! » : Alexia Alexi, régisseuse en lutte contre le machisme dans la musique', 'https://www.lemonde.fr/series-d-ete/article/2023/07/29/je-n-aime-pas-ton-kit-lumiere-mais-heureusement-tu-es-jolie-alexia-alexi-regisseuse-en-lutte-contre-le-machisme-dans-la-musique_6183828_3451060.html', '« Les pionnières » (6/6). La technicienne de 35 ans est parvenue à s’imposer dans les équipes des salles de concert et des festivals, toujours très masculines.', '2023-07-30 22:45:28.233893 +00:00', '2023-07-29 10:00:11.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (78, 'https://www.lemonde.fr/sport/article/2023/07/30/formule-1-max-verstappen-remporte-le-grand-prix-de-belgique-sa-dixieme-victoire-en-douze-courses_6183908_3242.html', 'Formule 1 : Max Verstappen remporte le Grand Prix de Belgique, sa dixième victoire en douze courses', 'https://www.lemonde.fr/sport/article/2023/07/30/formule-1-max-verstappen-remporte-le-grand-prix-de-belgique-sa-dixieme-victoire-en-douze-courses_6183908_3242.html', 'Avec la deuxième place de Sergio Pérez, Red Bull réalise le doublé et accentue son avance au classement du championnat du monde. Sur Alpine-Renault, Esteban Ocon et Pierre Gasly finissent respectivement 8ᵉ et 11ᵉ.', '2023-07-30 22:45:28.233895 +00:00', '2023-07-30 15:37:33.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (79, 'https://www.lemonde.fr/sport/article/2023/07/30/mondiaux-de-natation-derriere-les-locomotives-leon-marchand-et-maxime-grousset-les-bleus-restent-a-quai_6183901_3242.html', 'Mondiaux de natation : derrière les « locomotives » Léon Marchand et Maxime Grousset, les Bleus restent à quai', 'https://www.lemonde.fr/sport/article/2023/07/30/mondiaux-de-natation-derriere-les-locomotives-leon-marchand-et-maxime-grousset-les-bleus-restent-a-quai_6183901_3242.html', 'La France termine 4ᵉ au classement des nations avec quatre médailles d’or et six au total, toutes décrochées par les deux nageurs, sur les vingt et un tricolores engagés en individuel aux championnats du monde au Japon.', '2023-07-30 22:45:28.233896 +00:00', '2023-07-30 13:10:39.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (80, 'https://www.lemonde.fr/planete/live/2023/07/30/canicule-en-direct-dans-le-var-place-en-alerte-rouge-feux-de-forets-huit-massifs-forestiers-interdits-d-acces-dimanche_6183301_3244.html', 'Canicule, en direct : dans le Var, placé en alerte rouge feux de forêts, huit massifs forestiers interdits d’accès dimanche', 'https://www.lemonde.fr/planete/live/2023/07/30/canicule-en-direct-dans-le-var-place-en-alerte-rouge-feux-de-forets-huit-massifs-forestiers-interdits-d-acces-dimanche_6183301_3244.html', 'Dans les Bouches-du-Rhône, placées en alerte orange incendie face au risque « élevé » d’incendie, ce sont sept massifs qui sont interdits d’accès dimanche.', '2023-07-30 22:45:28.233897 +00:00', '2023-07-30 14:02:58.000000 +00:00', 2); +INSERT INTO items (id, guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) VALUES (81, 'https://www.lemonde.fr/les-decodeurs/article/2023/05/08/le-guide-critique-des-arguments-et-intox-climatosceptiques_6172472_4355770.html', 'Le guide critique des arguments et intox climatosceptiques', 'https://www.lemonde.fr/les-decodeurs/article/2023/05/08/le-guide-critique-des-arguments-et-intox-climatosceptiques_6172472_4355770.html', 'Confusions entre météo et climat, réécriture de l’histoire… Derrière des apparences parfois savantes, ceux qui nient la réalité du dérèglement climatique multiplient les assertions trompeuses, voire les biais souvent grossiers.', '2023-07-30 22:45:28.233899 +00:00', '2023-05-08 04:00:04.000000 +00:00', 2); + +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 79, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 78, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 77, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 4, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 5, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 6, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 7, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 8, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 9, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 10, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 11, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 12, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 16, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 17, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 18, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 19, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 20, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 21, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 22, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 23, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 24, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 25, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 26, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 27, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 28, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 29, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 30, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 31, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 32, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 33, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 34, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 35, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 36, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 37, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 38, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 39, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 40, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 41, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 42, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 43, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 44, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 45, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 46, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 47, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 48, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 49, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 50, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 51, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 52, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 53, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 54, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 55, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 56, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 57, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 58, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 59, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 60, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 61, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 62, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 63, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 15, 1, true, true); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 13, 1, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 14, 1, true, true); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 68, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 76, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 69, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 70, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 66, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 75, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 64, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 73, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 81, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 80, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 74, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 72, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 71, 2, false, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 65, 2, true, false); +INSERT INTO users_items (user_id, item_id, channel_id, read, starred) VALUES (1, 67, 2, true, true); + +-- New user, without channels +INSERT INTO users (id, username, password, role) VALUES (2, 'john_doe', 'no', 'basic'); \ No newline at end of file diff --git a/src/dao/items.rs b/src/dao/items.rs new file mode 100644 index 0000000..32b3f00 --- /dev/null +++ b/src/dao/items.rs @@ -0,0 +1,414 @@ +use crate::common::model::{NewItem, PagedResult, UserItem}; +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Postgres, QueryBuilder, Result}; + +pub struct ItemDao { + db: PgPool, +} + +impl ItemDao { + pub fn new(db: PgPool) -> Self { + ItemDao { db } + } + + /// Return a page of items of a given channel for a given user. + #[tracing::instrument(skip(self))] + pub async fn get_items_of_user( + &self, + channel_id: Option, + read: Option, + starred: Option, + user_id: i32, + page_number: u64, + page_size: u64, + ) -> Result> { + let base_part = r#" + SELECT items.id, + items.guid, + items.title, + items.url, + items.content, + items.fetch_timestamp, + items.publish_timestamp, + users_items.read AS read, + users_items.starred AS starred, + users_items.notes AS notes, + channel_users.name AS channel_name, + items.channel_id AS channel_id + FROM items + RIGHT JOIN users_items ON items.id = users_items.item_id + RIGHT JOIN channel_users ON items.channel_id = channel_users.channel_id and users_items.user_id = channel_users.user_id + WHERE users_items.user_id = + "#; + + let mut page_query: QueryBuilder = QueryBuilder::new(base_part); + page_query.push_bind(user_id); + + add_filters(&mut page_query, channel_id, read, starred); + + page_query.push( + r#" + ORDER BY items.publish_timestamp DESC + "#, + ); + + page_query.push(" LIMIT "); + page_query.push_bind(page_size as i64); + + page_query.push(" OFFSET "); + page_query.push_bind((page_number as i64 - 1) * page_size as i64); + + let mut count_query: QueryBuilder = QueryBuilder::new( + r#" + SELECT COUNT(*) AS num_items FROM ( + "#, + ); + count_query.push(base_part); + count_query.push_bind(user_id); + add_filters(&mut count_query, channel_id, read, starred); + count_query.push(" ) AS sub_query "); + + let content = page_query.build_query_as().fetch_all(&self.db).await?; + let total_items = count_query + .build_query_scalar() + .fetch_optional(&self.db) + .await? + .unwrap_or(0i64) as u64; + + Ok(PagedResult::new( + content, + total_items, + page_size, + page_number, + )) + } + + /// Get all the item's GUID of a given channel. + #[tracing::instrument(skip(self))] + pub async fn get_all_items_guid_of_channel( + &self, + channel_id: i32, + ) -> Result>> { + sqlx::query_scalar!( + r#" + SELECT guid FROM items WHERE channel_id = $1 + "#, + channel_id + ) + .fetch_all(&self.db) + .await + } + + /// Update the read status of an item for a given user + #[tracing::instrument(skip(self))] + pub async fn set_item_read(&self, user_id: i32, ids: Vec, read: bool) -> Result<()> { + for id in ids { + sqlx::query!( + r#" + UPDATE users_items SET read = $1 WHERE user_id = $2 AND item_id = $3 + "#, + read, + user_id, + id + ) + .execute(&self.db) + .await?; + } + + Ok(()) + } + + /// Update the starred status of an item for a given user + #[tracing::instrument(skip(self))] + pub async fn set_item_starred(&self, user_id: i32, ids: Vec, starred: bool) -> Result<()> { + //TODO: transactional + for id in ids { + sqlx::query!( + r#" + UPDATE users_items SET starred = $1 WHERE user_id = $2 AND item_id = $3 + "#, + starred, + user_id, + id + ) + .execute(&self.db) + .await?; + } + + Ok(()) + } + + /// Insert an item in the database and associate it to all given users + #[tracing::instrument(skip(self))] + pub async fn insert_items_delta_for_all_registered_users( + &self, + channel_id: i32, + fetch_timestamp: &DateTime, + ) -> Result<()> { + //TODO: transactional + + let user_ids = self.get_user_ids_of_channel(channel_id).await?; + + for user_id in user_ids { + self.insert_item_user(&channel_id, &user_id, fetch_timestamp) + .await?; + } + + Ok(()) + } + + /// Insert items in the database + #[tracing::instrument(skip(self))] + pub async fn insert_items(&self, items: &Vec) -> Result> { + let mut guids: Vec> = vec![]; + let mut titles: Vec> = vec![]; + let mut urls: Vec> = vec![]; + let mut contents: Vec> = vec![]; + let mut fetch_timestamps: Vec> = vec![]; + let mut publish_timestamps: Vec>> = vec![]; + let mut channel_ids: Vec = vec![]; + + for item in items { + guids.push(item.guid.clone()); + titles.push(item.title.clone()); + urls.push(item.url.clone()); + contents.push(item.content.clone()); + fetch_timestamps.push(item.fetch_timestamp); + publish_timestamps.push(item.publish_timestamp); + channel_ids.push(item.channel_id); + } + + // Postgres magic: https://github.com/launchbadge/sqlx/blob/main/FAQ.md#how-can-i-bind-an-array-to-a-values-clause-how-can-i-do-bulk-inserts + // Also, sqlx magic: https://github.com/launchbadge/sqlx/issues/571#issuecomment-664910255 + sqlx::query_scalar!( + r#" + INSERT INTO items (guid, title, url, content, fetch_timestamp, publish_timestamp, channel_id) + SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[], $4::text[], $5::timestamptz[], $6::timestamptz[], $7::int[]) + RETURNING id + "#, + &guids[..] as _, &titles[..] as _, &urls[..] as _, &contents[..] as _, &fetch_timestamps[..], &publish_timestamps[..] as _, &channel_ids[..]) + .fetch_all(&self.db).await + } + + /// Add a note to a item for a user. + /// The user_id is needed to insure that a user does not try to add a note on someone else item. + pub async fn add_notes(&self, notes: String, user_id: i32, item_id: i32) -> Result<()> { + let r = sqlx::query!( + r#" + UPDATE users_items SET notes = $1 WHERE item_id = $2 and user_id = $3 + "#, + notes, + item_id, + user_id + ) + .execute(&self.db) + .await?; + + if r.rows_affected() == 0 { + return Err(sqlx::Error::RowNotFound); + } + + Ok(()) + } + + /// Get a particular item for a given user + /// The user_id is needed to insure that a user does not try to add a note on someone else item. + #[tracing::instrument(skip(self))] + pub async fn get_one_item(&self, item_id: i32, user_id: i32) -> Result> { + sqlx::query_as!( + UserItem, + r#" + SELECT items.id, + items.guid, + items.title, + items.url, + items.content, + items.fetch_timestamp, + items.publish_timestamp, + users_items.read AS read, + users_items.starred AS starred, + users_items.notes AS notes, + channel_users.name AS channel_name, + channel_users.channel_id AS channel_id + FROM items + RIGHT JOIN users_items ON items.id = users_items.item_id + RIGHT JOIN channel_users ON items.channel_id = channel_users.channel_id + WHERE users_items.user_id = $1 AND users_items.item_id = $2 + "#, + user_id, + item_id + ) + .fetch_optional(&self.db) + .await + } + + /// Insert the delta of the missing user's items for a given channel + #[tracing::instrument(skip(self))] + async fn insert_item_user( + &self, + channel_id: &i32, + user_id: &i32, + timestamp: &DateTime, + ) -> Result<()> { + sqlx::query!( + r#" + INSERT INTO users_items (user_id, item_id, channel_id, read, starred, added_timestamp) + SELECT $1, id, $2, false, false, $3 + FROM items + WHERE channel_id = $2 + AND fetch_timestamp > COALESCE((SELECT MAX(added_timestamp) FROM users_items WHERE user_id = $1 AND channel_id = $2), TO_TIMESTAMP(0)); + "#, + user_id, channel_id, timestamp) + .execute(&self.db) + .await?; + + Ok(()) + } + + async fn get_user_ids_of_channel(&self, channel_id: i32) -> Result> { + sqlx::query_scalar!( + r#" + SELECT user_id FROM channel_users WHERE channel_id = $1 + "#, + channel_id + ) + .fetch_all(&self.db) + .await + } +} +fn add_filters( + query: &mut QueryBuilder, + channel_id: Option, + read: Option, + starred: Option, +) { + if let Some(channel_id) = channel_id { + query.push(" AND users_items.channel_id = "); + query.push_bind(channel_id); + } + + if let Some(read) = read { + query.push(" AND users_items.read = "); + query.push_bind(read); + } + + if let Some(starred) = starred { + query.push(" AND users_items.starred = "); + query.push_bind(starred); + } +} + +#[cfg(test)] +mod tests { + use speculoos::prelude::*; + + use super::*; + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn basic_without_filter(db: PgPool) -> Result<()> { + let item_service = ItemDao { db }; + + let page = item_service + .get_items_of_user(None, None, None, 1, 1, 20) + .await?; + + assert_that!(page.page_size()).is_equal_to(&20); + assert_that!(page.total_pages()).is_equal_to(&4); + assert_that!(page.total_items()).is_equal_to(&78); + assert_that!(page.elements_number()).is_equal_to(&20); + assert_that!(page.page_number()).is_equal_to(&1); + assert_that!(page.content()).has_length(20); + + Ok(()) + } + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn basic_channel_filter(db: PgPool) -> Result<()> { + let item_service = ItemDao { db }; + let page = item_service + .get_items_of_user(Some(1), None, None, 1, 1, 20) + .await?; + + assert_that!(page.page_size()).is_equal_to(&20); + assert_that!(page.total_pages()).is_equal_to(&3); + assert_that!(page.total_items()).is_equal_to(&60); + assert_that!(page.elements_number()).is_equal_to(&20); + assert_that!(page.page_number()).is_equal_to(&1); + assert_that!(page.content()).has_length(20); + + Ok(()) + } + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn basic_read_filter(db: PgPool) -> Result<()> { + let item_service = ItemDao { db }; + let page = item_service + .get_items_of_user(None, Some(true), None, 1, 1, 20) + .await?; + + assert_that!(page.page_size()).is_equal_to(&20); + assert_that!(page.total_pages()).is_equal_to(&4); + assert_that!(page.total_items()).is_equal_to(&62); + assert_that!(page.elements_number()).is_equal_to(&20); + assert_that!(page.page_number()).is_equal_to(&1); + assert_that!(page.content()).has_length(20); + + let page = item_service + .get_items_of_user(None, Some(false), None, 1, 1, 20) + .await?; + + assert_that!(page.page_size()).is_equal_to(&20); + assert_that!(page.total_pages()).is_equal_to(&1); + assert_that!(page.total_items()).is_equal_to(&16); + assert_that!(page.elements_number()).is_equal_to(&16); + assert_that!(page.page_number()).is_equal_to(&1); + assert_that!(page.content()).has_length(16); + + Ok(()) + } + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn basic_starred_filter(db: PgPool) -> Result<()> { + let item_service = ItemDao { db }; + let page = item_service + .get_items_of_user(None, None, Some(true), 1, 1, 20) + .await?; + + assert_that!(page.page_size()).is_equal_to(&20); + assert_that!(page.total_pages()).is_equal_to(&1); + assert_that!(page.total_items()).is_equal_to(&3); + assert_that!(page.elements_number()).is_equal_to(&3); + assert_that!(page.page_number()).is_equal_to(&1); + assert_that!(page.content()).has_length(3); + + let page = item_service + .get_items_of_user(None, None, Some(false), 1, 1, 20) + .await?; + + assert_that!(page.page_size()).is_equal_to(&20); + assert_that!(page.total_pages()).is_equal_to(&4); + assert_that!(page.total_items()).is_equal_to(&75); + assert_that!(page.elements_number()).is_equal_to(&20); + assert_that!(page.page_number()).is_equal_to(&1); + assert_that!(page.content()).has_length(20); + + Ok(()) + } + + #[sqlx::test(fixtures("base_fixtures"), migrations = "./migrations")] + async fn basic_all_filters(db: PgPool) -> Result<()> { + let item_service = ItemDao { db }; + let page = item_service + .get_items_of_user(Some(1), Some(true), Some(true), 1, 1, 20) + .await?; + + assert_that!(page.page_size()).is_equal_to(&20); + assert_that!(page.total_pages()).is_equal_to(&1); + assert_that!(page.total_items()).is_equal_to(&2); + assert_that!(page.elements_number()).is_equal_to(&2); + assert_that!(page.page_number()).is_equal_to(&1); + assert_that!(page.content()).has_length(2); + + Ok(()) + } +} diff --git a/src/dao/mod.rs b/src/dao/mod.rs new file mode 100644 index 0000000..cd7ea7d --- /dev/null +++ b/src/dao/mod.rs @@ -0,0 +1,3 @@ +pub mod channels; +pub mod items; +pub mod users; diff --git a/src/dao/users.rs b/src/dao/users.rs new file mode 100644 index 0000000..01c84cb --- /dev/null +++ b/src/dao/users.rs @@ -0,0 +1,178 @@ +use crate::common::model::{PagedResult, User, UserRole}; +use crate::common::password::encode_password; +use secrecy::Secret; +use sqlx::PgPool; +use sqlx::Result; +use tracing::{info, instrument}; + +pub struct UserDao { + db: PgPool, +} + +impl UserDao { + pub fn new(db: PgPool) -> Self { + UserDao { db } + } + + #[instrument(skip(self))] + pub async fn get_user_by_username(&self, wanted_username: &str) -> Result> { + sqlx::query_as!( + User, + r#" + SELECT id, username, password, role as "role: UserRole", email_verified FROM users WHERE username = $1 + "#, + wanted_username + ) + .fetch_optional(&self.db) + .await + } + + /// Return the user matching the id + #[instrument(skip(self))] + pub async fn get_user_by_id(&self, id: i32) -> Result> { + sqlx::query_as!( + User, + r#" + SELECT id, username, password, role as "role: UserRole", email_verified FROM users WHERE id = $1 + "#, + id + ) + .fetch_optional(&self.db) + .await + } + + /// Return the user matching the id + #[instrument(skip(self))] + pub async fn get_user_by_hashed_email(&self, hashed_email: &str) -> Result> { + sqlx::query_as!( + User, + r#" + SELECT id, username, password, role as "role: UserRole", email_verified FROM users WHERE email = $1 AND email_verified = true + "#, + hashed_email + ) + .fetch_optional(&self.db) + .await + } + + /// List all the users + #[instrument(skip(self))] + pub async fn list_users(&self, page_number: u64, page_size: u64) -> Result> { + let content = sqlx::query_as!( + User, + r#" + SELECT id, username, password, role as "role: UserRole", email_verified FROM users + ORDER BY id + LIMIT $1 OFFSET $2 + "#, + page_size as i64, + (page_number as i64 - 1) * page_size as i64 + ) + .fetch_all(&self.db) + .await?; + + let total_items = sqlx::query_scalar!( + r#" + SELECT COUNT(*) FROM users + "#, + ) + .fetch_one(&self.db) + .await? + .unwrap_or(0) as u64; + + Ok(PagedResult::new( + content, + total_items, + page_size, + page_number, + )) + } + + /// Create a new user + #[instrument(skip(self, password))] + pub async fn create_user( + &self, + login: &str, + password: &Secret, + hashed_email: &Option, + user_role: &UserRole, + ) -> anyhow::Result { + let user = sqlx::query_as!( + User, + r#" + INSERT INTO users (username, password, email, role, email_verified) VALUES ($1, $2, $3, $4, false) + RETURNING id, username, password, role as "role: UserRole", email_verified + "#, + login, + encode_password(password), + hashed_email.to_owned(), + user_role as &UserRole + ) + .fetch_one(&self.db) + .await?; //TODO Make a beautifull error on unique constraint violation + + Ok(user) + } + + /// Update a user's password + #[instrument(skip(self, new_password))] + pub async fn update_user_password( + &self, + user_id: i32, + new_password: &Secret, + ) -> Result<()> { + let result = sqlx::query!( + r#" + UPDATE users SET password = $1 WHERE id=$2 + "#, + encode_password(new_password), + user_id + ) + .execute(&self.db) + .await?; + + //TODO: Must return a dedicated error + if result.rows_affected() == 0 { + return Err(sqlx::Error::RowNotFound); + } + + Ok(()) + } + + #[instrument(skip(self, hashed_email))] + pub async fn update_user( + &self, + user_id: i32, + hashed_email: &Option, + ) -> anyhow::Result<()> { + let user = self + .get_user_by_id(user_id) + .await? + .ok_or_else(|| sqlx::Error::RowNotFound)?; + + if let Some(email) = hashed_email { + sqlx::query!( + r#"UPDATE users SET email = $1, email_verified = false WHERE id = $2"#, + email, + user.id + ) + .execute(&self.db) + .await?; + } + + Ok(()) + } + + #[instrument(skip(self))] + pub async fn delete_user(&self, user_id: i32) -> anyhow::Result<()> { + let result = sqlx::query!(r#"DELETE FROM users WHERE id = $1"#, user_id) + .execute(&self.db) + .await?; + if result.rows_affected() == 0 { + return Err(sqlx::Error::RowNotFound)?; + } + + info!("Deleted user {}", user_id); + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index d78de8c..d977612 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod common; +pub mod dao; pub mod model; pub mod rate_limiting; pub mod routes;