diff --git a/api_tests/package.json b/api_tests/package.json index e2953d6b33..b229814e70 100644 --- a/api_tests/package.json +++ b/api_tests/package.json @@ -10,7 +10,7 @@ "scripts": { "lint": "tsc --noEmit && eslint --report-unused-disable-directives && prettier --check 'src/**/*.ts'", "fix": "prettier --write src && eslint --fix src", - "api-test": "jest -i follow.spec.ts && jest -i image.spec.ts && jest -i user.spec.ts && jest -i private_message.spec.ts && jest -i community.spec.ts && jest -i private_community.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts ", + "api-test": "jest -i follow.spec.ts && jest -i image.spec.ts && jest -i user.spec.ts && jest -i private_message.spec.ts && jest -i community.spec.ts && jest -i private_community.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts && jest -i tags.spec.ts", "api-test-follow": "jest -i follow.spec.ts", "api-test-comment": "jest -i comment.spec.ts", "api-test-post": "jest -i post.spec.ts", @@ -18,7 +18,8 @@ "api-test-community": "jest -i community.spec.ts", "api-test-private-community": "jest -i private_community.spec.ts", "api-test-private-message": "jest -i private_message.spec.ts", - "api-test-image": "jest -i image.spec.ts" + "api-test-image": "jest -i image.spec.ts", + "api-test-tags": "jest -i tags.spec.ts" }, "devDependencies": { "@types/jest": "^29.5.12", @@ -29,7 +30,7 @@ "eslint": "^9.20.0", "eslint-plugin-prettier": "^5.2.3", "jest": "^29.5.0", - "lemmy-js-client": "0.20.0-ap-id.1", + "lemmy-js-client": "npm:@phiresky/lemmy-js-client@1.0.0-post-tags.2", "prettier": "^3.5.0", "ts-jest": "^29.1.0", "tsoa": "^6.6.0", diff --git a/api_tests/pnpm-lock.yaml b/api_tests/pnpm-lock.yaml index cd2112bb45..340a4b20cf 100644 --- a/api_tests/pnpm-lock.yaml +++ b/api_tests/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^29.5.0 version: 29.7.0(@types/node@22.13.1) lemmy-js-client: - specifier: 0.20.0-ap-id.1 - version: 0.20.0-ap-id.1 + specifier: npm:@phiresky/lemmy-js-client@1.0.0-post-tags.2 + version: '@phiresky/lemmy-js-client@1.0.0-post-tags.2' prettier: specifier: ^3.5.0 version: 3.5.0 @@ -487,6 +487,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@phiresky/lemmy-js-client@1.0.0-post-tags.2': + resolution: {integrity: sha512-BbtYEN9GsC7UXA4Q3uVHK0mo3iEk3jlS8sv3t0p7eZCuHRo7LTZQnIJx7enUSp4Tl2xOvcgjYVWmEjQjdul1NA==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1528,9 +1531,6 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - lemmy-js-client@0.20.0-ap-id.1: - resolution: {integrity: sha512-HzY005mhbINXa5i+GabuJSrwN27ExZKj2XxM1cAnfTWJ4ZqvbLuz4i26JDeE8pj6GGKbXBIj2VX4aOhKgCjkSA==} - leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -2815,6 +2815,12 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.0 + '@phiresky/lemmy-js-client@1.0.0-post-tags.2': + dependencies: + tsoa: 6.6.0 + transitivePeerDependencies: + - supports-color + '@pkgjs/parseargs@0.11.0': optional: true @@ -4169,8 +4175,6 @@ snapshots: kleur@3.0.3: {} - lemmy-js-client@0.20.0-ap-id.1: {} - leven@3.1.0: {} levn@0.4.1: diff --git a/api_tests/src/tags.spec.ts b/api_tests/src/tags.spec.ts new file mode 100644 index 0000000000..d0d3ab8c66 --- /dev/null +++ b/api_tests/src/tags.spec.ts @@ -0,0 +1,147 @@ +jest.setTimeout(120000); + +import { + alpha, + setupLogins, + createCommunity, + unfollows, + randomString, + createPost, +} from "./shared"; +import { CreateCommunityTag } from "lemmy-js-client/dist/types/CreateCommunityTag"; +import { UpdateCommunityTag } from "lemmy-js-client/dist/types/UpdateCommunityTag"; +import { DeleteCommunityTag } from "lemmy-js-client/dist/types/DeleteCommunityTag"; +import { EditPost } from "lemmy-js-client"; + +beforeAll(setupLogins); +afterAll(unfollows); + +test("Create, update, delete community tag", async () => { + // Create a community first + let communityRes = await createCommunity(alpha); + const communityId = communityRes.community_view.community.id; + + // Create a tag + const tagName = randomString(10); + let createForm: CreateCommunityTag = { + name: tagName, + community_id: communityId, + }; + let createRes = await alpha.createCommunityTag(createForm); + expect(createRes.id).toBeDefined(); + expect(createRes.name).toBe(tagName); + expect(createRes.community_id).toBe(communityId); + + // Update the tag + const newTagName = randomString(10); + let updateForm: UpdateCommunityTag = { + tag_id: createRes.id, + name: newTagName, + }; + let updateRes = await alpha.updateCommunityTag(updateForm); + expect(updateRes.id).toBe(createRes.id); + expect(updateRes.name).toBe(newTagName); + expect(updateRes.community_id).toBe(communityId); + + // List tags + let listRes = await alpha.getCommunity({ id: communityId }); + expect(listRes.community_view.post_tags.length).toBeGreaterThan(0); + expect( + listRes.community_view.post_tags.find(t => t.id === createRes.id)?.name, + ).toBe(newTagName); + + // Delete the tag + let deleteForm: DeleteCommunityTag = { + tag_id: createRes.id, + }; + let deleteRes = await alpha.deleteCommunityTag(deleteForm); + expect(deleteRes.id).toBe(createRes.id); + + // Verify tag is deleted + listRes = await alpha.getCommunity({ id: communityId }); + expect( + listRes.community_view.post_tags.find(t => t.id === createRes.id), + ).toBeUndefined(); +}); + +test("Update post tags", async () => { + // Create a community + let communityRes = await createCommunity(alpha); + const communityId = communityRes.community_view.community.id; + + // Create two tags + const tag1Name = randomString(10); + let createForm1: CreateCommunityTag = { + name: tag1Name, + community_id: communityId, + }; + let tag1Res = await alpha.createCommunityTag(createForm1); + expect(tag1Res.id).toBeDefined(); + + const tag2Name = randomString(10); + let createForm2: CreateCommunityTag = { + name: tag2Name, + community_id: communityId, + }; + let tag2Res = await alpha.createCommunityTag(createForm2); + expect(tag2Res.id).toBeDefined(); + + // Create a post + let postRes = await alpha.createPost({ + name: randomString(10), + community_id: communityId, + }); + expect(postRes.post_view.post.id).toBeDefined(); + + // Update post tags + let updateForm: EditPost = { + post_id: postRes.post_view.post.id, + tags: [tag1Res.id, tag2Res.id], + }; + let updateRes = await alpha.editPost(updateForm); + expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id); + expect(updateRes.post_view.tags?.length).toBe(2); + expect(updateRes.post_view.tags?.map(t => t.id).sort()).toEqual( + [tag1Res.id, tag2Res.id].sort(), + ); + + // Update post to remove one tag + updateForm.tags = [tag1Res.id]; + updateRes = await alpha.editPost(updateForm); + expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id); + expect(updateRes.post_view.tags?.length).toBe(1); + expect(updateRes.post_view.tags?.[0].id).toBe(tag1Res.id); +}); + +test("Post author can update post tags", async () => { + // Create a community + let communityRes = await createCommunity(alpha); + const communityId = communityRes.community_view.community.id; + + // Create a tag + const tagName = randomString(10); + let createForm: CreateCommunityTag = { + name: tagName, + community_id: communityId, + }; + let tagRes = await alpha.createCommunityTag(createForm); + expect(tagRes.id).toBeDefined(); + + let postRes = await createPost( + alpha, + communityId, + "https://example.com/", + "post with tags", + ); + expect(postRes.post_view.post.id).toBeDefined(); + + // Alpha should be able to update tags on their own post + let updateForm: EditPost = { + post_id: postRes.post_view.post.id, + tags: [tagRes.id], + }; + let updateRes = await alpha.editPost(updateForm); + expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id); + expect(updateRes.post_view.tags?.length).toBe(1); + expect(updateRes.post_view.tags?.[0].id).toBe(tagRes.id); +}); diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 281f8a09fe..5a5964c399 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -23,12 +23,12 @@ lemmy_db_schema = { workspace = true, features = ["full"] } lemmy_db_views = { workspace = true, features = ["full"] } lemmy_api_common = { workspace = true, features = ["full"] } activitypub_federation = { workspace = true } +tracing = { workspace = true } bcrypt = { workspace = true } actix-web = { workspace = true } base64 = { workspace = true } captcha = { workspace = true } anyhow = { workspace = true } -tracing = { workspace = true } chrono = { workspace = true } url = { workspace = true } hound = "3.5.1" diff --git a/crates/api/src/community/mod.rs b/crates/api/src/community/mod.rs index 121e181c69..b533aefb9a 100644 --- a/crates/api/src/community/mod.rs +++ b/crates/api/src/community/mod.rs @@ -5,4 +5,5 @@ pub mod follow; pub mod hide; pub mod pending_follows; pub mod random; +pub mod tag; pub mod transfer; diff --git a/crates/api/src/community/tag.rs b/crates/api/src/community/tag.rs new file mode 100644 index 0000000000..cfb206bf94 --- /dev/null +++ b/crates/api/src/community/tag.rs @@ -0,0 +1,107 @@ +use activitypub_federation::config::Data; +use actix_web::web::Json; +use lemmy_api_common::{ + community::{CreateCommunityTag, DeleteCommunityTag, UpdateCommunityTag}, + context::LemmyContext, + utils::check_community_mod_action, +}; +use lemmy_db_schema::{ + source::{ + community::Community, + tag::{Tag, TagInsertForm, TagUpdateForm}, + }, + traits::Crud, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::LemmyResult; + +pub async fn create_community_tag( + data: Json, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + let community = Community::read(&mut context.pool(), data.community_id).await?; + + // Verify that only mods can create tags + check_community_mod_action( + &local_user_view.person, + &community, + false, + &mut context.pool(), + ) + .await?; + + // Create the tag + let tag_form = TagInsertForm { + name: data.name.clone(), + community_id: data.community_id, + ap_id: community.build_tag_ap_id(&data.name)?, + published: None, // defaults to now + updated: None, + deleted: false, + }; + + let tag = Tag::create(&mut context.pool(), &tag_form).await?; + + Ok(Json(tag)) +} + +#[tracing::instrument(skip(context))] +pub async fn update_community_tag( + data: Json, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + let tag = Tag::read(&mut context.pool(), data.tag_id).await?; + let community = Community::read(&mut context.pool(), tag.community_id).await?; + + // Verify that only mods can update tags + check_community_mod_action( + &local_user_view.person, + &community, + false, + &mut context.pool(), + ) + .await?; + + // Update the tag + let tag_form = TagUpdateForm { + name: Some(data.name.clone()), + updated: Some(Some(chrono::Utc::now())), + ..Default::default() + }; + + let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?; + + Ok(Json(tag)) +} + +#[tracing::instrument(skip(context))] +pub async fn delete_community_tag( + data: Json, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + let tag = Tag::read(&mut context.pool(), data.tag_id).await?; + let community = Community::read(&mut context.pool(), tag.community_id).await?; + + // Verify that only mods can delete tags + check_community_mod_action( + &local_user_view.person, + &community, + false, + &mut context.pool(), + ) + .await?; + + // Soft delete the tag + let tag_form = TagUpdateForm { + updated: Some(Some(chrono::Utc::now())), + deleted: Some(true), + ..Default::default() + }; + + let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?; + + Ok(Json(tag)) +} diff --git a/crates/api_common/src/community.rs b/crates/api_common/src/community.rs index 3bd6c9b1a6..ab857f692a 100644 --- a/crates/api_common/src/community.rs +++ b/crates/api_common/src/community.rs @@ -1,5 +1,5 @@ use lemmy_db_schema::{ - newtypes::{CommunityId, LanguageId, PersonId}, + newtypes::{CommunityId, LanguageId, PersonId, TagId}, source::site::Site, CommunityVisibility, ListingType, @@ -16,6 +16,35 @@ use serde_with::skip_serializing_none; #[cfg(feature = "full")] use ts_rs::TS; +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Create a tag for a community. +pub struct CreateCommunityTag { + pub community_id: CommunityId, + pub name: String, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Update a community tag. +pub struct UpdateCommunityTag { + pub tag_id: TagId, + pub name: String, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Delete a community tag. +pub struct DeleteCommunityTag { + pub tag_id: TagId, +} + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] diff --git a/crates/api_common/src/lib.rs b/crates/api_common/src/lib.rs index 7eac9b21ec..d553406d1d 100644 --- a/crates/api_common/src/lib.rs +++ b/crates/api_common/src/lib.rs @@ -19,6 +19,7 @@ pub mod request; pub mod send_activity; pub mod site; pub mod tagline; +pub mod tags; #[cfg(feature = "full")] pub mod utils; diff --git a/crates/api_common/src/post.rs b/crates/api_common/src/post.rs index c9fc33be10..32969675bd 100644 --- a/crates/api_common/src/post.rs +++ b/crates/api_common/src/post.rs @@ -176,11 +176,11 @@ pub struct EditPost { /// Instead of fetching a thumbnail, use a custom one. #[cfg_attr(feature = "full", ts(optional))] pub custom_thumbnail: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub tags: Option>, /// Time when this post should be scheduled. Null means publish immediately. #[cfg_attr(feature = "full", ts(optional))] pub scheduled_publish_time: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub tags: Option>, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] diff --git a/crates/api_common/src/tags.rs b/crates/api_common/src/tags.rs new file mode 100644 index 0000000000..d8ce538233 --- /dev/null +++ b/crates/api_common/src/tags.rs @@ -0,0 +1,45 @@ +use crate::{context::LemmyContext, utils::check_community_mod_action}; +use activitypub_federation::config::Data; +use lemmy_db_schema::{ + newtypes::TagId, + source::{community::Community, post::Post, post_tag::PostTag, tag::PostTagInsertForm}, + traits::Crud, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::LemmyResult; + +pub async fn update_post_tags( + context: &Data, + post: &Post, + community: &Community, + tags: &[TagId], + local_user_view: &LocalUserView, +) -> LemmyResult<()> { + let post = Post::read(&mut context.pool(), post.id).await?; + + let is_author = Post::is_post_creator(local_user_view.person.id, post.creator_id); + + if !is_author { + // Check if user is either the post author or a community mod + check_community_mod_action( + &local_user_view.person, + community, + false, + &mut context.pool(), + ) + .await?; + } + + // Delete existing post tags + PostTag::delete_for_post(&mut context.pool(), post.id).await?; + + // Create new post tags + for tag_id in tags { + let form = PostTagInsertForm { + post_id: post.id, + tag_id: *tag_id, + }; + PostTag::create(&mut context.pool(), &form).await?; + } + Ok(()) +} diff --git a/crates/api_crud/src/post/create.rs b/crates/api_crud/src/post/create.rs index 60aa74a1ec..3f9e7e2d65 100644 --- a/crates/api_crud/src/post/create.rs +++ b/crates/api_crud/src/post/create.rs @@ -7,6 +7,7 @@ use lemmy_api_common::{ post::{CreatePost, PostResponse}, request::generate_post_link_metadata, send_activity::SendActivityData, + tags::update_post_tags, utils::{ check_community_user_action, get_url_blocklist, @@ -123,6 +124,10 @@ pub async fn create_post( .await .with_lemmy_type(LemmyErrorType::CouldntCreatePost)?; + if let Some(tags) = &data.tags { + update_post_tags(&context, &inserted_post, &community, tags, &local_user_view).await?; + } + let community_id = community.id; let federate_post = if scheduled_publish_time.is_none() { send_webmention(inserted_post.clone(), community); diff --git a/crates/api_crud/src/post/update.rs b/crates/api_crud/src/post/update.rs index 8caaebfb69..167db34f1b 100644 --- a/crates/api_crud/src/post/update.rs +++ b/crates/api_crud/src/post/update.rs @@ -8,6 +8,7 @@ use lemmy_api_common::{ post::{EditPost, PostResponse}, request::generate_post_link_metadata, send_activity::SendActivityData, + tags::update_post_tags, utils::{ check_community_user_action, get_url_blocklist, @@ -99,6 +100,17 @@ pub async fn update_post( ) .await?; + if let Some(tags) = &data.tags { + update_post_tags( + &context, + &orig_post.post, + &orig_post.community, + tags, + &local_user_view, + ) + .await?; + } + // Verify that only the creator can edit if !Post::is_post_creator(local_user_view.person.id, orig_post.post.creator_id) { Err(LemmyErrorType::NoPostEditAllowed)? diff --git a/crates/db_schema/src/impls/community.rs b/crates/db_schema/src/impls/community.rs index 12578d5991..36fb8b9031 100644 --- a/crates/db_schema/src/impls/community.rs +++ b/crates/db_schema/src/impls/community.rs @@ -51,6 +51,8 @@ use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, settings::structs::Settings, }; +use regex::Regex; +use std::sync::LazyLock; use url::Url; #[async_trait] @@ -278,6 +280,20 @@ impl Community { .and(community::deleted.eq(false)) } + pub fn build_tag_ap_id(&self, tag_name: &str) -> LemmyResult { + #[allow(clippy::expect_used)] + static VALID_ID_SLUG: LazyLock = + LazyLock::new(|| Regex::new(r"[^a-z0-9_-]+").expect("compile regex")); + let tag_name_lower = tag_name.to_lowercase(); + let id_slug = VALID_ID_SLUG.replace_all(&tag_name_lower, "-"); + // limit length to 40 chars + let id_slug: String = id_slug.chars().take(40).collect(); + if id_slug.is_empty() { + Err(LemmyErrorType::InvalidUrl)? + } + Ok(Url::parse(&format!("{}/tag/{}", self.ap_id, &id_slug))?.into()) + } + pub fn local_url(name: &str, settings: &Settings) -> LemmyResult { let domain = settings.get_protocol_and_hostname(); Ok(Url::parse(&format!("{domain}/c/{name}"))?.into()) diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs index f6a01f06a8..59925f7010 100644 --- a/crates/db_schema/src/impls/mod.rs +++ b/crates/db_schema/src/impls/mod.rs @@ -32,6 +32,7 @@ pub mod person_comment_mention; pub mod person_post_mention; pub mod post; pub mod post_report; +pub mod post_tag; pub mod private_message; pub mod private_message_report; pub mod registration_application; diff --git a/crates/db_schema/src/impls/post_tag.rs b/crates/db_schema/src/impls/post_tag.rs new file mode 100644 index 0000000000..7e17a47b88 --- /dev/null +++ b/crates/db_schema/src/impls/post_tag.rs @@ -0,0 +1,51 @@ +use crate::{ + newtypes::{PostId, TagId}, + schema::post_tag, + source::{ + post_tag::{PostTag, PostTagForm}, + tag::PostTagInsertForm, + }, + traits::Crud, + utils::{get_conn, DbPool}, +}; +use diesel::{delete, insert_into, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; + +impl PostTag { + pub async fn delete_for_post( + pool: &mut DbPool<'_>, + post_id: PostId, + ) -> Result<(), diesel::result::Error> { + let conn = &mut get_conn(pool).await?; + delete(post_tag::table.filter(post_tag::post_id.eq(post_id))) + .execute(conn) + .await?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl Crud for PostTag { + type InsertForm = PostTagInsertForm; + type UpdateForm = PostTagForm; + type IdType = (PostId, TagId); + + async fn create( + pool: &mut DbPool<'_>, + form: &PostTagInsertForm, + ) -> Result { + let conn = &mut get_conn(pool).await?; + insert_into(post_tag::table) + .values(form) + .get_result::(conn) + .await + } + + async fn update( + _pool: &mut DbPool<'_>, + _id: Self::IdType, + _form: &Self::UpdateForm, + ) -> Result { + unimplemented!() + } +} diff --git a/crates/db_schema/src/impls/tag.rs b/crates/db_schema/src/impls/tag.rs index c0171e04ca..bd30a526cc 100644 --- a/crates/db_schema/src/impls/tag.rs +++ b/crates/db_schema/src/impls/tag.rs @@ -1,19 +1,32 @@ use crate::{ - newtypes::TagId, + newtypes::{CommunityId, TagId}, schema::{post_tag, tag}, - source::tag::{PostTagInsertForm, Tag, TagInsertForm}, + source::tag::{PostTagInsertForm, Tag, TagInsertForm, TagUpdateForm}, traits::Crud, utils::{get_conn, DbPool}, }; -use diesel::{insert_into, result::Error, QueryDsl}; +use diesel::{insert_into, result::Error, ExpressionMethods, QueryDsl}; use diesel_async::RunQueryDsl; use lemmy_utils::error::LemmyResult; - +impl Tag { + pub async fn get_by_community( + pool: &mut DbPool<'_>, + search_community_id: CommunityId, + ) -> Result, Error> { + use crate::schema::tag::dsl::*; + let conn = &mut get_conn(pool).await?; + tag + .filter(community_id.eq(search_community_id)) + .filter(deleted.eq(false)) + .load::(conn) + .await + } +} #[async_trait] impl Crud for Tag { type InsertForm = TagInsertForm; - type UpdateForm = TagInsertForm; + type UpdateForm = TagUpdateForm; type IdType = TagId; diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs index c34be3726e..bde3e85e65 100644 --- a/crates/db_schema/src/source/mod.rs +++ b/crates/db_schema/src/source/mod.rs @@ -38,6 +38,7 @@ pub mod person_comment_mention; pub mod person_post_mention; pub mod post; pub mod post_report; +pub mod post_tag; pub mod private_message; pub mod private_message_report; pub mod registration_application; diff --git a/crates/db_schema/src/source/post_tag.rs b/crates/db_schema/src/source/post_tag.rs new file mode 100644 index 0000000000..3576200e3f --- /dev/null +++ b/crates/db_schema/src/source/post_tag.rs @@ -0,0 +1,29 @@ +use crate::newtypes::{PostId, TagId}; +#[cfg(feature = "full")] +use crate::schema::post_tag; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "full", + derive(Queryable, Selectable, Associations, Identifiable) +)] +#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::post::Post)))] +#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::tag::Tag)))] +#[cfg_attr(feature = "full", diesel(table_name = post_tag))] +#[cfg_attr(feature = "full", diesel(primary_key(post_id, tag_id)))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +pub struct PostTag { + pub post_id: PostId, + pub tag_id: TagId, + pub published: DateTime, +} + +#[derive(Clone, Debug)] +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = post_tag))] +pub struct PostTagForm { + pub post_id: PostId, + pub tag_id: TagId, +} diff --git a/crates/db_schema/src/source/tag.rs b/crates/db_schema/src/source/tag.rs index 265d864c32..d50010cb09 100644 --- a/crates/db_schema/src/source/tag.rs +++ b/crates/db_schema/src/source/tag.rs @@ -48,6 +48,18 @@ pub struct TagInsertForm { pub deleted: bool, } +#[derive(Debug, Clone, Default)] +#[cfg_attr(feature = "full", derive(AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = tag))] +pub struct TagUpdateForm { + pub ap_id: Option, + pub name: Option, + pub community_id: Option, + pub published: Option>, + pub updated: Option>>, + pub deleted: Option, +} + #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = post_tag))] diff --git a/crates/db_views/src/combined/search_combined_view.rs b/crates/db_views/src/combined/search_combined_view.rs index d861f0687b..c5f1341a78 100644 --- a/crates/db_views/src/combined/search_combined_view.rs +++ b/crates/db_views/src/combined/search_combined_view.rs @@ -507,6 +507,7 @@ impl InternalToCombinedView for SearchCombinedViewInternal { blocked: v.community_blocked, banned_from_community: v.banned_from_community, can_mod: v.can_mod, + post_tags: v.post_tags, })) } else if let (Some(person), Some(counts)) = (v.item_creator, v.item_creator_counts) { Some(SearchCombinedView::Person(PersonView { diff --git a/crates/db_views/src/post/post_tags_view.rs b/crates/db_views/src/post/post_tags_view.rs index 5d14925673..3a1e8d6266 100644 --- a/crates/db_views/src/post/post_tags_view.rs +++ b/crates/db_views/src/post/post_tags_view.rs @@ -1,5 +1,5 @@ //! see post_view.rs for the reason for this json decoding -use crate::structs::PostTags; +use crate::structs::TagsView; use diesel::{ deserialize::FromSql, pg::{Pg, PgValue}, @@ -7,22 +7,22 @@ use diesel::{ sql_types::{self, Nullable}, }; -impl FromSql, Pg> for PostTags { +impl FromSql, Pg> for TagsView { fn from_sql(bytes: PgValue) -> diesel::deserialize::Result { let value = >::from_sql(bytes)?; - Ok(serde_json::from_value::(value)?) + Ok(serde_json::from_value::(value)?) } fn from_nullable_sql( bytes: Option<::RawValue<'_>>, ) -> diesel::deserialize::Result { match bytes { Some(bytes) => Self::from_sql(bytes), - None => Ok(Self { tags: vec![] }), + None => Ok(Self(vec![])), } } } -impl ToSql, Pg> for PostTags { +impl ToSql, Pg> for TagsView { fn to_sql(&self, out: &mut diesel::serialize::Output) -> diesel::serialize::Result { let value = serde_json::to_value(self)?; >::to_sql(&value, &mut out.reborrow()) diff --git a/crates/db_views/src/post/post_view.rs b/crates/db_views/src/post/post_view.rs index 8a5e07fc4d..f8f8e07d75 100644 --- a/crates/db_views/src/post/post_view.rs +++ b/crates/db_views/src/post/post_view.rs @@ -658,7 +658,7 @@ impl<'a> PostQuery<'a> { mod tests { use crate::{ post::post_view::{PaginationCursorData, PostQuery, PostView}, - structs::{LocalUserView, PostTags}, + structs::{LocalUserView, TagsView}, }; use chrono::Utc; use diesel_async::SimpleAsyncConnection; @@ -2047,7 +2047,7 @@ mod tests { hidden: false, saved: None, creator_blocked: false, - tags: PostTags::default(), + tags: TagsView::default(), }) } @@ -2427,14 +2427,14 @@ mod tests { ) .await?; - assert_eq!(2, post_view.tags.tags.len()); - assert_eq!(data.tag_1.name, post_view.tags.tags[0].name); - assert_eq!(data.tag_2.name, post_view.tags.tags[1].name); + assert_eq!(2, post_view.tags.0.len()); + assert_eq!(data.tag_1.name, post_view.tags.0[0].name); + assert_eq!(data.tag_2.name, post_view.tags.0[1].name); let all_posts = data.default_post_query().list(&data.site, pool).await?; - assert_eq!(2, all_posts[0].tags.tags.len()); // post with tags - assert_eq!(0, all_posts[1].tags.tags.len()); // bot post - assert_eq!(0, all_posts[2].tags.tags.len()); // normal post + assert_eq!(2, all_posts[0].tags.0.len()); // post with tags + assert_eq!(0, all_posts[1].tags.0.len()); // bot post + assert_eq!(0, all_posts[2].tags.0.len()); // normal post Ok(()) } diff --git a/crates/db_views/src/structs.rs b/crates/db_views/src/structs.rs index 5f3a39adbe..d51893ea2f 100644 --- a/crates/db_views/src/structs.rs +++ b/crates/db_views/src/structs.rs @@ -15,6 +15,7 @@ use diesel::{ Queryable, Selectable, }; +use diesel::{expression::SqlLiteral, sql_types::Json}; use lemmy_db_schema::{ aggregates::structs::{ CommentAggregates, @@ -79,7 +80,16 @@ use lemmy_db_schema::{ impls::comment::comment_select_remove_deletes, impls::community::community_follower_select_subscribed_type, impls::local_user::local_user_can_mod, - schema::{comment, comment_actions, community_actions, local_user, person, person_actions}, + schema::{ + comment, + comment_actions, + community, + community_actions, + local_user, + person, + person_actions, + tag, + }, utils::functions::coalesce, Person1AliasAllColumnsTuple, }; @@ -353,7 +363,7 @@ pub struct PostView { #[cfg_attr(feature = "full", ts(optional))] pub my_vote: Option, pub unread_comments: i64, - pub tags: PostTags, + pub tags: TagsView, pub can_mod: bool, } @@ -509,7 +519,7 @@ pub(crate) struct PersonContentCombinedViewInternal { pub post_hidden: bool, pub my_post_vote: Option, pub image_details: Option, - pub post_tags: PostTags, + pub post_tags: TagsView, // Comment-specific pub comment: Option, pub comment_counts: Option, @@ -609,8 +619,23 @@ pub struct CommunityView { ) )] pub can_mod: bool, + #[cfg_attr(feature = "full", + diesel( + select_expression = tag_fragment() + ) + )] + pub post_tags: TagsView, } +#[diesel::dsl::auto_type] +fn tag_fragment() -> _ { + let sel: SqlLiteral = diesel::dsl::sql::("json_agg(tag.*)"); + tag::table + .select(sel) + .filter(tag::community_id.eq(community::id)) + .filter(tag::deleted.eq(false)) + .single_value() +} /// The community sort types. See here for descriptions: https://join-lemmy.org/docs/en/users/03-votes-and-ranking.html #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] @@ -689,8 +714,8 @@ pub struct PersonPostMentionView { #[cfg_attr(feature = "full", ts(optional))] pub my_vote: Option, pub unread_comments: i64, - pub post_tags: PostTags, pub can_mod: bool, + pub post_tags: TagsView, } #[skip_serializing_none] @@ -794,7 +819,7 @@ pub struct InboxCombinedViewInternal { pub post_hidden: bool, pub my_post_vote: Option, pub image_details: Option, - pub post_tags: PostTags, + pub post_tags: TagsView, // Private message pub private_message: Option, // Shared @@ -1171,7 +1196,7 @@ pub(crate) struct SearchCombinedViewInternal { pub post_hidden: bool, pub my_post_vote: Option, pub image_details: Option, - pub post_tags: PostTags, + pub post_tags: TagsView, // // Comment-specific pub comment: Option, pub comment_counts: Option, @@ -1207,10 +1232,8 @@ pub enum SearchCombinedView { } #[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Default)] -#[cfg_attr(feature = "full", derive(TS, FromSqlRow, AsExpression))] #[serde(transparent)] +#[cfg_attr(feature = "full", derive(TS, FromSqlRow, AsExpression))] #[cfg_attr(feature = "full", diesel(sql_type = Nullable))] /// we wrap this in a struct so we can implement FromSqlRow for it -pub struct PostTags { - pub tags: Vec, -} +pub struct TagsView(pub Vec); diff --git a/src/api_routes_v4.rs b/src/api_routes_v4.rs index 48710b0df4..6373bada65 100644 --- a/src/api_routes_v4.rs +++ b/src/api_routes_v4.rs @@ -18,6 +18,7 @@ use lemmy_api::{ list::get_pending_follows_list, }, random::get_random_community, + tag::{create_community_tag, delete_community_tag, update_community_tag}, transfer::transfer_community, }, local_user::{ @@ -222,6 +223,9 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { .route("/icon", delete().to(delete_community_icon)) .route("/banner", post().to(upload_community_banner)) .route("/banner", delete().to(delete_community_banner)) + .route("/post_tag", post().to(create_community_tag)) + .route("/post_tag", put().to(update_community_tag)) + .route("/post_tag", delete().to(delete_community_tag)) .service( scope("/pending_follows") .route("/count", get().to(get_pending_follows_count))