Repository: importantimport/hatsu (language: rust)

Issue:
Use feed language
Hatsu should read the feed language and set the post to the corresponding language:

```jsonc
// feed.json
{
  "language": "en-US",
  "items": []
}
```

```jsonc
// object.json
{
  "content": "lorem ipsum",
  "contentMap": {
    "en": "lorem ipsum" // language.slice(0, 2)
  }
}
```

This requires modifying the user table and adding a new language field.

~~It is also necessary to list supported languages and not output contentMap if the language is not in it.~~



Discussion / hints:

Fix patch:
diff --git a/crates/apub/src/actors/db_user.rs b/crates/apub/src/actors/db_user.rs
index a9b40368..e3f66f44 100644
--- a/crates/apub/src/actors/db_user.rs
+++ b/crates/apub/src/actors/db_user.rs
@@ -92,6 +92,7 @@ impl Object for ApubUser {
             private_key: None,
             hatsu: None,
             feed: None,
+            language: None,
             last_refreshed_at: hatsu_utils::date::now(),
         };
 
diff --git a/crates/apub/src/actors/db_user_impl.rs b/crates/apub/src/actors/db_user_impl.rs
index f7fef3e5..383136fc 100644
--- a/crates/apub/src/actors/db_user_impl.rs
+++ b/crates/apub/src/actors/db_user_impl.rs
@@ -51,6 +51,7 @@ impl ApubUser {
             private_key: Some(keypair.private_key),
             hatsu: user_feed_top_level.hatsu.map(UserFeedHatsu::into_db),
             feed: Some(user_feed.into_db()),
+            language: user_feed_top_level.language,
             last_refreshed_at: hatsu_utils::date::now(),
         };
 
@@ -65,8 +66,7 @@ impl ApubUser {
             title: self.name.clone(),
             description: self.summary.clone(),
             icon: self.icon.clone().and_then(|url| Url::parse(&url).ok()),
-            // TODO: use language
-            language: Option::default(),
+            language: self.language.clone(),
             feed_url: Url::parse("https://hatsu.local").unwrap(),
             next_url: Option::default(),
             items: Vec::default(),
diff --git a/crates/apub/src/objects/note.rs b/crates/apub/src/objects/note.rs
index 8d9a1724..a0531c88 100644
--- a/crates/apub/src/objects/note.rs
+++ b/crates/apub/src/objects/note.rs
@@ -9,7 +9,7 @@ use activitypub_federation::{
     traits::{Actor, Object},
 };
 use hatsu_db_schema::prelude::Post;
-use hatsu_feed::UserFeedItem;
+use hatsu_feed::{UserFeedItem, UserFeedTopLevel};
 use hatsu_utils::{markdown::markdown_to_html, AppData, AppError};
 use sea_orm::EntityTrait;
 use serde::{Deserialize, Serialize};
@@ -21,6 +21,7 @@ use crate::{
     actors::ApubUser,
     links::{Hashtag, Tag},
     objects::ApubPost,
+    utils::generate_map,
 };
 
 /// <https://www.w3.org/ns/activitystreams#Note>
@@ -45,6 +46,7 @@ pub struct Note {
     #[serde(deserialize_with = "deserialize_one_or_many")]
     pub cc: Vec<Url>,
     pub content: String,
+    pub content_map: Option<Value>,
     /// TODO: customization via item._hatsu.source
     #[serde(skip_serializing_if = "Option::is_none")]
     pub source: Option<Value>,
@@ -79,6 +81,7 @@ impl Note {
     pub fn new(
         actor: &ApubUser,
         json: UserFeedItem,
+        top_level: &UserFeedTopLevel,
         published: Option<String>,
         updated: Option<String>,
         data: &Data<AppData>,
@@ -135,6 +138,7 @@ impl Note {
             to: vec![public()],
             // Leaving a CC here to retain compatibility, figured I should CC followers instead of public twice
             cc: vec![Url::parse(&format!("{}/followers", actor.id()))?],
+            content_map: generate_map(&content, json.language.or(top_level.language.clone())),
             content,
             source: Some(serde_json::to_value(NoteSource::new(source))?),
             tag: json.tags.map_or_else(Vec::new, |tags| {
@@ -180,20 +184,30 @@ impl Note {
     pub fn create(
         actor: &ApubUser,
         json: UserFeedItem,
+        top_level: &UserFeedTopLevel,
         data: &Data<AppData>,
     ) -> Result<Self, AppError> {
-        Self::new(actor, json, Some(hatsu_utils::date::now()), None, data)
+        Self::new(
+            actor,
+            json,
+            top_level,
+            Some(hatsu_utils::date::now()),
+            None,
+            data,
+        )
     }
 
     pub fn update(
         actor: &ApubUser,
         json: UserFeedItem,
+        top_level: &UserFeedTopLevel,
         published: String,
         data: &Data<AppData>,
     ) -> Result<Self, AppError> {
         Self::new(
             actor,
             json,
+            top_level,
             Some(published),
             Some(hatsu_utils::date::now()),
             data,
diff --git a/crates/apub/src/utils/generate_map.rs b/crates/apub/src/utils/generate_map.rs
new file mode 100644
index 00000000..cfac039b
--- /dev/null
+++ b/crates/apub/src/utils/generate_map.rs
@@ -0,0 +1,11 @@
+use serde_json::{json, Value};
+
+pub fn generate_map(content: &str, language: Option<String>) -> Option<Value> {
+    match language {
+        Some(language) if language[.. 2].chars().all(|char| char.is_ascii_lowercase()) =>
+            Some(json!({
+                language[..2]: content
+            })),
+        _ => None,
+    }
+}
diff --git a/crates/apub/src/utils/mod.rs b/crates/apub/src/utils/mod.rs
index babd8222..a2c45c4f 100644
--- a/crates/apub/src/utils/mod.rs
+++ b/crates/apub/src/utils/mod.rs
@@ -1,3 +1,5 @@
+mod generate_map;
 mod verify_blocked;
 
+pub use generate_map::generate_map;
 pub use verify_blocked::verify_blocked;
diff --git a/crates/cron/src/tasks/check_feed_item.rs b/crates/cron/src/tasks/check_feed_item.rs
index a7649e65..14ada21d 100644
--- a/crates/cron/src/tasks/check_feed_item.rs
+++ b/crates/cron/src/tasks/check_feed_item.rs
@@ -5,7 +5,7 @@ use hatsu_db_schema::{
     prelude::*,
     user_feed_item::Model as DbUserFeedItem,
 };
-use hatsu_feed::{UserFeedItem as JsonUserFeedItem, WrappedUserFeedItem};
+use hatsu_feed::{UserFeedItem as JsonUserFeedItem, UserFeedTopLevel, WrappedUserFeedItem};
 use hatsu_utils::{AppData, AppError};
 use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, IntoActiveModel};
 
@@ -13,6 +13,7 @@ pub async fn check_feed_item(
     data: &Data<AppData>,
     user: &ApubUser,
     item: DbUserFeedItem,
+    top_level: &UserFeedTopLevel,
 ) -> Result<(), AppError> {
     match UserFeedItem::find_by_id(&item.id).one(&data.conn).await? {
         Some(prev_item) => {
@@ -20,15 +21,15 @@ pub async fn check_feed_item(
             if let Some(date_modified) = item.date_modified.clone() {
                 match prev_item.date_modified {
                     Some(prev_date_modified) if prev_date_modified != date_modified =>
-                        update_feed_item(item, user, data).await?,
-                    None => update_feed_item(item, user, data).await?,
+                        update_feed_item(item, user, data, top_level).await?,
+                    None => update_feed_item(item, user, data, top_level).await?,
                     _ => (),
                 };
             }
 
             Ok(())
         },
-        None => Ok(create_feed_item(item, user, data).await?),
+        None => Ok(create_feed_item(item, user, data, top_level).await?),
     }
 }
 
@@ -36,13 +37,14 @@ async fn create_feed_item(
     item: DbUserFeedItem,
     user: &ApubUser,
     data: &Data<AppData>,
+    top_level: &UserFeedTopLevel,
 ) -> Result<(), AppError> {
     // 将 Item 保存到数据库
     let item = item.into_active_model().insert(&data.conn).await?;
     let item: WrappedUserFeedItem = item.into();
 
     // 创建 Note
-    let note = Note::create(user, item.into_json()?, data)?;
+    let note = Note::create(user, item.into_json()?, top_level, data)?;
 
     // 创建 Post 并保存到数据库
     let _post = DbPost {
@@ -71,6 +73,7 @@ async fn update_feed_item(
     item: DbUserFeedItem,
     user: &ApubUser,
     data: &Data<AppData>,
+    top_level: &UserFeedTopLevel,
 ) -> Result<(), AppError> {
     // 更新 Item
     let item = item
@@ -86,7 +89,7 @@ async fn update_feed_item(
         .one(&data.conn)
         .await?
     {
-        let note = Note::update(user, item, post.published.clone(), data)?;
+        let note = Note::update(user, item, top_level, post.published.clone(), data)?;
 
         post::ActiveModel {
             object: Set(serde_json::to_string(&note)?),
diff --git a/crates/cron/src/tasks/update.rs b/crates/cron/src/tasks/update.rs
index a4e8565f..255de0a0 100644
--- a/crates/cron/src/tasks/update.rs
+++ b/crates/cron/src/tasks/update.rs
@@ -41,13 +41,14 @@ pub async fn partial_update_per_user(
     let feed = UserFeedTopLevel::get(db_user.clone()).await?;
     let user: ApubUser = db_user.into();
 
-    for item in feed.items {
+    for item in feed.items.clone() {
         check_feed_item(
             data,
             &user,
             WrappedUserFeedItem::from_json(item, &user, data)?
                 .deref()
                 .clone(),
+            &feed,
         )
         .await?;
     }
@@ -93,8 +94,6 @@ pub async fn full_update_per_user(data: &Data<AppData>, db_user: DbUser) -> Resu
     if !Into::<ApubUser>::into(db_user.clone())
         .to_user_feed_top_level()
         .eq(&UserFeedTopLevel {
-            // TODO: use language
-            language: Option::default(),
             feed_url: Url::parse("https://hatsu.local").unwrap(),
             next_url: Option::default(),
             items: Vec::default(),
@@ -102,10 +101,14 @@ pub async fn full_update_per_user(data: &Data<AppData>, db_user: DbUser) -> Resu
         })
     {
         db_user = hatsu_db_schema::user::ActiveModel {
-            hatsu: Set(user_feed_top_level.hatsu.map(UserFeedHatsu::into_db)),
-            name: Set(user_feed_top_level.title),
-            summary: Set(user_feed_top_level.description),
-            icon: Set(user_feed_top_level.icon.map(|url| url.to_string())),
+            hatsu: Set(user_feed_top_level
+                .hatsu
+                .clone()
+                .map(UserFeedHatsu::into_db)),
+            name: Set(user_feed_top_level.title.clone()),
+            summary: Set(user_feed_top_level.description.clone()),
+            icon: Set(user_feed_top_level.icon.clone().map(|url| url.to_string())),
+            language: Set(user_feed_top_level.language.clone()),
             ..db_user.clone().into_active_model()
         }
         .update(&data.conn)
@@ -114,13 +117,14 @@ pub async fn full_update_per_user(data: &Data<AppData>, db_user: DbUser) -> Resu
 
     let user: ApubUser = db_user.into();
 
-    for item in user_feed_top_level.items {
+    for item in user_feed_top_level.items.clone() {
         check_feed_item(
             data,
             &user,
             WrappedUserFeedItem::from_json(item, &user, data)?
                 .deref()
                 .clone(),
+            &user_feed_top_level,
         )
         .await?;
     }
diff --git a/crates/db_migration/src/lib.rs b/crates/db_migration/src/lib.rs
index 5ceb4a4d..f1bc61c9 100644
--- a/crates/db_migration/src/lib.rs
+++ b/crates/db_migration/src/lib.rs
@@ -10,6 +10,7 @@ mod m20240501_000002_received_announce;
 mod m20240515_000001_user_feed_hatsu_extension;
 mod m20240515_000002_user_feed;
 mod m20240926_000001_blocked_url;
+mod m20241028_000001_user_language;
 
 pub struct Migrator;
 
@@ -27,6 +28,7 @@ impl MigratorTrait for Migrator {
             Box::new(m20240515_000001_user_feed_hatsu_extension::Migration),
             Box::new(m20240515_000002_user_feed::Migration),
             Box::new(m20240926_000001_blocked_url::Migration),
+            Box::new(m20241028_000001_user_language::Migration),
         ]
     }
 }
diff --git a/crates/db_migration/src/m20240131_000001_user.rs b/crates/db_migration/src/m20240131_000001_user.rs
index fd9df42b..8817847d 100644
--- a/crates/db_migration/src/m20240131_000001_user.rs
+++ b/crates/db_migration/src/m20240131_000001_user.rs
@@ -71,6 +71,8 @@ pub enum User {
     Hatsu,
     /// User Feed
     Feed,
+    /// language (`m20241028_000001`)
+    Language,
     /// will be remove in v1.0.0
     // #[deprecated(since = "0.2.0-beta.5", note = "replaced by feed")]
     FeedJson,
diff --git a/crates/db_migration/src/m20241028_000001_user_language.rs b/crates/db_migration/src/m20241028_000001_user_language.rs
new file mode 100644
index 00000000..c31e8b33
--- /dev/null
+++ b/crates/db_migration/src/m20241028_000001_user_language.rs
@@ -0,0 +1,35 @@
+use sea_orm_migration::{prelude::*, schema::*};
+
+use crate::m20240131_000001_user::User;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+        manager
+            .alter_table(
+                Table::alter()
+                    .table(User::Table)
+                    .add_column(string_null(User::Language))
+                    .to_owned(),
+            )
+            .await?;
+
+        Ok(())
+    }
+
+    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+        manager
+            .alter_table(
+                Table::alter()
+                    .table(User::Table)
+                    .drop_column(User::Language)
+                    .to_owned(),
+            )
+            .await?;
+
+        Ok(())
+    }
+}
diff --git a/crates/db_schema/src/user.rs b/crates/db_schema/src/user.rs
index 17ae3359..3dd9733f 100644
--- a/crates/db_schema/src/user.rs
+++ b/crates/db_schema/src/user.rs
@@ -22,6 +22,7 @@ pub struct Model {
     pub last_refreshed_at: String,
     pub hatsu: Option<UserHatsu>,
     pub feed: Option<UserFeed>,
+    pub language: Option<String>,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, FromJsonQueryResult)]


Test patch: