mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-27 04:00:10 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
895d72fd37 | ||
|
|
ee0afabc66 | ||
|
|
4c3f0b11c9 | ||
|
|
ceec7d4b77 | ||
|
|
efc8dadcb3 |
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT DISTINCT tag_token as `tag_token: TagToken`\nFROM media_file_tags\nWHERE media_file_token = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "tag_token: TagToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "05033f7f4588cc2d68d9db22236f2be323731ee120217192fa29fe60af776e19"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "DELETE FROM media_file_tags WHERE tag_token = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3495f03338e7ee4c2ae9787204d521aefa787312463d392e45d02a693d9afb83"
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "media_file_id: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "media_file_token: MediaFileToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "media_class: MediaFileClass",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "media_type: MediaFileType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_batch_token: BatchGenerationToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "public_bucket_directory_hash",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_public_bucket_prefix",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_public_bucket_extension",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_cover_public_bucket_directory_hash?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_cover_public_bucket_prefix?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_cover_public_bucket_extension?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "creator_set_visibility: Visibility",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 28
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "is_user_upload: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "maybe_prompt_token: PromptToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "maybe_origin_filename",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "maybe_duration_millis",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "maybe_frame_width",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "maybe_frame_height",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "76bcf698f40d852558f071a6909b05b1c960c1cf236831ae009f90de46d3aefb"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tags\nSET tag_value = ?,\n tag_value_lowercase = ?\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8938fc564dda214e383a8d30532db6ef964c03f7e1fd6d9561bca5e8b56834cf"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n maybe_creator_user_token as `maybe_creator_user_token: UserToken`,\n creator_set_visibility as `creator_set_visibility: Visibility`\nFROM media_files\nWHERE token = ?\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "maybe_creator_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "creator_set_visibility: Visibility",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 28
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a114fab486689fd2b16197cbe3a3ebb41d3d10937542832e15dd652200b6d419"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE tags\nSET maybe_deleted_at = NOW(),\n use_count = 0\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a58fc9050c1fd160e03d6e4932c1b777ea7ab8cbfddca5834faab3e2963d816d"
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "media_file_id: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "media_file_token: MediaFileToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "media_class: MediaFileClass",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "media_type: MediaFileType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_batch_token: BatchGenerationToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "public_bucket_directory_hash",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_public_bucket_prefix",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_public_bucket_extension",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_cover_public_bucket_directory_hash?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_cover_public_bucket_prefix?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_cover_public_bucket_extension?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "creator_set_visibility: Visibility",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 28
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "is_user_upload: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "maybe_prompt_token: PromptToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "maybe_origin_filename",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "maybe_duration_millis",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "maybe_frame_width",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "maybe_frame_height",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "aa1e693babe6997e75935ec893e6eed46351ec72ce02a9144fd11d2fcfd37fdf"
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n t.id as `id: u64`,\n t.token as `token: TagToken`,\n t.tag_value,\n t.tag_value_lowercase,\n t.use_count as `use_count: u32`\nFROM media_file_tags mft\nJOIN tags t\n ON t.token = mft.tag_token\nWHERE mft.media_file_token = ?\n AND t.maybe_deleted_at IS NULL\nORDER BY t.tag_value_lowercase ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "token: TagToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "tag_value",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "tag_value_lowercase",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "use_count: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bbfed9e861b1a7cd0cdb2413e2a985aaa79aa9c885408bf5e104d1ea80bdaf49"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT token as `token: TagToken`\nFROM tags\nWHERE creator_user_token = ?\n AND tag_value_lowercase = ?\n AND maybe_deleted_at IS NOT NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: TagToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cbfe9172d6227175aa6dfd276a4d1a6676d1d1b6bdc3a35927a5d71be38219c0"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nDELETE FROM tags\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NOT NULL\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e50d11df0606a12059e13f36daa41fc5aa59d700168425c406e545b535d6d2ba"
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n id as `id: u64`,\n token as `token: TagToken`,\n tag_value,\n tag_value_lowercase,\n use_count as `use_count: u32`\nFROM tags\nWHERE creator_user_token = ?\n AND maybe_deleted_at IS NULL\n AND id < ?\nORDER BY id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "token: TagToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "tag_value",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "tag_value_lowercase",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "use_count: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "eafaf1a9ed2e27dec201ea66d23a65662492ca2dbdac6333629b82defc4b5111"
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n id as `id: u64`,\n token as `token: TagToken`,\n tag_value,\n tag_value_lowercase,\n use_count as `use_count: u32`\nFROM tags\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "token: TagToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "tag_value",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "tag_value_lowercase",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "use_count: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ecc3848cd186f1298a2a60c810c69b7a8715719a0c7622122d7e1c5747fab06a"
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_file_tags mft\nJOIN media_files mf\n ON mf.token = mft.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mft.tag_token = ?\n AND mft.user_token = ?\n AND mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "media_file_id: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "media_file_token: MediaFileToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "media_class: MediaFileClass",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "media_type: MediaFileType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_batch_token: BatchGenerationToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "public_bucket_directory_hash",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_public_bucket_prefix",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_public_bucket_extension",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_cover_public_bucket_directory_hash?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_cover_public_bucket_prefix?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_cover_public_bucket_extension?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "creator_set_visibility: Visibility",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 28
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "is_user_upload: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "maybe_prompt_token: PromptToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "maybe_origin_filename",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "maybe_duration_millis",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "maybe_frame_width",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "maybe_frame_height",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f36c7238231353882b5b2ac39616dcdc6adebf7c7e1198d9c2af4674ba5d982c"
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod omni_gen;
|
||||
pub mod prompts;
|
||||
pub mod stripe_artcraft;
|
||||
pub mod subscriptions;
|
||||
pub mod tags;
|
||||
pub mod user_referral_codes;
|
||||
pub mod users;
|
||||
pub mod utils;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::tags::common::TagDetails;
|
||||
|
||||
// ── POST /v1/tags/media_file/add/{media_file_token} ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AddMediaFileTagsPathInfo {
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
|
||||
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
|
||||
/// both or neither). Tag text is trimmed and deduped case-insensitively;
|
||||
/// empty entries are dropped. Must sanitize to at least one tag (400
|
||||
/// otherwise).
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AddMediaFileTagsRequest {
|
||||
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
|
||||
pub maybe_tags: Option<String>,
|
||||
|
||||
/// Tags as a list. Entries are still trimmed.
|
||||
pub maybe_tags_list: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AddMediaFileTagsSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The tags from this request after upsert, with canonical tokens and
|
||||
/// fresh use counts. Tags already on the file are absorbed (no error).
|
||||
pub tags: Vec<TagDetails>,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::tags::common::TagDetails;
|
||||
|
||||
// ── POST /v1/tags/bulk_add ──
|
||||
|
||||
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
|
||||
/// both or neither). Tag text is trimmed and deduped case-insensitively;
|
||||
/// empty entries are dropped. Must sanitize to at least one tag (400
|
||||
/// otherwise). Media file tokens are deduped; tokens the user doesn't
|
||||
/// own (or that are deleted) are silently skipped.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct BulkAddTagsRequest {
|
||||
/// Media files to tag.
|
||||
pub media_file_tokens: Vec<MediaFileToken>,
|
||||
|
||||
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
|
||||
pub maybe_tags: Option<String>,
|
||||
|
||||
/// Tags as a list. Entries are still trimmed.
|
||||
pub maybe_tags_list: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BulkAddTagsSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The subset of the input tokens that were actually tagged: files
|
||||
/// that exist, aren't deleted, and are owned by the caller.
|
||||
pub accepted_media_file_tokens: Vec<MediaFileToken>,
|
||||
|
||||
/// The tags from this request after upsert, with canonical tokens and
|
||||
/// fresh use counts.
|
||||
pub tags: Vec<TagDetails>,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::tags::common::TagDetails;
|
||||
|
||||
// ── POST /v1/tags/media_files/bulk_list_tags ──
|
||||
//
|
||||
// POST rather than GET: the token list belongs in a request body, and
|
||||
// GET request bodies are non-standard. The operation is still a pure
|
||||
// read.
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct BulkListMediaFileTagsRequest {
|
||||
/// Media files to look up. Tokens with no tags come back with an
|
||||
/// empty `tags` list.
|
||||
pub media_file_tokens: Vec<MediaFileToken>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MediaFileTagsEntry {
|
||||
pub media_file_token: MediaFileToken,
|
||||
pub tags: Vec<TagDetails>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BulkListMediaFileTagsSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// One entry per requested media file token (deduped), in request order.
|
||||
pub media_files: Vec<MediaFileTagsEntry>,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::tags::common::TagDetails;
|
||||
|
||||
// ── POST /v1/tags/bulk_set ──
|
||||
|
||||
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
|
||||
/// both or neither). Tag text is trimmed and deduped case-insensitively;
|
||||
/// empty entries are dropped. Unlike `bulk_add`, sanitizing down to zero
|
||||
/// tags is allowed — it clears all tags from the listed media files.
|
||||
/// Media file tokens are deduped; tokens the user doesn't own (or that
|
||||
/// are deleted) are silently skipped.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct BulkSetTagsRequest {
|
||||
/// Media files whose tag sets will be replaced.
|
||||
pub media_file_tokens: Vec<MediaFileToken>,
|
||||
|
||||
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
|
||||
pub maybe_tags: Option<String>,
|
||||
|
||||
/// Tags as a list. Entries are still trimmed.
|
||||
pub maybe_tags_list: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BulkSetTagsSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The subset of the input tokens that were actually updated: files
|
||||
/// that exist, aren't deleted, and are owned by the caller.
|
||||
pub accepted_media_file_tokens: Vec<MediaFileToken>,
|
||||
|
||||
/// The full tag set now on every accepted media file, with canonical
|
||||
/// tokens and fresh use counts.
|
||||
pub tags: Vec<TagDetails>,
|
||||
|
||||
/// How many previously-attached tag links were removed across all the
|
||||
/// accepted media files because they weren't mentioned in the request.
|
||||
/// (Orphaned tags are not deleted.)
|
||||
pub removed_count: u64,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
// ── POST /v1/tags/media_file/clear/{media_file_token} ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ClearMediaFileTagsPathInfo {
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ClearMediaFileTagsSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// How many tag links were removed from the media file. (Orphaned
|
||||
/// tags are not deleted.)
|
||||
pub removed_count: u64,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
/// One tag as returned by every tags endpoint.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TagDetails {
|
||||
pub tag_token: TagToken,
|
||||
|
||||
/// The display value of the tag, as entered by its creator.
|
||||
pub tag_value: String,
|
||||
|
||||
/// Lowercased form of `tag_value`. This is the tag's unique key
|
||||
/// within the creator's account.
|
||||
pub tag_value_lowercase: String,
|
||||
|
||||
/// Rollup statistic: how many media files currently carry this tag.
|
||||
pub use_count: u32,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
// ── DELETE /v1/tags/{tag_token} ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct DeleteTagPathInfo {
|
||||
pub tag_token: TagToken,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DeleteTagSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// How many media-file links were hard-deleted along with the tag.
|
||||
/// (The tag record itself is soft-deleted.)
|
||||
pub removed_link_count: u64,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::tags::common::TagDetails;
|
||||
|
||||
// ── GET /v1/tags/media_file/list/{media_file_token} ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListMediaFileTagsPathInfo {
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListMediaFileTagsSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// All (live) tags on the media file, sorted by tag value.
|
||||
pub tags: Vec<TagDetails>,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use serde_derive::Deserialize;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
// ── GET /v1/tags/media_files/with_tag/{tag_token} ──
|
||||
//
|
||||
// NB: The success response lives in `storyteller_web`'s handler because
|
||||
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
|
||||
// constructors that depend on the request's `MediaDomain` +
|
||||
// `ServerEnvironment`.
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesWithTagPathInfo {
|
||||
pub tag_token: TagToken,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListMediaFilesWithTagQueryParams {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::Deserialize;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
// ── GET /v1/tags/media_files/list_tagged ──
|
||||
//
|
||||
// NB: The success response lives in `storyteller_web`'s handler because
|
||||
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
|
||||
// constructors that depend on the request's `MediaDomain` +
|
||||
// `ServerEnvironment`.
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListTaggedMediaFilesQueryParams {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::tags::common::TagDetails;
|
||||
|
||||
// ── GET /v1/tags/list ──
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListTagsQueryParams {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListTagsSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The logged-in user's (live) tags, newest first.
|
||||
pub tags: Vec<TagDetails>,
|
||||
|
||||
/// Present when there may be more results; pass back as `cursor`.
|
||||
pub maybe_cursor: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::Deserialize;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
// ── GET /v1/tags/media_files/list_untagged ──
|
||||
//
|
||||
// NB: The success response lives in `storyteller_web`'s handler because
|
||||
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
|
||||
// constructors that depend on the request's `MediaDomain` +
|
||||
// `ServerEnvironment`.
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListUntaggedMediaFilesQueryParams {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub mod add_media_file_tags;
|
||||
pub mod bulk_add_tags;
|
||||
pub mod bulk_list_media_file_tags;
|
||||
pub mod bulk_set_tags;
|
||||
pub mod clear_media_file_tags;
|
||||
pub mod common;
|
||||
pub mod delete_tag;
|
||||
pub mod list_media_file_tags;
|
||||
pub mod list_media_files_with_tag;
|
||||
pub mod list_tagged_media_files;
|
||||
pub mod list_tags;
|
||||
pub mod list_untagged_media_files;
|
||||
pub mod rename_tag;
|
||||
pub mod set_media_file_tags;
|
||||
@@ -0,0 +1,29 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
use crate::tags::common::TagDetails;
|
||||
|
||||
// ── PUT /v1/tags/rename/{tag_token} ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct RenameTagPathInfo {
|
||||
pub tag_token: TagToken,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct RenameTagRequest {
|
||||
/// The new tag text (trimmed server-side). Both case-only changes and
|
||||
/// wholesale renames are allowed. Fails with 400 if the caller already
|
||||
/// has a different tag with the same lowercased value.
|
||||
pub new_tag_value: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct RenameTagSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The tag after the rename.
|
||||
pub tag: TagDetails,
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::tags::common::TagDetails;
|
||||
|
||||
// ── POST /v1/tags/media_file/set/{media_file_token} ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SetMediaFileTagsPathInfo {
|
||||
pub media_file_token: MediaFileToken,
|
||||
}
|
||||
|
||||
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
|
||||
/// both or neither). Tag text is trimmed and deduped case-insensitively;
|
||||
/// empty entries are dropped. Unlike `add`, sanitizing down to zero tags
|
||||
/// is allowed — it clears all tags from the media file.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SetMediaFileTagsRequest {
|
||||
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
|
||||
pub maybe_tags: Option<String>,
|
||||
|
||||
/// Tags as a list. Entries are still trimmed.
|
||||
pub maybe_tags_list: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SetMediaFileTagsSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The media file's full tag set after the operation, with canonical
|
||||
/// tokens and fresh use counts.
|
||||
pub tags: Vec<TagDetails>,
|
||||
|
||||
/// How many previously-attached tag links were removed because they
|
||||
/// weren't mentioned in the request. (Orphaned tags are not deleted.)
|
||||
pub removed_count: u64,
|
||||
}
|
||||
+1
-1
@@ -55,7 +55,7 @@ pub async fn delete_all_anonymous_user_images(_args: &Args, mysql: &Pool<MySql>)
|
||||
}
|
||||
|
||||
info!("Deleting image file: {:?}", &file.token);
|
||||
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, &mysql).await?;
|
||||
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
|
||||
}
|
||||
|
||||
page_index += 1;
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ pub async fn delete_all_files(username: &str, mysql: &Pool<MySql>) -> AnyhowResu
|
||||
|
||||
for file in media_files.records {
|
||||
info!("Deleting file: {:?}", &file.token);
|
||||
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, &mysql).await?;
|
||||
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
|
||||
}
|
||||
|
||||
page_index += 1;
|
||||
|
||||
+29
-17
@@ -1,12 +1,15 @@
|
||||
use sqlx::MySqlPool;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use errors::AnyhowResult;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
pub async fn delete_media_file_as_user(
|
||||
pub async fn delete_media_file_as_user<'e, 'c: 'e, E>(
|
||||
media_file_token: &MediaFileToken,
|
||||
mysql_pool: &MySqlPool
|
||||
) -> AnyhowResult<()> {
|
||||
mysql_executor: E,
|
||||
) -> AnyhowResult<()>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let _r = sqlx::query!(
|
||||
r#"
|
||||
UPDATE media_files
|
||||
@@ -18,16 +21,19 @@ LIMIT 1
|
||||
"#,
|
||||
media_file_token,
|
||||
)
|
||||
.execute(mysql_pool)
|
||||
.execute(mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_media_file_as_mod(
|
||||
pub async fn delete_media_file_as_mod<'e, 'c: 'e, E>(
|
||||
media_file_token: &MediaFileToken,
|
||||
mod_user_token: &str,
|
||||
mysql_pool: &MySqlPool
|
||||
) -> AnyhowResult<()> {
|
||||
mysql_executor: E,
|
||||
) -> AnyhowResult<()>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let _r = sqlx::query!(
|
||||
r#"
|
||||
UPDATE media_files
|
||||
@@ -42,15 +48,18 @@ LIMIT 1
|
||||
mod_user_token,
|
||||
media_file_token,
|
||||
)
|
||||
.execute(mysql_pool)
|
||||
.execute(mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn undelete_media_file_as_user(
|
||||
pub async fn undelete_media_file_as_user<'e, 'c: 'e, E>(
|
||||
media_file_token: &MediaFileToken,
|
||||
mysql_pool: &MySqlPool
|
||||
) -> AnyhowResult<()> {
|
||||
mysql_executor: E,
|
||||
) -> AnyhowResult<()>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let _r = sqlx::query!(
|
||||
r#"
|
||||
UPDATE media_files
|
||||
@@ -62,16 +71,19 @@ LIMIT 1
|
||||
"#,
|
||||
media_file_token,
|
||||
)
|
||||
.execute(mysql_pool)
|
||||
.execute(mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn undelete_media_file_as_mod(
|
||||
pub async fn undelete_media_file_as_mod<'e, 'c: 'e, E>(
|
||||
media_file_token: &MediaFileToken,
|
||||
mod_user_token: &str,
|
||||
mysql_pool: &MySqlPool
|
||||
) -> AnyhowResult<()> {
|
||||
mysql_executor: E,
|
||||
) -> AnyhowResult<()>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let _r = sqlx::query!(
|
||||
r#"
|
||||
UPDATE media_files
|
||||
@@ -85,7 +97,7 @@ LIMIT 1
|
||||
mod_user_token,
|
||||
media_file_token,
|
||||
)
|
||||
.execute(mysql_pool)
|
||||
.execute(mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
/// The common media-file row shape shared by list queries (folder
|
||||
/// listings, tag listings, etc.): the file's own metadata plus the
|
||||
/// joined cover-image bucket fields the endpoints need to materialize
|
||||
/// MediaLinks, MediaFileCoverImageDetails, and the rest of the typed
|
||||
/// response shape.
|
||||
///
|
||||
/// Queries can `sqlx::query_as!` directly into this struct when they
|
||||
/// select exactly these columns (alias `media_files.id` to
|
||||
/// `media_file_id`, and the cover LEFT JOIN fields to the
|
||||
/// `maybe_cover_*` names).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaFileListRow {
|
||||
/// `media_files.id` — used as the pagination cursor.
|
||||
pub media_file_id: u64,
|
||||
|
||||
pub media_file_token: MediaFileToken,
|
||||
pub media_class: MediaFileClass,
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
pub maybe_batch_token: Option<BatchGenerationToken>,
|
||||
|
||||
// The media file's own bucket fields (caller uses these to build MediaLinks).
|
||||
pub public_bucket_directory_hash: String,
|
||||
pub maybe_public_bucket_prefix: Option<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
|
||||
// Cover image's bucket fields, from a LEFT JOIN on
|
||||
// mf.maybe_cover_image_media_file_token. All three are Some-or-None
|
||||
// together (matched / unmatched join row).
|
||||
pub maybe_cover_public_bucket_directory_hash: Option<String>,
|
||||
pub maybe_cover_public_bucket_prefix: Option<String>,
|
||||
pub maybe_cover_public_bucket_extension: Option<String>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
pub is_user_upload: bool,
|
||||
|
||||
pub maybe_title: Option<String>,
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
pub maybe_origin_filename: Option<String>,
|
||||
|
||||
pub maybe_duration_millis: Option<i32>,
|
||||
pub maybe_frame_width: Option<i32>,
|
||||
pub maybe_frame_height: Option<i32>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -5,3 +5,4 @@ pub mod list_media_files_by_batch_token;
|
||||
pub mod list_media_files_for_elastic_search_backfill_using_cursor;
|
||||
pub mod list_media_files_for_user;
|
||||
pub mod list_session_project_media_files;
|
||||
pub mod media_file_list_row;
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod prompts;
|
||||
pub mod public_event_feed;
|
||||
pub mod staff_audit_logs;
|
||||
pub mod stats;
|
||||
pub mod tags;
|
||||
pub mod trending_model_analytics;
|
||||
pub mod tts;
|
||||
pub mod twitch;
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder, Row};
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
/// One (media file, tag) pairing from the bulk lookup. The same tag can
|
||||
/// appear under many media files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaFileTagPairRow {
|
||||
pub media_file_token: MediaFileToken,
|
||||
pub tag_token: TagToken,
|
||||
pub tag_value: String,
|
||||
pub tag_value_lowercase: String,
|
||||
pub use_count: u32,
|
||||
}
|
||||
|
||||
pub struct BulkListTagsForMediaFilesArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub media_file_tokens: &'e [MediaFileToken],
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// All (live) tags on each of the given media files, in one round-trip.
|
||||
/// Media files with no tags simply produce no rows. Not scoped to any
|
||||
/// user: per-file tags are publicly visible.
|
||||
pub async fn bulk_list_tags_for_media_files<'e, 'c: 'e, E>(
|
||||
args: BulkListTagsForMediaFilesArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<MediaFileTagPairRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.media_file_tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"SELECT mft.media_file_token, t.token, t.tag_value, t.tag_value_lowercase, t.use_count \
|
||||
FROM media_file_tags mft \
|
||||
JOIN tags t ON t.token = mft.tag_token \
|
||||
WHERE t.maybe_deleted_at IS NULL AND mft.media_file_token IN (",
|
||||
);
|
||||
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.media_file_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let rows = builder.build().fetch_all(args.mysql_executor).await?;
|
||||
|
||||
Ok(rows.into_iter()
|
||||
.map(|row| MediaFileTagPairRow {
|
||||
media_file_token: MediaFileToken::new(row.get::<String, _>(0)),
|
||||
tag_token: TagToken::new(row.get::<String, _>(1)),
|
||||
tag_value: row.get::<String, _>(2),
|
||||
tag_value_lowercase: row.get::<String, _>(3),
|
||||
use_count: row.get::<u32, _>(4),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct ClearMediaFileTagsArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub media_file_tokens: &'e [MediaFileToken],
|
||||
pub user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Hard-delete ALL of the user's tag links on the given media files.
|
||||
/// Backs the clear endpoint, and the set endpoints when the requested
|
||||
/// tag set is empty. Returns the number of links deleted. (Tags orphaned
|
||||
/// by this are not deleted.)
|
||||
pub async fn clear_media_file_tags<'e, 'c: 'e, E>(
|
||||
args: ClearMediaFileTagsArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.media_file_tokens.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"DELETE FROM media_file_tags WHERE user_token = ",
|
||||
);
|
||||
builder.push_bind(args.user_token.as_str());
|
||||
|
||||
builder.push(" AND media_file_token IN (");
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.media_file_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let result = builder.build().execute(args.mysql_executor).await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
pub struct DeleteLinksToSoftDeletedTagsArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_tokens: &'e [TagToken],
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Remove any `media_file_tags` links pointing at tags that are
|
||||
/// soft-deleted, among the given tag tokens. Guards the apply-tags
|
||||
/// transaction against a concurrent tag delete: the DELETE is a locking
|
||||
/// read, so unlike the transaction's snapshot SELECTs it observes a
|
||||
/// just-committed soft-delete and cleans up links the snapshot happily
|
||||
/// inserted. Cheap — an IN-list probe on `index_tag_token` plus a
|
||||
/// primary-key lookup per link.
|
||||
pub async fn delete_links_to_soft_deleted_tags<'e, 'c: 'e, E>(
|
||||
args: DeleteLinksToSoftDeletedTagsArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.tag_tokens.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"DELETE media_file_tags FROM media_file_tags \
|
||||
JOIN tags ON tags.token = media_file_tags.tag_token \
|
||||
WHERE tags.maybe_deleted_at IS NOT NULL \
|
||||
AND media_file_tags.tag_token IN (",
|
||||
);
|
||||
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.tag_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let result = builder.build().execute(args.mysql_executor).await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
pub struct DeleteMediaFileTagsForTagArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_token: &'e TagToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Hard-delete every media-file link for a tag, across all media files.
|
||||
/// Used when the tag itself is being deleted. Returns the number of
|
||||
/// links deleted.
|
||||
pub async fn delete_media_file_tags_for_tag<'e, 'c: 'e, E>(
|
||||
args: DeleteMediaFileTagsForTagArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"DELETE FROM media_file_tags WHERE tag_token = ?"#,
|
||||
args.tag_token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct DeleteMediaFileTagsNotInSetArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub media_file_tokens: &'e [MediaFileToken],
|
||||
pub keep_tag_tokens: &'e [TagToken],
|
||||
pub user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Hard-delete the user's tag links on the given media files, EXCEPT
|
||||
/// links whose tag is in `keep_tag_tokens`. This is the "remove what
|
||||
/// wasn't mentioned" half of the set-tags endpoints. Returns the number
|
||||
/// of links deleted. (Tags orphaned by this are not deleted.)
|
||||
///
|
||||
/// `keep_tag_tokens` must be non-empty — to remove everything, use
|
||||
/// `clear_media_file_tags` instead (an empty NOT IN list is invalid SQL).
|
||||
pub async fn delete_media_file_tags_not_in_set<'e, 'c: 'e, E>(
|
||||
args: DeleteMediaFileTagsNotInSetArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.media_file_tokens.is_empty() || args.keep_tag_tokens.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"DELETE FROM media_file_tags WHERE user_token = ",
|
||||
);
|
||||
builder.push_bind(args.user_token.as_str());
|
||||
|
||||
builder.push(" AND media_file_token IN (");
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.media_file_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
builder.push(" AND tag_token NOT IN (");
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.keep_tag_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let result = builder.build().execute(args.mysql_executor).await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder, Row};
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct FilterOwnedMediaFileTokensArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub candidate_tokens: &'e [MediaFileToken],
|
||||
pub owner_user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Return only the input tokens that exist, aren't soft-deleted, and are
|
||||
/// owned (created) by the given user. Tagging endpoints run their inputs
|
||||
/// through this — for now only a media file's creator may tag it.
|
||||
pub async fn filter_owned_media_file_tokens<'e, 'c: 'e, E>(
|
||||
args: FilterOwnedMediaFileTokensArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<MediaFileToken>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.candidate_tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"SELECT token FROM media_files \
|
||||
WHERE maybe_creator_user_token = ",
|
||||
);
|
||||
builder.push_bind(args.owner_user_token.as_str());
|
||||
builder.push(
|
||||
" AND user_deleted_at IS NULL AND mod_deleted_at IS NULL AND token IN (",
|
||||
);
|
||||
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.candidate_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let rows = builder.build().fetch_all(args.mysql_executor).await?;
|
||||
|
||||
Ok(rows.into_iter()
|
||||
.map(|row| MediaFileToken::new(row.get::<String, _>(0)))
|
||||
.collect())
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder, Row};
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct FilterVisibleMediaFileTokensArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub candidate_tokens: &'e [MediaFileToken],
|
||||
pub requester_user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Return only the input tokens whose media file exists, isn't
|
||||
/// soft-deleted, and whose tags the requester may see: any non-private
|
||||
/// file (public and hidden files are viewable by anyone with the URL),
|
||||
/// or the requester's own files.
|
||||
pub async fn filter_visible_media_file_tokens<'e, 'c: 'e, E>(
|
||||
args: FilterVisibleMediaFileTokensArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<MediaFileToken>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.candidate_tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"SELECT token FROM media_files \
|
||||
WHERE user_deleted_at IS NULL \
|
||||
AND mod_deleted_at IS NULL \
|
||||
AND (creator_set_visibility != ",
|
||||
);
|
||||
builder.push_bind(Visibility::Private.to_str());
|
||||
builder.push(" OR maybe_creator_user_token = ");
|
||||
builder.push_bind(args.requester_user_token.as_str());
|
||||
builder.push(") AND token IN (");
|
||||
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.candidate_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let rows = builder.build().fetch_all(args.mysql_executor).await?;
|
||||
|
||||
Ok(rows.into_iter()
|
||||
.map(|row| MediaFileToken::new(row.get::<String, _>(0)))
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Just enough of a media file to decide whether a requester may see
|
||||
/// its tags.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaFileAccessFields {
|
||||
pub maybe_creator_user_token: Option<UserToken>,
|
||||
pub creator_set_visibility: Visibility,
|
||||
}
|
||||
|
||||
pub struct GetMediaFileAccessFieldsArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub media_file_token: &'e MediaFileToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Fetch the owner + visibility of a (live) media file. Returns
|
||||
/// `Ok(None)` if the file doesn't exist or is soft-deleted.
|
||||
pub async fn get_media_file_access_fields<'e, 'c: 'e, E>(
|
||||
args: GetMediaFileAccessFieldsArgs<'e, 'c, E>,
|
||||
) -> Result<Option<MediaFileAccessFields>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
maybe_creator_user_token as `maybe_creator_user_token: UserToken`,
|
||||
creator_set_visibility as `creator_set_visibility: Visibility`
|
||||
FROM media_files
|
||||
WHERE token = ?
|
||||
AND user_deleted_at IS NULL
|
||||
AND mod_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.media_file_token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| MediaFileAccessFields {
|
||||
maybe_creator_user_token: r.maybe_creator_user_token,
|
||||
creator_set_visibility: r.creator_set_visibility,
|
||||
}))
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct GetSoftDeletedTagByLowercaseArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_value_lowercase: &'e str,
|
||||
pub creator_user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Find a user's SOFT-DELETED tag by its lowercased value. Soft-deleted
|
||||
/// rows still occupy the `(tag_value_lowercase, creator_user_token)`
|
||||
/// unique key, so a rename onto that value must purge the dead row
|
||||
/// first — this lookup feeds that purge.
|
||||
pub async fn get_soft_deleted_tag_by_lowercase<'e, 'c: 'e, E>(
|
||||
args: GetSoftDeletedTagByLowercaseArgs<'e, 'c, E>,
|
||||
) -> Result<Option<TagToken>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT token as `token: TagToken`
|
||||
FROM tags
|
||||
WHERE creator_user_token = ?
|
||||
AND tag_value_lowercase = ?
|
||||
AND maybe_deleted_at IS NOT NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.creator_user_token.as_str(),
|
||||
args.tag_value_lowercase,
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| r.token))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::tags::tag_row::TagRow;
|
||||
|
||||
pub struct GetTagForOwnerArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_token: &'e TagToken,
|
||||
pub creator_user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Fetch a single (live) tag by token, scoped to its creator. Returns
|
||||
/// `Ok(None)` if the tag doesn't exist, is soft-deleted, or belongs to a
|
||||
/// different user (don't leak existence of others' tags).
|
||||
pub async fn get_tag_for_owner<'e, 'c: 'e, E>(
|
||||
args: GetTagForOwnerArgs<'e, 'c, E>,
|
||||
) -> Result<Option<TagRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
id as `id: u64`,
|
||||
token as `token: TagToken`,
|
||||
tag_value,
|
||||
tag_value_lowercase,
|
||||
use_count as `use_count: u32`
|
||||
FROM tags
|
||||
WHERE token = ?
|
||||
AND creator_user_token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.tag_token.as_str(),
|
||||
args.creator_user_token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| TagRow {
|
||||
id: r.id,
|
||||
token: r.token,
|
||||
tag_value: r.tag_value,
|
||||
tag_value_lowercase: r.tag_value_lowercase,
|
||||
use_count: r.use_count,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct HardDeleteSoftDeletedTagArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_token: &'e TagToken,
|
||||
pub creator_user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Hard-delete a tag row that is ALREADY soft-deleted, freeing its
|
||||
/// `(tag_value_lowercase, creator_user_token)` unique-key slot so a
|
||||
/// rename can take the value over. Refuses to touch live tags — the
|
||||
/// `maybe_deleted_at IS NOT NULL` predicate makes a stale token a
|
||||
/// no-op. Callers should remove the tag's `media_file_tags` links first
|
||||
/// (`delete_media_file_tags_for_tag`).
|
||||
pub async fn hard_delete_soft_deleted_tag<'e, 'c: 'e, E>(
|
||||
args: HardDeleteSoftDeletedTagArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM tags
|
||||
WHERE token = ?
|
||||
AND creator_user_token = ?
|
||||
AND maybe_deleted_at IS NOT NULL
|
||||
"#,
|
||||
args.tag_token.as_str(),
|
||||
args.creator_user_token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct InsertMediaFileTagsArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub media_file_tokens: &'e [MediaFileToken],
|
||||
pub tag_tokens: &'e [TagToken],
|
||||
pub user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Attach every tag to every media file (full cartesian product) in a
|
||||
/// single multi-row statement. Uses `INSERT IGNORE` so already-attached
|
||||
/// pairs are absorbed without error — idempotent, which is what makes
|
||||
/// the add/set endpoints upsert-friendly. Returns the number of rows
|
||||
/// actually inserted.
|
||||
///
|
||||
/// Callers must cap the input sizes; the row count is
|
||||
/// `media_file_tokens.len() * tag_tokens.len()`.
|
||||
pub async fn insert_media_file_tags<'e, 'c: 'e, E>(
|
||||
args: InsertMediaFileTagsArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.media_file_tokens.is_empty() || args.tag_tokens.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let user_token_str = args.user_token.as_str();
|
||||
let pairs = args.media_file_tokens.iter()
|
||||
.flat_map(|media_file_token| {
|
||||
args.tag_tokens.iter()
|
||||
.map(move |tag_token| (media_file_token, tag_token))
|
||||
});
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"INSERT IGNORE INTO media_file_tags (media_file_token, tag_token, user_token) ",
|
||||
);
|
||||
builder.push_values(pairs, |mut b, (media_file_token, tag_token)| {
|
||||
b.push_bind(media_file_token.as_str())
|
||||
.push_bind(tag_token.as_str())
|
||||
.push_bind(user_token_str);
|
||||
});
|
||||
|
||||
let result = builder.build().execute(args.mysql_executor).await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder, Row};
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct ListLinkedTagTokensForMediaFilesArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub media_file_tokens: &'e [MediaFileToken],
|
||||
pub user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// The distinct tag tokens the user currently has linked to the given
|
||||
/// media files. Called before a destructive set/clear so we know which
|
||||
/// tags need their `use_count` recounted afterwards.
|
||||
pub async fn list_linked_tag_tokens_for_media_files<'e, 'c: 'e, E>(
|
||||
args: ListLinkedTagTokensForMediaFilesArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<TagToken>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.media_file_tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"SELECT DISTINCT tag_token FROM media_file_tags WHERE user_token = ",
|
||||
);
|
||||
builder.push_bind(args.user_token.as_str());
|
||||
|
||||
builder.push(" AND media_file_token IN (");
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.media_file_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let rows = builder.build().fetch_all(args.mysql_executor).await?;
|
||||
|
||||
Ok(rows.into_iter()
|
||||
.map(|row| TagToken::new(row.get::<String, _>(0)))
|
||||
.collect())
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::media_files::list::media_file_list_row::MediaFileListRow;
|
||||
|
||||
pub struct ListMediaFilesWithTagForUserArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_token: &'e TagToken,
|
||||
pub owner_user_token: &'e UserToken,
|
||||
pub maybe_cursor_id: Option<u64>,
|
||||
pub limit: u32,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Paginated list of the user's media files carrying the given tag.
|
||||
/// Excludes soft-deleted and intermediate system files. Newest first
|
||||
/// (`media_files.id` descending; the id doubles as the cursor). The
|
||||
/// join enters through `index_tag_token_and_user_token`, and the unique
|
||||
/// key on `(media_file_token, tag_token, user_token)` guarantees at most
|
||||
/// one link row per media file — no DISTINCT needed.
|
||||
///
|
||||
/// The caller is expected to have already verified the tag belongs to
|
||||
/// the user (`get_tag_for_owner`) so a bad token can 404.
|
||||
pub async fn list_media_files_with_tag_for_user<'e, 'c: 'e, E>(
|
||||
args: ListMediaFilesWithTagForUserArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
// No-cursor requests use a sentinel above every possible id, which
|
||||
// keeps this to a single static query. (`media_files.id` is a signed
|
||||
// BIGINT, so the sentinel is i64::MAX rather than u64::MAX.)
|
||||
let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64);
|
||||
let limit = args.limit as i64;
|
||||
|
||||
sqlx::query_as!(
|
||||
MediaFileListRow,
|
||||
r#"
|
||||
SELECT
|
||||
mf.id as `media_file_id: u64`,
|
||||
|
||||
mf.token as `media_file_token: MediaFileToken`,
|
||||
mf.media_class as `media_class: MediaFileClass`,
|
||||
mf.media_type as `media_type: MediaFileType`,
|
||||
|
||||
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
|
||||
|
||||
mf.public_bucket_directory_hash,
|
||||
mf.maybe_public_bucket_prefix,
|
||||
mf.maybe_public_bucket_extension,
|
||||
|
||||
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
|
||||
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
|
||||
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
|
||||
|
||||
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
|
||||
mf.is_user_upload as `is_user_upload: bool`,
|
||||
|
||||
mf.maybe_title,
|
||||
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
|
||||
mf.maybe_origin_filename,
|
||||
|
||||
mf.maybe_duration_millis,
|
||||
mf.maybe_frame_width,
|
||||
mf.maybe_frame_height,
|
||||
|
||||
mf.created_at,
|
||||
mf.updated_at
|
||||
FROM media_file_tags mft
|
||||
JOIN media_files mf
|
||||
ON mf.token = mft.media_file_token
|
||||
LEFT JOIN media_files cover
|
||||
ON cover.token = mf.maybe_cover_image_media_file_token
|
||||
WHERE mft.tag_token = ?
|
||||
AND mft.user_token = ?
|
||||
AND mf.maybe_creator_user_token = ?
|
||||
AND mf.user_deleted_at IS NULL
|
||||
AND mf.mod_deleted_at IS NULL
|
||||
AND NOT mf.is_intermediate_system_file
|
||||
AND mf.id < ?
|
||||
ORDER BY mf.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
args.tag_token.as_str(),
|
||||
args.owner_user_token.as_str(),
|
||||
args.owner_user_token.as_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
pub struct ListTagTokensForMediaFileArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub media_file_token: &'e MediaFileToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// The distinct tag tokens linked to a media file, across ALL users
|
||||
/// (unlike `list_linked_tag_tokens_for_media_files`, which is scoped to
|
||||
/// one user's links). Used by media-file delete/undelete to know which
|
||||
/// tags need their `use_count` recounted.
|
||||
pub async fn list_tag_tokens_for_media_file<'e, 'c: 'e, E>(
|
||||
args: ListTagTokensForMediaFileArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<TagToken>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT DISTINCT tag_token as `tag_token: TagToken`
|
||||
FROM media_file_tags
|
||||
WHERE media_file_token = ?
|
||||
"#,
|
||||
args.media_file_token.as_str(),
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.tag_token).collect())
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::media_files::list::media_file_list_row::MediaFileListRow;
|
||||
|
||||
pub struct ListTaggedMediaFilesForUserArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
pub maybe_cursor_id: Option<u64>,
|
||||
pub limit: u32,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Paginated list of the user's media files that carry at least one tag.
|
||||
/// Excludes soft-deleted and intermediate system files. Newest first
|
||||
/// (`media_files.id` descending; the id doubles as the cursor). The
|
||||
/// EXISTS probe is a point lookup on `index_media_file_token`.
|
||||
pub async fn list_tagged_media_files_for_user<'e, 'c: 'e, E>(
|
||||
args: ListTaggedMediaFilesForUserArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
// No-cursor requests use a sentinel above every possible id, which
|
||||
// keeps this to a single static query. (`media_files.id` is a signed
|
||||
// BIGINT, so the sentinel is i64::MAX rather than u64::MAX.)
|
||||
let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64);
|
||||
let limit = args.limit as i64;
|
||||
|
||||
sqlx::query_as!(
|
||||
MediaFileListRow,
|
||||
r#"
|
||||
SELECT
|
||||
mf.id as `media_file_id: u64`,
|
||||
|
||||
mf.token as `media_file_token: MediaFileToken`,
|
||||
mf.media_class as `media_class: MediaFileClass`,
|
||||
mf.media_type as `media_type: MediaFileType`,
|
||||
|
||||
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
|
||||
|
||||
mf.public_bucket_directory_hash,
|
||||
mf.maybe_public_bucket_prefix,
|
||||
mf.maybe_public_bucket_extension,
|
||||
|
||||
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
|
||||
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
|
||||
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
|
||||
|
||||
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
|
||||
mf.is_user_upload as `is_user_upload: bool`,
|
||||
|
||||
mf.maybe_title,
|
||||
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
|
||||
mf.maybe_origin_filename,
|
||||
|
||||
mf.maybe_duration_millis,
|
||||
mf.maybe_frame_width,
|
||||
mf.maybe_frame_height,
|
||||
|
||||
mf.created_at,
|
||||
mf.updated_at
|
||||
FROM media_files mf
|
||||
LEFT JOIN media_files cover
|
||||
ON cover.token = mf.maybe_cover_image_media_file_token
|
||||
WHERE mf.maybe_creator_user_token = ?
|
||||
AND mf.user_deleted_at IS NULL
|
||||
AND mf.mod_deleted_at IS NULL
|
||||
AND NOT mf.is_intermediate_system_file
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM media_file_tags mft
|
||||
WHERE mft.media_file_token = mf.token
|
||||
)
|
||||
AND mf.id < ?
|
||||
ORDER BY mf.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
args.owner_user_token.as_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
use crate::queries::tags::tag_row::TagRow;
|
||||
|
||||
pub struct ListTagsForMediaFileArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub media_file_token: &'e MediaFileToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// All (live) tags on a media file, sorted by tag value. Not paginated —
|
||||
/// tag counts per file are small. Not scoped to any user: per-file tags
|
||||
/// are publicly visible.
|
||||
pub async fn list_tags_for_media_file<'e, 'c: 'e, E>(
|
||||
args: ListTagsForMediaFileArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<TagRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
t.id as `id: u64`,
|
||||
t.token as `token: TagToken`,
|
||||
t.tag_value,
|
||||
t.tag_value_lowercase,
|
||||
t.use_count as `use_count: u32`
|
||||
FROM media_file_tags mft
|
||||
JOIN tags t
|
||||
ON t.token = mft.tag_token
|
||||
WHERE mft.media_file_token = ?
|
||||
AND t.maybe_deleted_at IS NULL
|
||||
ORDER BY t.tag_value_lowercase ASC
|
||||
"#,
|
||||
args.media_file_token.as_str(),
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter()
|
||||
.map(|r| TagRow {
|
||||
id: r.id,
|
||||
token: r.token,
|
||||
tag_value: r.tag_value,
|
||||
tag_value_lowercase: r.tag_value_lowercase,
|
||||
use_count: r.use_count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::tags::tag_row::TagRow;
|
||||
|
||||
pub struct ListTagsForUserArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub creator_user_token: &'e UserToken,
|
||||
pub maybe_cursor_id: Option<u64>,
|
||||
pub limit: u32,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Paginated list of the user's (live) tags, newest first
|
||||
/// (`tags.id` descending; the id doubles as the cursor).
|
||||
pub async fn list_tags_for_user<'e, 'c: 'e, E>(
|
||||
args: ListTagsForUserArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<TagRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
// No-cursor requests use a sentinel of u64::MAX — every id matches,
|
||||
// which keeps this to a single static query.
|
||||
let cursor_id = args.maybe_cursor_id.unwrap_or(u64::MAX);
|
||||
let limit = args.limit as i64;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
id as `id: u64`,
|
||||
token as `token: TagToken`,
|
||||
tag_value,
|
||||
tag_value_lowercase,
|
||||
use_count as `use_count: u32`
|
||||
FROM tags
|
||||
WHERE creator_user_token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
args.creator_user_token.as_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter()
|
||||
.map(|r| TagRow {
|
||||
id: r.id,
|
||||
token: r.token,
|
||||
tag_value: r.tag_value,
|
||||
tag_value_lowercase: r.tag_value_lowercase,
|
||||
use_count: r.use_count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::media_files::list::media_file_list_row::MediaFileListRow;
|
||||
|
||||
pub struct ListUntaggedMediaFilesForUserArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
pub maybe_cursor_id: Option<u64>,
|
||||
pub limit: u32,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Paginated list of the user's media files that carry no tags at all.
|
||||
/// Excludes soft-deleted and intermediate system files. Newest first
|
||||
/// (`media_files.id` descending; the id doubles as the cursor). The
|
||||
/// NOT EXISTS probe is a point lookup on `index_media_file_token`.
|
||||
pub async fn list_untagged_media_files_for_user<'e, 'c: 'e, E>(
|
||||
args: ListUntaggedMediaFilesForUserArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
// No-cursor requests use a sentinel above every possible id, which
|
||||
// keeps this to a single static query. (`media_files.id` is a signed
|
||||
// BIGINT, so the sentinel is i64::MAX rather than u64::MAX.)
|
||||
let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64);
|
||||
let limit = args.limit as i64;
|
||||
|
||||
sqlx::query_as!(
|
||||
MediaFileListRow,
|
||||
r#"
|
||||
SELECT
|
||||
mf.id as `media_file_id: u64`,
|
||||
|
||||
mf.token as `media_file_token: MediaFileToken`,
|
||||
mf.media_class as `media_class: MediaFileClass`,
|
||||
mf.media_type as `media_type: MediaFileType`,
|
||||
|
||||
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
|
||||
|
||||
mf.public_bucket_directory_hash,
|
||||
mf.maybe_public_bucket_prefix,
|
||||
mf.maybe_public_bucket_extension,
|
||||
|
||||
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
|
||||
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
|
||||
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
|
||||
|
||||
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
|
||||
mf.is_user_upload as `is_user_upload: bool`,
|
||||
|
||||
mf.maybe_title,
|
||||
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
|
||||
mf.maybe_origin_filename,
|
||||
|
||||
mf.maybe_duration_millis,
|
||||
mf.maybe_frame_width,
|
||||
mf.maybe_frame_height,
|
||||
|
||||
mf.created_at,
|
||||
mf.updated_at
|
||||
FROM media_files mf
|
||||
LEFT JOIN media_files cover
|
||||
ON cover.token = mf.maybe_cover_image_media_file_token
|
||||
WHERE mf.maybe_creator_user_token = ?
|
||||
AND mf.user_deleted_at IS NULL
|
||||
AND mf.mod_deleted_at IS NULL
|
||||
AND NOT mf.is_intermediate_system_file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_file_tags mft
|
||||
WHERE mft.media_file_token = mf.token
|
||||
)
|
||||
AND mf.id < ?
|
||||
ORDER BY mf.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
args.owner_user_token.as_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
pub mod bulk_list_tags_for_media_files;
|
||||
pub mod clear_media_file_tags;
|
||||
pub mod delete_links_to_soft_deleted_tags;
|
||||
pub mod delete_media_file_tags_for_tag;
|
||||
pub mod delete_media_file_tags_not_in_set;
|
||||
pub mod filter_owned_media_file_tokens;
|
||||
pub mod filter_visible_media_file_tokens;
|
||||
pub mod get_media_file_access_fields;
|
||||
pub mod get_soft_deleted_tag_by_lowercase;
|
||||
pub mod get_tag_for_owner;
|
||||
pub mod hard_delete_soft_deleted_tag;
|
||||
pub mod insert_media_file_tags;
|
||||
pub mod list_linked_tag_tokens_for_media_files;
|
||||
pub mod list_media_files_with_tag_for_user;
|
||||
pub mod list_tag_tokens_for_media_file;
|
||||
pub mod list_tagged_media_files_for_user;
|
||||
pub mod list_tags_for_media_file;
|
||||
pub mod list_tags_for_user;
|
||||
pub mod list_untagged_media_files_for_user;
|
||||
pub mod recount_tag_use_counts;
|
||||
pub mod rename_tag;
|
||||
pub mod select_tags_by_lowercase_values;
|
||||
pub mod select_tags_by_tokens_for_owner;
|
||||
pub mod soft_delete_tag;
|
||||
pub mod tag_row;
|
||||
pub mod upsert_tags;
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
pub struct RecountTagUseCountsArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_tokens: &'e [TagToken],
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Recompute `tags.use_count` for the given tags from the actual
|
||||
/// `media_file_tags` link counts. Call after any add/remove of links,
|
||||
/// passing only the affected tag tokens — the correlated COUNT probes
|
||||
/// `index_tag_token`, so cost is proportional to the tags' link counts,
|
||||
/// not table size.
|
||||
///
|
||||
/// Only links to LIVE media files count: media-file deletion is a soft
|
||||
/// delete that leaves the link rows in place (so undelete restores the
|
||||
/// user's tags), and those parked links must not inflate the rollup.
|
||||
pub async fn recount_tag_use_counts<'e, 'c: 'e, E>(
|
||||
args: RecountTagUseCountsArgs<'e, 'c, E>,
|
||||
) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.tag_tokens.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"UPDATE tags \
|
||||
SET use_count = ( \
|
||||
SELECT COUNT(*) FROM media_file_tags \
|
||||
JOIN media_files \
|
||||
ON media_files.token = media_file_tags.media_file_token \
|
||||
AND media_files.user_deleted_at IS NULL \
|
||||
AND media_files.mod_deleted_at IS NULL \
|
||||
WHERE media_file_tags.tag_token = tags.token \
|
||||
) \
|
||||
WHERE tags.token IN (",
|
||||
);
|
||||
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.tag_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
builder.build().execute(args.mysql_executor).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct RenameTagArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_token: &'e TagToken,
|
||||
pub creator_user_token: &'e UserToken,
|
||||
pub new_tag_value: &'e str,
|
||||
pub new_tag_value_lowercase: &'e str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Rename a tag, scoped to its creator. Handles both case-only changes
|
||||
/// (same lowercase) and wholesale renames with the one UPDATE. If the
|
||||
/// user already has a different tag with the same
|
||||
/// `tag_value_lowercase`, the unique key rejects the rename — callers
|
||||
/// should map that duplicate-key `sqlx::Error` to a 400. Returns the
|
||||
/// number of rows updated (0 = not found / not owned / already deleted).
|
||||
pub async fn rename_tag<'e, 'c: 'e, E>(
|
||||
args: RenameTagArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE tags
|
||||
SET tag_value = ?,
|
||||
tag_value_lowercase = ?
|
||||
WHERE token = ?
|
||||
AND creator_user_token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
"#,
|
||||
args.new_tag_value,
|
||||
args.new_tag_value_lowercase,
|
||||
args.tag_token.as_str(),
|
||||
args.creator_user_token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder, Row};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::tags::tag_row::TagRow;
|
||||
|
||||
pub struct SelectTagsByLowercaseValuesArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_values_lowercase: &'e [String],
|
||||
pub creator_user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up a user's (live) tags by their lowercased values. Used after
|
||||
/// `upsert_tags` to resolve the canonical tokens for the batch.
|
||||
pub async fn select_tags_by_lowercase_values<'e, 'c: 'e, E>(
|
||||
args: SelectTagsByLowercaseValuesArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<TagRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.tag_values_lowercase.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"SELECT id, token, tag_value, tag_value_lowercase, use_count \
|
||||
FROM tags \
|
||||
WHERE creator_user_token = ",
|
||||
);
|
||||
builder.push_bind(args.creator_user_token.as_str());
|
||||
builder.push(" AND maybe_deleted_at IS NULL AND tag_value_lowercase IN (");
|
||||
|
||||
let mut separated = builder.separated(", ");
|
||||
for value in args.tag_values_lowercase {
|
||||
separated.push_bind(value.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let rows = builder.build().fetch_all(args.mysql_executor).await?;
|
||||
|
||||
Ok(rows.into_iter()
|
||||
.map(|row| TagRow {
|
||||
id: row.get::<u64, _>(0),
|
||||
token: TagToken::new(row.get::<String, _>(1)),
|
||||
tag_value: row.get::<String, _>(2),
|
||||
tag_value_lowercase: row.get::<String, _>(3),
|
||||
use_count: row.get::<u32, _>(4),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder, Row};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::tags::tag_row::TagRow;
|
||||
|
||||
pub struct SelectTagsByTokensForOwnerArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_tokens: &'e [TagToken],
|
||||
pub creator_user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up a user's (live) tags by token. Used to re-read fresh
|
||||
/// `use_count` values for a response after a recount.
|
||||
pub async fn select_tags_by_tokens_for_owner<'e, 'c: 'e, E>(
|
||||
args: SelectTagsByTokensForOwnerArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<TagRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.tag_tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"SELECT id, token, tag_value, tag_value_lowercase, use_count \
|
||||
FROM tags \
|
||||
WHERE creator_user_token = ",
|
||||
);
|
||||
builder.push_bind(args.creator_user_token.as_str());
|
||||
builder.push(" AND maybe_deleted_at IS NULL AND token IN (");
|
||||
|
||||
let mut separated = builder.separated(", ");
|
||||
for token in args.tag_tokens {
|
||||
separated.push_bind(token.as_str());
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let rows = builder.build().fetch_all(args.mysql_executor).await?;
|
||||
|
||||
Ok(rows.into_iter()
|
||||
.map(|row| TagRow {
|
||||
id: row.get::<u64, _>(0),
|
||||
token: TagToken::new(row.get::<String, _>(1)),
|
||||
tag_value: row.get::<String, _>(2),
|
||||
tag_value_lowercase: row.get::<String, _>(3),
|
||||
use_count: row.get::<u32, _>(4),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct SoftDeleteTagArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub tag_token: &'e TagToken,
|
||||
pub creator_user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Soft-delete a tag, scoped to its creator. The caller is expected to
|
||||
/// have already hard-deleted the tag's media-file links
|
||||
/// (`delete_media_file_tags_for_tag`), so `use_count` is zeroed here.
|
||||
/// Returns the number of rows updated (0 = not found / not owned /
|
||||
/// already deleted).
|
||||
pub async fn soft_delete_tag<'e, 'c: 'e, E>(
|
||||
args: SoftDeleteTagArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE tags
|
||||
SET maybe_deleted_at = NOW(),
|
||||
use_count = 0
|
||||
WHERE token = ?
|
||||
AND creator_user_token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
"#,
|
||||
args.tag_token.as_str(),
|
||||
args.creator_user_token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
/// One (live) tag row, as returned by the tags list/select queries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagRow {
|
||||
/// `tags.id` — used as the pagination cursor for user tag lists.
|
||||
pub id: u64,
|
||||
|
||||
pub token: TagToken,
|
||||
|
||||
/// The display value of the tag, as entered by its creator.
|
||||
pub tag_value: String,
|
||||
|
||||
/// Lowercased form of `tag_value` — the tag's unique key within the
|
||||
/// creator's account.
|
||||
pub tag_value_lowercase: String,
|
||||
|
||||
/// Rollup statistic: how many media files currently carry this tag.
|
||||
pub use_count: u32,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql, QueryBuilder};
|
||||
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// One sanitized tag to upsert. The caller is responsible for trimming,
|
||||
/// lowercasing, and case-insensitive deduping.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewTagValue {
|
||||
/// The display value, original casing preserved.
|
||||
pub tag_value: String,
|
||||
|
||||
/// Lowercased `tag_value` — the unique key within the creator's account.
|
||||
pub tag_value_lowercase: String,
|
||||
}
|
||||
|
||||
pub struct UpsertTagsArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub new_tags: &'e [NewTagValue],
|
||||
pub creator_user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Upsert tags for a user. New tags are created with freshly generated
|
||||
/// tokens; tags that already exist (matched on the
|
||||
/// `(tag_value_lowercase, creator_user_token)` unique key) are absorbed,
|
||||
/// keeping their existing token and display casing. Previously
|
||||
/// soft-deleted tags are revived (`maybe_deleted_at` reset to NULL) in
|
||||
/// the same statement.
|
||||
///
|
||||
/// Callers should follow up with `select_tags_by_lowercase_values` to
|
||||
/// learn the canonical tokens.
|
||||
pub async fn upsert_tags<'e, 'c: 'e, E>(
|
||||
args: UpsertTagsArgs<'e, 'c, E>,
|
||||
) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.new_tags.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let creator_user_token_str = args.creator_user_token.as_str();
|
||||
let mut builder = QueryBuilder::<MySql>::new(
|
||||
"INSERT INTO tags (token, tag_value, tag_value_lowercase, creator_user_token) ",
|
||||
);
|
||||
builder.push_values(args.new_tags, |mut b, new_tag| {
|
||||
b.push_bind(TagToken::generate().0)
|
||||
.push_bind(new_tag.tag_value.as_str())
|
||||
.push_bind(new_tag.tag_value_lowercase.as_str())
|
||||
.push_bind(creator_user_token_str);
|
||||
});
|
||||
builder.push(" ON DUPLICATE KEY UPDATE maybe_deleted_at = NULL");
|
||||
|
||||
builder.build().execute(args.mysql_executor).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -143,6 +143,24 @@ use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::*;
|
||||
use artcraft_api_defs::moderation::top_spenders::list::*;
|
||||
use artcraft_api_defs::moderation::user_spend_events::list::*;
|
||||
use artcraft_api_defs::folders::subfolder::*;
|
||||
use artcraft_api_defs::tags::add_media_file_tags::*;
|
||||
use artcraft_api_defs::tags::bulk_add_tags::*;
|
||||
use artcraft_api_defs::tags::bulk_list_media_file_tags::*;
|
||||
use artcraft_api_defs::tags::bulk_set_tags::*;
|
||||
use artcraft_api_defs::tags::clear_media_file_tags::*;
|
||||
use artcraft_api_defs::tags::common::*;
|
||||
use artcraft_api_defs::tags::delete_tag::*;
|
||||
use artcraft_api_defs::tags::list_media_file_tags::*;
|
||||
use artcraft_api_defs::tags::list_media_files_with_tag::*;
|
||||
use artcraft_api_defs::tags::list_tagged_media_files::*;
|
||||
use artcraft_api_defs::tags::list_tags::*;
|
||||
use artcraft_api_defs::tags::list_untagged_media_files::*;
|
||||
use artcraft_api_defs::tags::rename_tag::*;
|
||||
use artcraft_api_defs::tags::set_media_file_tags::*;
|
||||
use crate::http_server::endpoints::tags::list_media_files_with_tag_handler::*;
|
||||
use crate::http_server::endpoints::tags::list_tagged_media_files_handler::*;
|
||||
use crate::http_server::endpoints::tags::list_untagged_media_files_handler::*;
|
||||
use crate::http_server::endpoints::tags::tag_media_file_list_item::*;
|
||||
use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*;
|
||||
use artcraft_api_defs::moderation::user_referrals::list_user_referrals_for_user::*;
|
||||
use artcraft_api_defs::moderation::user_stripe_data::moderator_get_user_stripe_customer_ids::*;
|
||||
@@ -663,6 +681,22 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler,
|
||||
crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::bulk_move_folder_media_files_handler,
|
||||
crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler,
|
||||
|
||||
// Tags
|
||||
crate::http_server::endpoints::tags::list_tags_handler::list_tags_handler,
|
||||
crate::http_server::endpoints::tags::list_media_file_tags_handler::list_media_file_tags_handler,
|
||||
crate::http_server::endpoints::tags::list_untagged_media_files_handler::list_untagged_media_files_handler,
|
||||
crate::http_server::endpoints::tags::list_tagged_media_files_handler::list_tagged_media_files_handler,
|
||||
crate::http_server::endpoints::tags::list_media_files_with_tag_handler::list_media_files_with_tag_handler,
|
||||
crate::http_server::endpoints::tags::bulk_list_media_file_tags_handler::bulk_list_media_file_tags_handler,
|
||||
crate::http_server::endpoints::tags::add_media_file_tags_handler::add_media_file_tags_handler,
|
||||
crate::http_server::endpoints::tags::set_media_file_tags_handler::set_media_file_tags_handler,
|
||||
crate::http_server::endpoints::tags::clear_media_file_tags_handler::clear_media_file_tags_handler,
|
||||
crate::http_server::endpoints::tags::bulk_add_tags_handler::bulk_add_tags_handler,
|
||||
crate::http_server::endpoints::tags::bulk_set_tags_handler::bulk_set_tags_handler,
|
||||
crate::http_server::endpoints::tags::delete_tag_handler::delete_tag_handler,
|
||||
crate::http_server::endpoints::tags::rename_tag_handler::rename_tag_handler,
|
||||
|
||||
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler,
|
||||
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler,
|
||||
crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler,
|
||||
@@ -1448,6 +1482,41 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
BulkRemoveFolderMediaFilesSuccessResponse,
|
||||
BulkMoveFolderMediaFilesRequest,
|
||||
BulkMoveFolderMediaFilesSuccessResponse,
|
||||
|
||||
// Tags
|
||||
AddMediaFileTagsPathInfo,
|
||||
AddMediaFileTagsRequest,
|
||||
AddMediaFileTagsSuccessResponse,
|
||||
BulkAddTagsRequest,
|
||||
BulkAddTagsSuccessResponse,
|
||||
BulkListMediaFileTagsRequest,
|
||||
BulkListMediaFileTagsSuccessResponse,
|
||||
BulkSetTagsRequest,
|
||||
BulkSetTagsSuccessResponse,
|
||||
ClearMediaFileTagsPathInfo,
|
||||
ClearMediaFileTagsSuccessResponse,
|
||||
DeleteTagPathInfo,
|
||||
DeleteTagSuccessResponse,
|
||||
ListMediaFileTagsPathInfo,
|
||||
ListMediaFileTagsSuccessResponse,
|
||||
ListMediaFilesWithTagPathInfo,
|
||||
ListMediaFilesWithTagQueryParams,
|
||||
ListMediaFilesWithTagSuccessResponse,
|
||||
ListTaggedMediaFilesQueryParams,
|
||||
ListTaggedMediaFilesSuccessResponse,
|
||||
ListTagsQueryParams,
|
||||
ListTagsSuccessResponse,
|
||||
ListUntaggedMediaFilesQueryParams,
|
||||
ListUntaggedMediaFilesSuccessResponse,
|
||||
MediaFileTagsEntry,
|
||||
RenameTagPathInfo,
|
||||
RenameTagRequest,
|
||||
RenameTagSuccessResponse,
|
||||
SetMediaFileTagsPathInfo,
|
||||
SetMediaFileTagsRequest,
|
||||
SetMediaFileTagsSuccessResponse,
|
||||
TagDetails,
|
||||
TagMediaFileListItem,
|
||||
))
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
use artcraft_api_defs::common::responses::media_links::MediaLinks;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use mysql_queries::queries::media_files::list::media_file_list_row::MediaFileListRow;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
|
||||
/// Build the `MediaLinks` + `MediaFileCoverImageDetails` pair for one
|
||||
/// media-file list row. Shared by every list endpoint that returns
|
||||
/// media files (folders, tags, ...) so the CDN-link construction stays
|
||||
/// in one place.
|
||||
pub fn build_media_links_and_cover(
|
||||
row: &MediaFileListRow,
|
||||
media_domain: MediaDomain,
|
||||
server_environment: ServerEnvironment,
|
||||
) -> (MediaLinks, MediaFileCoverImageDetails) {
|
||||
let bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&row.public_bucket_directory_hash,
|
||||
row.maybe_public_bucket_prefix.as_deref(),
|
||||
row.maybe_public_bucket_extension.as_deref(),
|
||||
);
|
||||
|
||||
let media_links = MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain,
|
||||
server_environment,
|
||||
&bucket_path,
|
||||
);
|
||||
|
||||
let cover_image = MediaFileCoverImageDetails::from_optional_db_fields(
|
||||
&row.media_file_token,
|
||||
media_domain,
|
||||
server_environment,
|
||||
row.maybe_cover_public_bucket_directory_hash.as_deref(),
|
||||
row.maybe_cover_public_bucket_prefix.as_deref(),
|
||||
row.maybe_cover_public_bucket_extension.as_deref(),
|
||||
);
|
||||
|
||||
(media_links, cover_image)
|
||||
}
|
||||
@@ -4,5 +4,6 @@ pub mod cover_image_links_builder;
|
||||
pub mod media_domain;
|
||||
pub mod media_file_cover_image_details;
|
||||
pub mod media_file_cover_image_details_builder;
|
||||
pub mod media_file_list_conversion;
|
||||
pub mod media_links_builder;
|
||||
pub mod weights_cover_image_details;
|
||||
+53
-8
@@ -14,7 +14,13 @@ use artcraft_api_defs::media_file::delete_media_file::{DeleteMediaFilePathInfo,
|
||||
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
|
||||
use log::warn;
|
||||
use mysql_queries::queries::media_files::delete::delete_media_file::{delete_media_file_as_mod, delete_media_file_as_user, undelete_media_file_as_mod, undelete_media_file_as_user};
|
||||
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
|
||||
use mysql_queries::queries::media_files::get::get_media_file::get_media_file_with_transactor;
|
||||
use mysql_queries::queries::tags::list_tag_tokens_for_media_file::{list_tag_tokens_for_media_file, ListTagTokensForMediaFileArgs};
|
||||
use mysql_queries::queries::tags::recount_tag_use_counts::{recount_tag_use_counts, RecountTagUseCountsArgs};
|
||||
use mysql_queries::utils::transactor::Transactor;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
use std::marker::PhantomData;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -42,9 +48,16 @@ pub async fn delete_media_file_handler(
|
||||
request: Json<DeleteMediaFileRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<Json<SimpleGenericJsonSuccess>, CommonWebError> {
|
||||
// One connection for the whole request — never hand helpers the raw
|
||||
// pool, or each call self-acquires and adds pool pressure.
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut conn)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
@@ -61,10 +74,10 @@ pub async fn delete_media_file_handler(
|
||||
|
||||
let is_mod = user_session.can_ban_users;
|
||||
|
||||
let media_file_lookup_result = get_media_file(
|
||||
let media_file_lookup_result = get_media_file_with_transactor(
|
||||
&path.token,
|
||||
is_mod,
|
||||
&server_state.mysql_pool,
|
||||
Transactor::for_connection(&mut *conn),
|
||||
).await;
|
||||
|
||||
let media_file = match media_file_lookup_result {
|
||||
@@ -98,14 +111,14 @@ pub async fn delete_media_file_handler(
|
||||
DeleteRole::AsUser => {
|
||||
delete_media_file_as_user(
|
||||
&path.token,
|
||||
&server_state.mysql_pool
|
||||
&mut *conn,
|
||||
).await
|
||||
}
|
||||
DeleteRole::AsMod => {
|
||||
delete_media_file_as_mod(
|
||||
&path.token,
|
||||
user_session.user_token.as_str(),
|
||||
&server_state.mysql_pool
|
||||
&mut *conn,
|
||||
).await
|
||||
}
|
||||
}
|
||||
@@ -119,14 +132,14 @@ pub async fn delete_media_file_handler(
|
||||
// NB: Technically only mods can see their own media_files
|
||||
undelete_media_file_as_user(
|
||||
&path.token,
|
||||
&server_state.mysql_pool
|
||||
&mut *conn,
|
||||
).await
|
||||
}
|
||||
DeleteRole::AsMod => {
|
||||
undelete_media_file_as_mod(
|
||||
&path.token,
|
||||
user_session.user_token.as_str(),
|
||||
&server_state.mysql_pool
|
||||
&mut *conn,
|
||||
).await
|
||||
}
|
||||
}
|
||||
@@ -140,7 +153,39 @@ pub async fn delete_media_file_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// Tag links survive delete/undelete (so an undelete restores the
|
||||
// user's tags), but `tags.use_count` only counts links to live
|
||||
// files — refresh the affected tags' counts. Best-effort: the
|
||||
// delete already succeeded, and a stale count self-heals on the
|
||||
// tag's next recount.
|
||||
recount_tags_for_media_file(&path.token, &mut conn).await;
|
||||
|
||||
Ok(Json(SimpleGenericJsonSuccess{
|
||||
success: true
|
||||
}))
|
||||
}
|
||||
|
||||
async fn recount_tags_for_media_file(
|
||||
media_file_token: &MediaFileToken,
|
||||
conn: &mut PoolConnection<MySql>,
|
||||
) {
|
||||
let linked_tag_tokens = match list_tag_tokens_for_media_file(ListTagTokensForMediaFileArgs {
|
||||
media_file_token,
|
||||
mysql_executor: &mut **conn,
|
||||
phantom: PhantomData,
|
||||
}).await {
|
||||
Ok(tokens) => tokens,
|
||||
Err(err) => {
|
||||
warn!("list_tag_tokens_for_media_file failed after delete/undelete: {:?}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = recount_tag_use_counts(RecountTagUseCountsArgs {
|
||||
tag_tokens: &linked_tag_tokens,
|
||||
mysql_executor: &mut **conn,
|
||||
phantom: PhantomData,
|
||||
}).await {
|
||||
warn!("recount_tag_use_counts failed after delete/undelete: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod prompts;
|
||||
pub mod service;
|
||||
pub mod stats;
|
||||
pub mod subscriptions;
|
||||
pub mod tags;
|
||||
pub mod trending;
|
||||
pub mod tts;
|
||||
pub mod user_bookmarks;
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::slice;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::tags::add_media_file_tags::{
|
||||
AddMediaFileTagsPathInfo, AddMediaFileTagsRequest, AddMediaFileTagsSuccessResponse,
|
||||
};
|
||||
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
|
||||
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
|
||||
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
|
||||
use crate::http_server::endpoints::tags::tag_input::{parse_tag_input, require_non_empty_tags};
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Add tags to a media file. Upsert semantics: tags that don't exist
|
||||
/// are created (reviving soft-deleted ones), tags already on the file
|
||||
/// are absorbed without error. Tags not mentioned are left alone. Only
|
||||
/// the media file's creator may tag it (404 otherwise).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/media_file/add/{media_file_token}",
|
||||
params(("media_file_token" = MediaFileToken, description = "Media file token")),
|
||||
request_body = AddMediaFileTagsRequest,
|
||||
responses(
|
||||
(status = 200, body = AddMediaFileTagsSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn add_media_file_tags_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<AddMediaFileTagsPathInfo>,
|
||||
request: Json<AddMediaFileTagsRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<AddMediaFileTagsSuccessResponse>, CommonWebError> {
|
||||
let new_tags = parse_tag_input(
|
||||
request.maybe_tags.as_deref(),
|
||||
request.maybe_tags_list.as_deref(),
|
||||
)?;
|
||||
require_non_empty_tags(&new_tags)?;
|
||||
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let owned = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
|
||||
candidate_tokens: slice::from_ref(&path.media_file_token),
|
||||
owner_user_token: &user_session.user_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Media file ownership check failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
if owned.is_empty() {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
let outcome = apply_tags_to_media_files(
|
||||
&mut conn,
|
||||
&user_session.user_token,
|
||||
&owned,
|
||||
&new_tags,
|
||||
/* remove_unmentioned= */ false,
|
||||
).await?;
|
||||
|
||||
Ok(Json(AddMediaFileTagsSuccessResponse {
|
||||
success: true,
|
||||
tags: outcome.tags.into_iter().map(tag_row_to_details).collect(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use log::{error, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Acquire, MySql, Transaction};
|
||||
|
||||
use mysql_queries::queries::tags::clear_media_file_tags::{
|
||||
clear_media_file_tags, ClearMediaFileTagsArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::delete_links_to_soft_deleted_tags::{
|
||||
delete_links_to_soft_deleted_tags, DeleteLinksToSoftDeletedTagsArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::delete_media_file_tags_not_in_set::{
|
||||
delete_media_file_tags_not_in_set, DeleteMediaFileTagsNotInSetArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::insert_media_file_tags::{
|
||||
insert_media_file_tags, InsertMediaFileTagsArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::list_linked_tag_tokens_for_media_files::{
|
||||
list_linked_tag_tokens_for_media_files, ListLinkedTagTokensForMediaFilesArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::recount_tag_use_counts::{
|
||||
recount_tag_use_counts, RecountTagUseCountsArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::select_tags_by_lowercase_values::{
|
||||
select_tags_by_lowercase_values, SelectTagsByLowercaseValuesArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::select_tags_by_tokens_for_owner::{
|
||||
select_tags_by_tokens_for_owner, SelectTagsByTokensForOwnerArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::tag_row::TagRow;
|
||||
use mysql_queries::queries::tags::upsert_tags::{upsert_tags, NewTagValue, UpsertTagsArgs};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
pub struct ApplyTagsOutcome {
|
||||
/// The requested tags after upsert, with canonical tokens and fresh
|
||||
/// use counts. Empty for pure clear operations.
|
||||
pub tags: Vec<TagRow>,
|
||||
|
||||
/// How many existing tag links were removed (always 0 when
|
||||
/// `remove_unmentioned` is false).
|
||||
pub removed_count: u64,
|
||||
}
|
||||
|
||||
/// The shared engine behind the add / set / clear / bulk_add / bulk_set
|
||||
/// endpoints: upsert the requested tags (reviving soft-deleted ones),
|
||||
/// attach them to every media file, optionally remove links that weren't
|
||||
/// mentioned ("set" semantics), and recount `use_count` for every tag
|
||||
/// whose link set changed — all in one transaction.
|
||||
///
|
||||
/// The caller must have already ownership-filtered `media_file_tokens`
|
||||
/// and sanitized `new_tags`. On any failure the transaction is
|
||||
/// explicitly rolled back before the original error is re-raised.
|
||||
pub async fn apply_tags_to_media_files(
|
||||
conn: &mut PoolConnection<MySql>,
|
||||
user_token: &UserToken,
|
||||
media_file_tokens: &[MediaFileToken],
|
||||
new_tags: &[NewTagValue],
|
||||
remove_unmentioned: bool,
|
||||
) -> Result<ApplyTagsOutcome, CommonWebError> {
|
||||
let mut tx = conn.begin().await.map_err(|err| {
|
||||
warn!("Failed to begin apply-tags transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let work_result = perform_apply_work(
|
||||
&mut tx,
|
||||
user_token,
|
||||
media_file_tokens,
|
||||
new_tags,
|
||||
remove_unmentioned,
|
||||
).await;
|
||||
|
||||
match work_result {
|
||||
Ok(outcome) => {
|
||||
tx.commit().await.map_err(|err| {
|
||||
warn!("Failed to commit apply-tags transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
Ok(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
if let Err(rollback_err) = tx.rollback().await {
|
||||
error!(
|
||||
"Rollback after apply-tags failure also failed: {:?} (original error: {:?})",
|
||||
rollback_err, err,
|
||||
);
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_apply_work(
|
||||
tx: &mut Transaction<'_, MySql>,
|
||||
user_token: &UserToken,
|
||||
media_file_tokens: &[MediaFileToken],
|
||||
new_tags: &[NewTagValue],
|
||||
remove_unmentioned: bool,
|
||||
) -> Result<ApplyTagsOutcome, CommonWebError> {
|
||||
// Tags already linked to these files may lose links below, so their
|
||||
// use counts need recounting too. Only "set" semantics remove links.
|
||||
let previously_linked_tag_tokens = if remove_unmentioned {
|
||||
list_linked_tag_tokens_for_media_files(ListLinkedTagTokensForMediaFilesArgs {
|
||||
media_file_tokens,
|
||||
user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_linked_tag_tokens_for_media_files failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
upsert_tags(UpsertTagsArgs {
|
||||
new_tags,
|
||||
creator_user_token: user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("upsert_tags failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let lowercase_values: Vec<String> = new_tags.iter()
|
||||
.map(|t| t.tag_value_lowercase.clone())
|
||||
.collect();
|
||||
let tag_rows = select_tags_by_lowercase_values(SelectTagsByLowercaseValuesArgs {
|
||||
tag_values_lowercase: &lowercase_values,
|
||||
creator_user_token: user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("select_tags_by_lowercase_values failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
let tag_tokens: Vec<TagToken> = tag_rows.iter().map(|row| row.token.clone()).collect();
|
||||
|
||||
let removed_count = if remove_unmentioned {
|
||||
let removed = if tag_tokens.is_empty() {
|
||||
clear_media_file_tags(ClearMediaFileTagsArgs {
|
||||
media_file_tokens,
|
||||
user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await
|
||||
} else {
|
||||
delete_media_file_tags_not_in_set(DeleteMediaFileTagsNotInSetArgs {
|
||||
media_file_tokens,
|
||||
keep_tag_tokens: &tag_tokens,
|
||||
user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await
|
||||
};
|
||||
removed.map_err(|err| {
|
||||
warn!("Removing unmentioned tag links failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
insert_media_file_tags(InsertMediaFileTagsArgs {
|
||||
media_file_tokens,
|
||||
tag_tokens: &tag_tokens,
|
||||
user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("insert_media_file_tags failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// Guard against a concurrent tag delete: our snapshot SELECT above
|
||||
// may have seen the tag as live while another transaction soft-
|
||||
// deleted it (hard-deleting its links). This DELETE is a locking
|
||||
// read, so it observes the committed soft-delete and removes any
|
||||
// links we just re-inserted, instead of leaving orphans.
|
||||
delete_links_to_soft_deleted_tags(DeleteLinksToSoftDeletedTagsArgs {
|
||||
tag_tokens: &tag_tokens,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("delete_links_to_soft_deleted_tags failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// Recount every tag whose link set may have changed: the requested
|
||||
// tags plus any tags that previously linked to these files.
|
||||
let mut recount_tag_tokens = tag_tokens.clone();
|
||||
for token in previously_linked_tag_tokens {
|
||||
if !recount_tag_tokens.contains(&token) {
|
||||
recount_tag_tokens.push(token);
|
||||
}
|
||||
}
|
||||
recount_tag_use_counts(RecountTagUseCountsArgs {
|
||||
tag_tokens: &recount_tag_tokens,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("recount_tag_use_counts failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// Re-read the requested tags so the response carries fresh use counts.
|
||||
let fresh_tag_rows = select_tags_by_tokens_for_owner(SelectTagsByTokensForOwnerArgs {
|
||||
tag_tokens: &tag_tokens,
|
||||
creator_user_token: user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("select_tags_by_tokens_for_owner failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(ApplyTagsOutcome {
|
||||
tags: fresh_tag_rows,
|
||||
removed_count,
|
||||
})
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
use std::collections::HashSet;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::tags::bulk_add_tags::{BulkAddTagsRequest, BulkAddTagsSuccessResponse};
|
||||
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
|
||||
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
|
||||
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
|
||||
use crate::http_server::endpoints::tags::tag_input::{parse_tag_input, require_non_empty_tags};
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
pub const MAX_BULK_MEDIA_FILES: usize = 500;
|
||||
|
||||
/// The link insert is one multi-row statement over the full
|
||||
/// files × tags cartesian product; cap the product so a maxed-out
|
||||
/// request can't build an absurd statement.
|
||||
pub const MAX_LINK_PRODUCT: usize = 25_000;
|
||||
|
||||
/// Add the same tag set to many media files at once (pairwise: every
|
||||
/// tag goes on every file). Upsert semantics throughout — existing
|
||||
/// tags and existing links are absorbed. Tags not mentioned are left
|
||||
/// alone. Input tokens the user doesn't own (or that are deleted) are
|
||||
/// silently skipped; the response lists the accepted tokens.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/bulk_add",
|
||||
request_body = BulkAddTagsRequest,
|
||||
responses(
|
||||
(status = 200, body = BulkAddTagsSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn bulk_add_tags_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<BulkAddTagsRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<BulkAddTagsSuccessResponse>, CommonWebError> {
|
||||
let new_tags = parse_tag_input(
|
||||
request.maybe_tags.as_deref(),
|
||||
request.maybe_tags_list.as_deref(),
|
||||
)?;
|
||||
require_non_empty_tags(&new_tags)?;
|
||||
let media_file_tokens = dedupe_and_cap_media_file_tokens(&request.media_file_tokens)?;
|
||||
|
||||
if media_file_tokens.len() * new_tags.len() > MAX_LINK_PRODUCT {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("media_files × tags is too large (max {} pairs)", MAX_LINK_PRODUCT),
|
||||
));
|
||||
}
|
||||
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let accepted = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
|
||||
candidate_tokens: &media_file_tokens,
|
||||
owner_user_token: &user_session.user_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Media file ownership check failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// Nothing to tag — don't create (or revive) tags that would attach
|
||||
// to no files.
|
||||
if accepted.is_empty() {
|
||||
return Ok(Json(BulkAddTagsSuccessResponse {
|
||||
success: true,
|
||||
accepted_media_file_tokens: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
}));
|
||||
}
|
||||
|
||||
let outcome = apply_tags_to_media_files(
|
||||
&mut conn,
|
||||
&user_session.user_token,
|
||||
&accepted,
|
||||
&new_tags,
|
||||
/* remove_unmentioned= */ false,
|
||||
).await?;
|
||||
|
||||
Ok(Json(BulkAddTagsSuccessResponse {
|
||||
success: true,
|
||||
accepted_media_file_tokens: accepted,
|
||||
tags: outcome.tags.into_iter().map(tag_row_to_details).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Dedupe (preserving order) and enforce the bulk size cap. Bails out
|
||||
/// mid-loop the moment the cap is exceeded, so an oversized body costs
|
||||
/// O(cap) rather than a full pass over an unbounded input. Shared with
|
||||
/// the bulk_set handler.
|
||||
pub fn dedupe_and_cap_media_file_tokens(
|
||||
input: &[MediaFileToken],
|
||||
) -> Result<Vec<MediaFileToken>, CommonWebError> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut deduped: Vec<MediaFileToken> = Vec::new();
|
||||
for token in input {
|
||||
if seen.insert(token.as_str()) {
|
||||
if deduped.len() >= MAX_BULK_MEDIA_FILES {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("too many media files in one request (max {})", MAX_BULK_MEDIA_FILES),
|
||||
));
|
||||
}
|
||||
deduped.push(token.clone());
|
||||
}
|
||||
}
|
||||
Ok(deduped)
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::tags::bulk_list_media_file_tags::{
|
||||
BulkListMediaFileTagsRequest, BulkListMediaFileTagsSuccessResponse, MediaFileTagsEntry,
|
||||
};
|
||||
use artcraft_api_defs::tags::common::TagDetails;
|
||||
use mysql_queries::queries::tags::bulk_list_tags_for_media_files::{
|
||||
bulk_list_tags_for_media_files, BulkListTagsForMediaFilesArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::filter_visible_media_file_tokens::{
|
||||
filter_visible_media_file_tokens, FilterVisibleMediaFileTokensArgs,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
|
||||
/// All tags on each of the supplied media files, in one round-trip.
|
||||
/// Follows the same visibility rule as the single-file listing: tags on
|
||||
/// public and hidden files are readable by anyone; a PRIVATE file's
|
||||
/// tags only appear for its creator (other requesters see it with an
|
||||
/// empty tag list, indistinguishable from an untagged file). POST
|
||||
/// because the token list belongs in a request body — the operation is
|
||||
/// still a pure read.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/media_files/bulk_list_tags",
|
||||
request_body = BulkListMediaFileTagsRequest,
|
||||
responses(
|
||||
(status = 200, body = BulkListMediaFileTagsSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn bulk_list_media_file_tags_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<BulkListMediaFileTagsRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<BulkListMediaFileTagsSuccessResponse>, CommonWebError> {
|
||||
// Cheap validation first — oversized requests shouldn't cost a pool
|
||||
// connection or a session query.
|
||||
if request.media_file_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("too many media files in one request (max {})", MAX_BULK),
|
||||
));
|
||||
}
|
||||
|
||||
// Dedupe, preserving request order.
|
||||
let mut seen = HashSet::new();
|
||||
let mut media_file_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
for token in &request.media_file_tokens {
|
||||
if seen.insert(token.as_str()) {
|
||||
media_file_tokens.push(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let visible_tokens = filter_visible_media_file_tokens(FilterVisibleMediaFileTokensArgs {
|
||||
candidate_tokens: &media_file_tokens,
|
||||
requester_user_token: &user_session.user_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("filter_visible_media_file_tokens failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let pair_rows = bulk_list_tags_for_media_files(BulkListTagsForMediaFilesArgs {
|
||||
media_file_tokens: &visible_tokens,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("bulk_list_tags_for_media_files failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let mut tags_by_media_file: HashMap<MediaFileToken, Vec<TagDetails>> = HashMap::new();
|
||||
for pair in pair_rows {
|
||||
tags_by_media_file
|
||||
.entry(pair.media_file_token)
|
||||
.or_default()
|
||||
.push(TagDetails {
|
||||
tag_token: pair.tag_token,
|
||||
tag_value: pair.tag_value,
|
||||
tag_value_lowercase: pair.tag_value_lowercase,
|
||||
use_count: pair.use_count,
|
||||
});
|
||||
}
|
||||
|
||||
// One entry per requested token, in request order, empty list when
|
||||
// the file has no tags (or isn't visible to the requester). Tags
|
||||
// sorted by value for stable output.
|
||||
let media_files = media_file_tokens.into_iter()
|
||||
.map(|media_file_token| {
|
||||
let mut tags = tags_by_media_file.remove(&media_file_token).unwrap_or_default();
|
||||
tags.sort_by(|a, b| a.tag_value_lowercase.cmp(&b.tag_value_lowercase));
|
||||
MediaFileTagsEntry { media_file_token, tags }
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(BulkListMediaFileTagsSuccessResponse {
|
||||
success: true,
|
||||
media_files,
|
||||
}))
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::tags::bulk_set_tags::{BulkSetTagsRequest, BulkSetTagsSuccessResponse};
|
||||
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
|
||||
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
|
||||
use crate::http_server::endpoints::tags::bulk_add_tags_handler::{
|
||||
dedupe_and_cap_media_file_tokens, MAX_LINK_PRODUCT,
|
||||
};
|
||||
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
|
||||
use crate::http_server::endpoints::tags::tag_input::parse_tag_input;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Replace the tag set on many media files at once (pairwise: every
|
||||
/// file ends up with exactly the mentioned tags). Mentioned tags are
|
||||
/// upserted and attached like `bulk_add`; previously-attached tags that
|
||||
/// weren't mentioned are unlinked (orphaned tags are not deleted). An
|
||||
/// empty tag set clears every listed file. Input tokens the user
|
||||
/// doesn't own (or that are deleted) are silently skipped; the
|
||||
/// response lists the accepted tokens.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/bulk_set",
|
||||
request_body = BulkSetTagsRequest,
|
||||
responses(
|
||||
(status = 200, body = BulkSetTagsSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn bulk_set_tags_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<BulkSetTagsRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<BulkSetTagsSuccessResponse>, CommonWebError> {
|
||||
// Empty (after trimming) is allowed here: "set to nothing" clears.
|
||||
let new_tags = parse_tag_input(
|
||||
request.maybe_tags.as_deref(),
|
||||
request.maybe_tags_list.as_deref(),
|
||||
)?;
|
||||
let media_file_tokens = dedupe_and_cap_media_file_tokens(&request.media_file_tokens)?;
|
||||
|
||||
if media_file_tokens.len() * new_tags.len() > MAX_LINK_PRODUCT {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("media_files × tags is too large (max {} pairs)", MAX_LINK_PRODUCT),
|
||||
));
|
||||
}
|
||||
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let accepted = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
|
||||
candidate_tokens: &media_file_tokens,
|
||||
owner_user_token: &user_session.user_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Media file ownership check failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// Nothing to update — don't create (or revive) tags that would
|
||||
// attach to no files.
|
||||
if accepted.is_empty() {
|
||||
return Ok(Json(BulkSetTagsSuccessResponse {
|
||||
success: true,
|
||||
accepted_media_file_tokens: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
removed_count: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
let outcome = apply_tags_to_media_files(
|
||||
&mut conn,
|
||||
&user_session.user_token,
|
||||
&accepted,
|
||||
&new_tags,
|
||||
/* remove_unmentioned= */ true,
|
||||
).await?;
|
||||
|
||||
Ok(Json(BulkSetTagsSuccessResponse {
|
||||
success: true,
|
||||
accepted_media_file_tokens: accepted,
|
||||
tags: outcome.tags.into_iter().map(tag_row_to_details).collect(),
|
||||
removed_count: outcome.removed_count,
|
||||
}))
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::slice;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::tags::clear_media_file_tags::{
|
||||
ClearMediaFileTagsPathInfo, ClearMediaFileTagsSuccessResponse,
|
||||
};
|
||||
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
|
||||
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Remove ALL tags from a media file (hard delete of the links — the
|
||||
/// orphaned tags themselves are not deleted, and their use counts are
|
||||
/// recounted). Only the media file's creator may do this (404
|
||||
/// otherwise).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/media_file/clear/{media_file_token}",
|
||||
params(("media_file_token" = MediaFileToken, description = "Media file token")),
|
||||
responses(
|
||||
(status = 200, body = ClearMediaFileTagsSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn clear_media_file_tags_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ClearMediaFileTagsPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ClearMediaFileTagsSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let owned = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
|
||||
candidate_tokens: slice::from_ref(&path.media_file_token),
|
||||
owner_user_token: &user_session.user_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Media file ownership check failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
if owned.is_empty() {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
// Clearing is just "set to the empty tag set".
|
||||
let outcome = apply_tags_to_media_files(
|
||||
&mut conn,
|
||||
&user_session.user_token,
|
||||
&owned,
|
||||
/* new_tags= */ &[],
|
||||
/* remove_unmentioned= */ true,
|
||||
).await?;
|
||||
|
||||
Ok(Json(ClearMediaFileTagsSuccessResponse {
|
||||
success: true,
|
||||
removed_count: outcome.removed_count,
|
||||
}))
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Acquire, MySql, Transaction};
|
||||
|
||||
use artcraft_api_defs::tags::delete_tag::{DeleteTagPathInfo, DeleteTagSuccessResponse};
|
||||
use mysql_queries::queries::tags::delete_media_file_tags_for_tag::{
|
||||
delete_media_file_tags_for_tag, DeleteMediaFileTagsForTagArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::get_tag_for_owner::{get_tag_for_owner, GetTagForOwnerArgs};
|
||||
use mysql_queries::queries::tags::soft_delete_tag::{soft_delete_tag, SoftDeleteTagArgs};
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Delete a tag: hard-deletes every media-file link, then soft-deletes
|
||||
/// the tag record itself (its token can be revived later by re-adding
|
||||
/// the same tag text). Only the tag's creator may do this (404
|
||||
/// otherwise).
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/{tag_token}",
|
||||
params(("tag_token" = TagToken, description = "Tag token")),
|
||||
responses(
|
||||
(status = 200, body = DeleteTagSuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn delete_tag_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<DeleteTagPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<DeleteTagSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let tag = get_tag_for_owner(GetTagForOwnerArgs {
|
||||
tag_token: &path.tag_token,
|
||||
creator_user_token: &user_session.user_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Tag lookup failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
if tag.is_none() {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
let removed_link_count = perform_atomic_delete(
|
||||
&mut conn,
|
||||
&path.tag_token,
|
||||
&user_session.user_token,
|
||||
).await?;
|
||||
|
||||
Ok(Json(DeleteTagSuccessResponse {
|
||||
success: true,
|
||||
removed_link_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run the link hard-delete and the tag soft-delete as one unit.
|
||||
async fn perform_atomic_delete(
|
||||
conn: &mut PoolConnection<MySql>,
|
||||
tag_token: &TagToken,
|
||||
creator_user_token: &UserToken,
|
||||
) -> Result<u64, CommonWebError> {
|
||||
let mut tx = conn.begin().await.map_err(|err| {
|
||||
warn!("Failed to begin delete-tag transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let work_result = perform_delete_work(&mut tx, tag_token, creator_user_token).await;
|
||||
|
||||
match work_result {
|
||||
Ok(removed_link_count) => {
|
||||
tx.commit().await.map_err(|err| {
|
||||
warn!("Failed to commit delete-tag transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
Ok(removed_link_count)
|
||||
}
|
||||
Err(err) => {
|
||||
if let Err(rollback_err) = tx.rollback().await {
|
||||
error!(
|
||||
"Rollback after delete-tag failure also failed: {:?} (original error: {:?})",
|
||||
rollback_err, err,
|
||||
);
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_delete_work(
|
||||
tx: &mut Transaction<'_, MySql>,
|
||||
tag_token: &TagToken,
|
||||
creator_user_token: &UserToken,
|
||||
) -> Result<u64, CommonWebError> {
|
||||
let removed_link_count = delete_media_file_tags_for_tag(DeleteMediaFileTagsForTagArgs {
|
||||
tag_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("delete_media_file_tags_for_tag failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let updated = soft_delete_tag(SoftDeleteTagArgs {
|
||||
tag_token,
|
||||
creator_user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("soft_delete_tag failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// The pre-check passed but the tag vanished mid-flight (concurrent
|
||||
// delete). The rollback keeps the links intact.
|
||||
if updated == 0 {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
Ok(removed_link_count)
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::tags::list_media_file_tags::{
|
||||
ListMediaFileTagsPathInfo, ListMediaFileTagsSuccessResponse,
|
||||
};
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::tags::get_media_file_access_fields::{
|
||||
get_media_file_access_fields, GetMediaFileAccessFieldsArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::list_tags_for_media_file::{
|
||||
list_tags_for_media_file, ListTagsForMediaFileArgs,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// All tags on a media file, sorted by tag value. No login required for
|
||||
/// public and hidden media files (anyone with the URL); PRIVATE files
|
||||
/// only reveal their tags to their creator — everyone else gets a 404,
|
||||
/// same as a nonexistent file, so private tag vocabulary doesn't leak.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/media_file/list/{media_file_token}",
|
||||
params(("media_file_token" = MediaFileToken, description = "Media file token")),
|
||||
responses(
|
||||
(status = 200, body = ListMediaFileTagsSuccessResponse),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_media_file_tags_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ListMediaFileTagsPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListMediaFileTagsSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let access_fields = get_media_file_access_fields(GetMediaFileAccessFieldsArgs {
|
||||
media_file_token: &path.media_file_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Media file access lookup failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
let Some(access_fields) = access_fields else {
|
||||
return Err(CommonWebError::NotFound);
|
||||
};
|
||||
|
||||
if access_fields.creator_set_visibility == Visibility::Private {
|
||||
// Only the creator may see a private file's tags. The session
|
||||
// lookup is deferred to here so the common (non-private) case
|
||||
// stays session-free.
|
||||
let maybe_user_session = server_state.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut conn)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Session checker error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let is_creator = match (&maybe_user_session, &access_fields.maybe_creator_user_token) {
|
||||
(Some(session), Some(creator)) => session.user_token.as_str() == creator.as_str(),
|
||||
_ => false,
|
||||
};
|
||||
if !is_creator {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
let rows = list_tags_for_media_file(ListTagsForMediaFileArgs {
|
||||
media_file_token: &path.media_file_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_tags_for_media_file failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(ListMediaFileTagsSuccessResponse {
|
||||
success: true,
|
||||
tags: rows.into_iter().map(tag_row_to_details).collect(),
|
||||
}))
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::tags::list_media_files_with_tag::{
|
||||
ListMediaFilesWithTagPathInfo, ListMediaFilesWithTagQueryParams,
|
||||
};
|
||||
use mysql_queries::queries::tags::get_tag_for_owner::{get_tag_for_owner, GetTagForOwnerArgs};
|
||||
use mysql_queries::queries::tags::list_media_files_with_tag_for_user::{
|
||||
list_media_files_with_tag_for_user, ListMediaFilesWithTagForUserArgs,
|
||||
};
|
||||
use tokens::tokens::tags::TagToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::tags::tag_media_file_list_item::{
|
||||
tag_media_file_row_to_list_item, TagMediaFileListItem,
|
||||
};
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "tags_with_tag_mf";
|
||||
const DEFAULT_LIMIT: u32 = 100;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListMediaFilesWithTagSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_files: Vec<TagMediaFileListItem>,
|
||||
pub maybe_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Paginated list of the logged-in user's media files carrying the
|
||||
/// given tag. The tag must belong to the user (404 otherwise).
|
||||
/// Newest first.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/media_files/with_tag/{tag_token}",
|
||||
params(
|
||||
("tag_token" = TagToken, description = "Tag token"),
|
||||
ListMediaFilesWithTagQueryParams,
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = ListMediaFilesWithTagSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_media_files_with_tag_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ListMediaFilesWithTagPathInfo>,
|
||||
query: Query<ListMediaFilesWithTagQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListMediaFilesWithTagSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let tag = get_tag_for_owner(GetTagForOwnerArgs {
|
||||
tag_token: &path.tag_token,
|
||||
creator_user_token: &user_session.user_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Tag lookup failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
if tag.is_none() {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
}
|
||||
};
|
||||
|
||||
let rows = list_media_files_with_tag_for_user(ListMediaFilesWithTagForUserArgs {
|
||||
tag_token: &path.tag_token,
|
||||
owner_user_token: &user_session.user_token,
|
||||
maybe_cursor_id,
|
||||
limit,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_media_files_with_tag_for_user failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
let media_files = rows.into_iter()
|
||||
.map(|row| tag_media_file_row_to_list_item(row, media_domain, server_environment))
|
||||
.collect();
|
||||
|
||||
Ok(Json(ListMediaFilesWithTagSuccessResponse {
|
||||
success: true,
|
||||
media_files,
|
||||
maybe_cursor,
|
||||
}))
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::tags::list_tagged_media_files::ListTaggedMediaFilesQueryParams;
|
||||
use mysql_queries::queries::tags::list_tagged_media_files_for_user::{
|
||||
list_tagged_media_files_for_user, ListTaggedMediaFilesForUserArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::tags::tag_media_file_list_item::{
|
||||
tag_media_file_row_to_list_item, TagMediaFileListItem,
|
||||
};
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "tags_tagged_mf";
|
||||
const DEFAULT_LIMIT: u32 = 100;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListTaggedMediaFilesSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_files: Vec<TagMediaFileListItem>,
|
||||
pub maybe_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Paginated list of the logged-in user's media files that carry at
|
||||
/// least one tag. Newest first.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/media_files/list_tagged",
|
||||
params(ListTaggedMediaFilesQueryParams),
|
||||
responses(
|
||||
(status = 200, body = ListTaggedMediaFilesSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_tagged_media_files_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListTaggedMediaFilesQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListTaggedMediaFilesSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
}
|
||||
};
|
||||
|
||||
let rows = list_tagged_media_files_for_user(ListTaggedMediaFilesForUserArgs {
|
||||
owner_user_token: &user_session.user_token,
|
||||
maybe_cursor_id,
|
||||
limit,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_tagged_media_files_for_user failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
let media_files = rows.into_iter()
|
||||
.map(|row| tag_media_file_row_to_list_item(row, media_domain, server_environment))
|
||||
.collect();
|
||||
|
||||
Ok(Json(ListTaggedMediaFilesSuccessResponse {
|
||||
success: true,
|
||||
media_files,
|
||||
maybe_cursor,
|
||||
}))
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::tags::list_tags::{ListTagsQueryParams, ListTagsSuccessResponse};
|
||||
use mysql_queries::queries::tags::list_tags_for_user::{list_tags_for_user, ListTagsForUserArgs};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "tags_list";
|
||||
const DEFAULT_LIMIT: u32 = 100;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
/// Paginated list of the logged-in user's tags, newest first.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/list",
|
||||
params(ListTagsQueryParams),
|
||||
responses(
|
||||
(status = 200, body = ListTagsSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_tags_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListTagsQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListTagsSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
}
|
||||
};
|
||||
|
||||
let rows = list_tags_for_user(ListTagsForUserArgs {
|
||||
creator_user_token: &user_session.user_token,
|
||||
maybe_cursor_id,
|
||||
limit,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_tags_for_user failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
|
||||
Ok(Json(ListTagsSuccessResponse {
|
||||
success: true,
|
||||
tags: rows.into_iter().map(tag_row_to_details).collect(),
|
||||
maybe_cursor,
|
||||
}))
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::tags::list_untagged_media_files::ListUntaggedMediaFilesQueryParams;
|
||||
use mysql_queries::queries::tags::list_untagged_media_files_for_user::{
|
||||
list_untagged_media_files_for_user, ListUntaggedMediaFilesForUserArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::tags::tag_media_file_list_item::{
|
||||
tag_media_file_row_to_list_item, TagMediaFileListItem,
|
||||
};
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "tags_untagged_mf";
|
||||
const DEFAULT_LIMIT: u32 = 100;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListUntaggedMediaFilesSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_files: Vec<TagMediaFileListItem>,
|
||||
pub maybe_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Paginated list of the logged-in user's media files that carry no
|
||||
/// tags at all. Newest first.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/media_files/list_untagged",
|
||||
params(ListUntaggedMediaFilesQueryParams),
|
||||
responses(
|
||||
(status = 200, body = ListUntaggedMediaFilesSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_untagged_media_files_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListUntaggedMediaFilesQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListUntaggedMediaFilesSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
}
|
||||
};
|
||||
|
||||
let rows = list_untagged_media_files_for_user(ListUntaggedMediaFilesForUserArgs {
|
||||
owner_user_token: &user_session.user_token,
|
||||
maybe_cursor_id,
|
||||
limit,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_untagged_media_files_for_user failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
let media_files = rows.into_iter()
|
||||
.map(|row| tag_media_file_row_to_list_item(row, media_domain, server_environment))
|
||||
.collect();
|
||||
|
||||
Ok(Json(ListUntaggedMediaFilesSuccessResponse {
|
||||
success: true,
|
||||
media_files,
|
||||
maybe_cursor,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
pub mod add_media_file_tags_handler;
|
||||
pub mod bulk_add_tags_handler;
|
||||
pub mod bulk_list_media_file_tags_handler;
|
||||
pub mod bulk_set_tags_handler;
|
||||
pub mod clear_media_file_tags_handler;
|
||||
pub mod delete_tag_handler;
|
||||
pub mod list_media_file_tags_handler;
|
||||
pub mod list_media_files_with_tag_handler;
|
||||
pub mod list_tagged_media_files_handler;
|
||||
pub mod list_tags_handler;
|
||||
pub mod list_untagged_media_files_handler;
|
||||
pub mod rename_tag_handler;
|
||||
pub mod set_media_file_tags_handler;
|
||||
|
||||
// Public: `TagMediaFileListItem` is registered in `api_doc.rs`.
|
||||
pub mod tag_media_file_list_item;
|
||||
|
||||
mod apply_tags;
|
||||
mod tag_details;
|
||||
mod tag_input;
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::{error, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Acquire, MySql, Transaction};
|
||||
|
||||
use artcraft_api_defs::tags::common::TagDetails;
|
||||
use artcraft_api_defs::tags::rename_tag::{
|
||||
RenameTagPathInfo, RenameTagRequest, RenameTagSuccessResponse,
|
||||
};
|
||||
use mysql_queries::errors::database_insert_error::DatabaseInsertError;
|
||||
use mysql_queries::queries::tags::delete_media_file_tags_for_tag::{
|
||||
delete_media_file_tags_for_tag, DeleteMediaFileTagsForTagArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::get_soft_deleted_tag_by_lowercase::{
|
||||
get_soft_deleted_tag_by_lowercase, GetSoftDeletedTagByLowercaseArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::get_tag_for_owner::{get_tag_for_owner, GetTagForOwnerArgs};
|
||||
use mysql_queries::queries::tags::hard_delete_soft_deleted_tag::{
|
||||
hard_delete_soft_deleted_tag, HardDeleteSoftDeletedTagArgs,
|
||||
};
|
||||
use mysql_queries::queries::tags::rename_tag::{rename_tag, RenameTagArgs};
|
||||
use tokens::tokens::tags::TagToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::tags::tag_input::MAX_TAG_LENGTH_CHARS;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Rename a tag. Handles both case-only changes (same lowercased value)
|
||||
/// and wholesale renames. If the target name is held by one of the
|
||||
/// user's SOFT-DELETED tags, the dead row is purged so the rename can
|
||||
/// take the name over; a LIVE tag with the target name fails with 400.
|
||||
/// Only the tag's creator may do this (404 otherwise).
|
||||
#[utoipa::path(
|
||||
put,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/rename/{tag_token}",
|
||||
params(("tag_token" = TagToken, description = "Tag token")),
|
||||
request_body = RenameTagRequest,
|
||||
responses(
|
||||
(status = 200, body = RenameTagSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn rename_tag_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<RenameTagPathInfo>,
|
||||
request: Json<RenameTagRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<RenameTagSuccessResponse>, CommonWebError> {
|
||||
let new_tag_value = request.new_tag_value.trim();
|
||||
if new_tag_value.is_empty() {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"new_tag_value must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
// NB: check the lowercased form too — Unicode lowercasing can EXPAND
|
||||
// a string (e.g. 'İ' U+0130 → "i\u{307}"), and both columns are
|
||||
// VARCHAR(255).
|
||||
let new_tag_value_lowercase = new_tag_value.to_lowercase();
|
||||
if new_tag_value.chars().count() > MAX_TAG_LENGTH_CHARS
|
||||
|| new_tag_value_lowercase.chars().count() > MAX_TAG_LENGTH_CHARS
|
||||
{
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("tag is too long (max {} characters)", MAX_TAG_LENGTH_CHARS),
|
||||
));
|
||||
}
|
||||
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let tag = get_tag_for_owner(GetTagForOwnerArgs {
|
||||
tag_token: &path.tag_token,
|
||||
creator_user_token: &user_session.user_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Tag lookup failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
let Some(tag) = tag else {
|
||||
return Err(CommonWebError::NotFound);
|
||||
};
|
||||
|
||||
perform_atomic_rename(
|
||||
&mut conn,
|
||||
&path.tag_token,
|
||||
&user_session.user_token,
|
||||
new_tag_value,
|
||||
&new_tag_value_lowercase,
|
||||
).await?;
|
||||
|
||||
Ok(Json(RenameTagSuccessResponse {
|
||||
success: true,
|
||||
tag: TagDetails {
|
||||
tag_token: tag.token,
|
||||
tag_value: new_tag_value.to_string(),
|
||||
tag_value_lowercase: new_tag_value_lowercase,
|
||||
use_count: tag.use_count,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// Purge any soft-deleted tag squatting on the target name, then rename
|
||||
/// — as one unit, so a failed rename doesn't leave the dead tag purged.
|
||||
async fn perform_atomic_rename(
|
||||
conn: &mut PoolConnection<MySql>,
|
||||
tag_token: &TagToken,
|
||||
creator_user_token: &UserToken,
|
||||
new_tag_value: &str,
|
||||
new_tag_value_lowercase: &str,
|
||||
) -> Result<(), CommonWebError> {
|
||||
let mut tx = conn.begin().await.map_err(|err| {
|
||||
warn!("Failed to begin rename-tag transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let work_result = perform_rename_work(
|
||||
&mut tx,
|
||||
tag_token,
|
||||
creator_user_token,
|
||||
new_tag_value,
|
||||
new_tag_value_lowercase,
|
||||
).await;
|
||||
|
||||
match work_result {
|
||||
Ok(()) => {
|
||||
tx.commit().await.map_err(|err| {
|
||||
warn!("Failed to commit rename-tag transaction: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
if let Err(rollback_err) = tx.rollback().await {
|
||||
error!(
|
||||
"Rollback after rename-tag failure also failed: {:?} (original error: {:?})",
|
||||
rollback_err, err,
|
||||
);
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_rename_work(
|
||||
tx: &mut Transaction<'_, MySql>,
|
||||
tag_token: &TagToken,
|
||||
creator_user_token: &UserToken,
|
||||
new_tag_value: &str,
|
||||
new_tag_value_lowercase: &str,
|
||||
) -> Result<(), CommonWebError> {
|
||||
// A soft-deleted tag still occupies the (tag_value_lowercase,
|
||||
// creator_user_token) unique key. The user's tag list doesn't show
|
||||
// it, so from their perspective the name is free — purge the dead
|
||||
// row (and any stray links to it) so the rename can take the name.
|
||||
let maybe_dead_tag_token = get_soft_deleted_tag_by_lowercase(GetSoftDeletedTagByLowercaseArgs {
|
||||
tag_value_lowercase: new_tag_value_lowercase,
|
||||
creator_user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("get_soft_deleted_tag_by_lowercase failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
if let Some(dead_tag_token) = maybe_dead_tag_token {
|
||||
delete_media_file_tags_for_tag(DeleteMediaFileTagsForTagArgs {
|
||||
tag_token: &dead_tag_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("delete_media_file_tags_for_tag failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
hard_delete_soft_deleted_tag(HardDeleteSoftDeletedTagArgs {
|
||||
tag_token: &dead_tag_token,
|
||||
creator_user_token,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("hard_delete_soft_deleted_tag failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
}
|
||||
|
||||
let updated = rename_tag(RenameTagArgs {
|
||||
tag_token,
|
||||
creator_user_token,
|
||||
new_tag_value,
|
||||
new_tag_value_lowercase,
|
||||
mysql_executor: &mut **tx,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
// A LIVE tag with the target name still rejects the rename via the
|
||||
// (tag_value_lowercase, creator_user_token) unique key.
|
||||
match DatabaseInsertError::from(err) {
|
||||
DatabaseInsertError::DuplicateKeyError => CommonWebError::BadInputWithSimpleMessage(
|
||||
"you already have a tag with that name".to_string(),
|
||||
),
|
||||
other => {
|
||||
warn!("rename_tag failed: {:?}", other);
|
||||
CommonWebError::server_error_with_message("Failed to rename tag")
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
// The pre-check passed but the tag vanished mid-flight.
|
||||
if updated == 0 {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::slice;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::tags::set_media_file_tags::{
|
||||
SetMediaFileTagsPathInfo, SetMediaFileTagsRequest, SetMediaFileTagsSuccessResponse,
|
||||
};
|
||||
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
|
||||
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
|
||||
};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
|
||||
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
|
||||
use crate::http_server::endpoints::tags::tag_input::parse_tag_input;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Replace a media file's tag set. Mentioned tags are upserted and
|
||||
/// attached exactly like `add`; previously-attached tags that weren't
|
||||
/// mentioned are unlinked (hard delete of the link — orphaned tags are
|
||||
/// not deleted). An empty tag set clears the file. Only the media
|
||||
/// file's creator may do this (404 otherwise).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Tags",
|
||||
path = "/v1/tags/media_file/set/{media_file_token}",
|
||||
params(("media_file_token" = MediaFileToken, description = "Media file token")),
|
||||
request_body = SetMediaFileTagsRequest,
|
||||
responses(
|
||||
(status = 200, body = SetMediaFileTagsSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn set_media_file_tags_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<SetMediaFileTagsPathInfo>,
|
||||
request: Json<SetMediaFileTagsRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<SetMediaFileTagsSuccessResponse>, CommonWebError> {
|
||||
// Empty (after trimming) is allowed here: "set to nothing" clears.
|
||||
let new_tags = parse_tag_input(
|
||||
request.maybe_tags.as_deref(),
|
||||
request.maybe_tags_list.as_deref(),
|
||||
)?;
|
||||
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let owned = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
|
||||
candidate_tokens: slice::from_ref(&path.media_file_token),
|
||||
owner_user_token: &user_session.user_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("Media file ownership check failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
if owned.is_empty() {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
let outcome = apply_tags_to_media_files(
|
||||
&mut conn,
|
||||
&user_session.user_token,
|
||||
&owned,
|
||||
&new_tags,
|
||||
/* remove_unmentioned= */ true,
|
||||
).await?;
|
||||
|
||||
Ok(Json(SetMediaFileTagsSuccessResponse {
|
||||
success: true,
|
||||
tags: outcome.tags.into_iter().map(tag_row_to_details).collect(),
|
||||
removed_count: outcome.removed_count,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use artcraft_api_defs::tags::common::TagDetails;
|
||||
use mysql_queries::queries::tags::tag_row::TagRow;
|
||||
|
||||
pub fn tag_row_to_details(row: TagRow) -> TagDetails {
|
||||
TagDetails {
|
||||
tag_token: row.token,
|
||||
tag_value: row.tag_value,
|
||||
tag_value_lowercase: row.tag_value_lowercase,
|
||||
use_count: row.use_count,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use mysql_queries::queries::tags::upsert_tags::NewTagValue;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
/// Cap on distinct tags in a single request (after dedupe).
|
||||
pub const MAX_TAGS_PER_REQUEST: usize = 100;
|
||||
|
||||
/// `tags.tag_value` is a VARCHAR(255).
|
||||
pub const MAX_TAG_LENGTH_CHARS: usize = 255;
|
||||
|
||||
/// Parse the "either a comma-separated string or a list" tag input shape
|
||||
/// shared by the add/set endpoints. Exactly one of the two fields must be
|
||||
/// set (400 otherwise). Each entry is trimmed; empty entries are dropped;
|
||||
/// duplicates are deduped case-insensitively (first casing wins).
|
||||
///
|
||||
/// An empty RESULT is not an error here — "set to nothing" is a valid
|
||||
/// clear operation. Endpoints where zero tags makes no sense (the adds)
|
||||
/// should follow up with [`require_non_empty_tags`].
|
||||
pub fn parse_tag_input(
|
||||
maybe_tags: Option<&str>,
|
||||
maybe_tags_list: Option<&[String]>,
|
||||
) -> Result<Vec<NewTagValue>, CommonWebError> {
|
||||
let raw_values: Vec<&str> = match (maybe_tags, maybe_tags_list) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"supply either maybe_tags or maybe_tags_list, not both".to_string(),
|
||||
));
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"supply one of maybe_tags or maybe_tags_list".to_string(),
|
||||
));
|
||||
}
|
||||
(Some(csv), None) => csv.split(',').collect(),
|
||||
(None, Some(list)) => list.iter().map(|s| s.as_str()).collect(),
|
||||
};
|
||||
|
||||
let mut seen_lowercase = HashSet::new();
|
||||
let mut new_tags = Vec::new();
|
||||
|
||||
for raw_value in raw_values {
|
||||
let trimmed = raw_value.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// NB: the lowercased form must be length-checked too — Unicode
|
||||
// lowercasing can EXPAND a string (e.g. 'İ' U+0130 lowercases to
|
||||
// "i\u{307}", two chars), and both columns are VARCHAR(255).
|
||||
let lowercase = trimmed.to_lowercase();
|
||||
if trimmed.chars().count() > MAX_TAG_LENGTH_CHARS
|
||||
|| lowercase.chars().count() > MAX_TAG_LENGTH_CHARS
|
||||
{
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("tag is too long (max {} characters)", MAX_TAG_LENGTH_CHARS),
|
||||
));
|
||||
}
|
||||
|
||||
if !seen_lowercase.insert(lowercase.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new_tags.push(NewTagValue {
|
||||
tag_value: trimmed.to_string(),
|
||||
tag_value_lowercase: lowercase,
|
||||
});
|
||||
}
|
||||
|
||||
if new_tags.len() > MAX_TAGS_PER_REQUEST {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("too many tags in one request (max {})", MAX_TAGS_PER_REQUEST),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(new_tags)
|
||||
}
|
||||
|
||||
/// 400 if sanitization produced zero tags. For the add endpoints, where
|
||||
/// "add nothing" is a client bug rather than a clear operation.
|
||||
pub fn require_non_empty_tags(new_tags: &[NewTagValue]) -> Result<(), CommonWebError> {
|
||||
if new_tags.is_empty() {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
"no tags supplied after trimming".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_csv_with_trimming_and_case_insensitive_dedupe() {
|
||||
let tags = parse_tag_input(Some(" Cats , sci-fi ,Wallpaper,, cats "), None).unwrap();
|
||||
let values: Vec<&str> = tags.iter().map(|t| t.tag_value.as_str()).collect();
|
||||
assert_eq!(values, vec!["Cats", "sci-fi", "Wallpaper"]);
|
||||
assert_eq!(tags[0].tag_value_lowercase, "cats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_both_or_neither_input() {
|
||||
assert!(parse_tag_input(Some("a"), Some(&["b".to_string()])).is_err());
|
||||
assert!(parse_tag_input(None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tag_whose_lowercase_form_expands_past_the_limit() {
|
||||
// 'İ' (U+0130) is one char but lowercases to two ("i\u{307}"), so
|
||||
// 255 of them pass a naive check while the lowercase form is 510
|
||||
// chars — over the VARCHAR(255) limit.
|
||||
let dotted_capital_i = "İ".repeat(MAX_TAG_LENGTH_CHARS);
|
||||
assert_eq!(dotted_capital_i.chars().count(), MAX_TAG_LENGTH_CHARS);
|
||||
assert!(parse_tag_input(Some(&dotted_capital_i), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_tag_at_exactly_the_length_limit() {
|
||||
let max_length_tag = "a".repeat(MAX_TAG_LENGTH_CHARS);
|
||||
let tags = parse_tag_input(Some(&max_length_tag), None).unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::common::responses::media_links::MediaLinks;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::media_files::list::media_file_list_row::MediaFileListRow;
|
||||
use server_environment::ServerEnvironment;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_file_list_conversion::build_media_links_and_cover;
|
||||
|
||||
/// One media file as it appears in the tag-scoped media file lists
|
||||
/// (untagged / tagged / with-tag). Same lean shape as the folder list
|
||||
/// item. Intermediate system files are filtered out by the queries, so
|
||||
/// the flag isn't part of the wire shape.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TagMediaFileListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
/// Coarse-grained class (image / video / audio / dimensional).
|
||||
pub media_class: MediaFileClass,
|
||||
|
||||
/// Specific format (jpg, png, mp4, etc.) — closer to a MIME type.
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
/// Link to the prompt
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
/// If this file was generated as part of a batch, the batch token —
|
||||
/// useful for showing "siblings" in the UI.
|
||||
pub maybe_batch_token: Option<BatchGenerationToken>,
|
||||
|
||||
/// Rich CDN links to the media itself (full URL, thumbnail template,
|
||||
/// video previews when applicable).
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
/// Cover image details. For files that don't carry their own cover
|
||||
/// image, this still gives the deterministic default-cover spec
|
||||
/// (image_index + color_index) the frontend uses for placeholders.
|
||||
pub cover_image: MediaFileCoverImageDetails,
|
||||
|
||||
pub maybe_title: Option<String>,
|
||||
pub maybe_original_filename: Option<String>,
|
||||
|
||||
/// Original pixel width / height for image and video files when known.
|
||||
pub maybe_frame_width: Option<i32>,
|
||||
pub maybe_frame_height: Option<i32>,
|
||||
|
||||
/// Duration for audio and video files, if available. Milliseconds.
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
pub is_user_upload: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub fn tag_media_file_row_to_list_item(
|
||||
row: MediaFileListRow,
|
||||
media_domain: MediaDomain,
|
||||
server_environment: ServerEnvironment,
|
||||
) -> TagMediaFileListItem {
|
||||
let (media_links, cover_image) = build_media_links_and_cover(&row, media_domain, server_environment);
|
||||
|
||||
TagMediaFileListItem {
|
||||
token: row.media_file_token,
|
||||
media_class: row.media_class,
|
||||
media_type: row.media_type,
|
||||
maybe_batch_token: row.maybe_batch_token,
|
||||
media_links,
|
||||
cover_image,
|
||||
creator_set_visibility: row.creator_set_visibility,
|
||||
is_user_upload: row.is_user_upload,
|
||||
maybe_title: row.maybe_title,
|
||||
maybe_prompt_token: row.maybe_prompt_token,
|
||||
maybe_original_filename: row.maybe_origin_filename,
|
||||
// Schema stores `INT(10)`; widen to u64 for the wire shape.
|
||||
maybe_duration_millis: row.maybe_duration_millis.map(|n| n as u64),
|
||||
maybe_frame_width: row.maybe_frame_width,
|
||||
maybe_frame_height: row.maybe_frame_height,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
+2
@@ -16,6 +16,7 @@ use crate::http_server::routes::application_routes::omni_gen_routes::add_omni_ge
|
||||
use crate::http_server::routes::application_routes::prompts_routes::add_prompts_routes;
|
||||
use crate::http_server::routes::application_routes::stripe_artcraft_routes::add_stripe_artcraft_routes;
|
||||
use crate::http_server::routes::application_routes::subscription_routes::add_subscription_routes;
|
||||
use crate::http_server::routes::application_routes::tag_routes::add_tag_routes;
|
||||
use crate::http_server::routes::application_routes::user_bookmarks_routes::add_user_bookmarks_routes;
|
||||
use crate::http_server::routes::application_routes::user_rating_routes::add_user_rating_routes;
|
||||
use crate::http_server::routes::application_routes::user_referral_code_routes::add_user_referral_code_routes;
|
||||
@@ -59,6 +60,7 @@ where
|
||||
// Media files routes
|
||||
app = add_media_file_routes(app); // /v1/media_files/...
|
||||
app = add_folders_routes(app); // /v1/folders/...
|
||||
app = add_tag_routes(app); // /v1/tags/...
|
||||
app = add_api_keys_routes(app); // /v1/api_keys/...
|
||||
app = add_featured_item_routes(app); // /v1/featured_item/...
|
||||
app = add_prompts_routes(app); // /v1/prompts/...
|
||||
|
||||
@@ -16,6 +16,7 @@ mod omni_gen_routes;
|
||||
mod prompts_routes;
|
||||
mod stripe_artcraft_routes;
|
||||
mod subscription_routes;
|
||||
mod tag_routes;
|
||||
mod user_bookmarks_routes;
|
||||
mod user_rating_routes;
|
||||
mod user_referral_code_routes;
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
use actix_http::body::MessageBody;
|
||||
use actix_service::ServiceFactory;
|
||||
use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::{web, App, Error, HttpResponse};
|
||||
|
||||
use crate::http_server::endpoints::tags::add_media_file_tags_handler::add_media_file_tags_handler;
|
||||
use crate::http_server::endpoints::tags::bulk_add_tags_handler::bulk_add_tags_handler;
|
||||
use crate::http_server::endpoints::tags::bulk_list_media_file_tags_handler::bulk_list_media_file_tags_handler;
|
||||
use crate::http_server::endpoints::tags::bulk_set_tags_handler::bulk_set_tags_handler;
|
||||
use crate::http_server::endpoints::tags::clear_media_file_tags_handler::clear_media_file_tags_handler;
|
||||
use crate::http_server::endpoints::tags::delete_tag_handler::delete_tag_handler;
|
||||
use crate::http_server::endpoints::tags::list_media_file_tags_handler::list_media_file_tags_handler;
|
||||
use crate::http_server::endpoints::tags::list_media_files_with_tag_handler::list_media_files_with_tag_handler;
|
||||
use crate::http_server::endpoints::tags::list_tagged_media_files_handler::list_tagged_media_files_handler;
|
||||
use crate::http_server::endpoints::tags::list_tags_handler::list_tags_handler;
|
||||
use crate::http_server::endpoints::tags::list_untagged_media_files_handler::list_untagged_media_files_handler;
|
||||
use crate::http_server::endpoints::tags::rename_tag_handler::rename_tag_handler;
|
||||
use crate::http_server::endpoints::tags::set_media_file_tags_handler::set_media_file_tags_handler;
|
||||
|
||||
pub fn add_tag_routes<T, B>(app: App<T>) -> App<T>
|
||||
where
|
||||
B: MessageBody,
|
||||
T: ServiceFactory<
|
||||
ServiceRequest,
|
||||
Config = (),
|
||||
Response = ServiceResponse<B>,
|
||||
Error = Error,
|
||||
InitError = (),
|
||||
>,
|
||||
{
|
||||
app.service(
|
||||
web::scope("/v1/tags")
|
||||
// The user's tags
|
||||
.service(
|
||||
web::resource("/list")
|
||||
.route(web::get().to(list_tags_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/rename/{tag_token}")
|
||||
.route(web::put().to(rename_tag_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
// Tagging many media files at once
|
||||
.service(
|
||||
web::resource("/bulk_add")
|
||||
.route(web::post().to(bulk_add_tags_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/bulk_set")
|
||||
.route(web::post().to(bulk_set_tags_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
// Tag operations on a single media file
|
||||
.service(
|
||||
web::scope("/media_file")
|
||||
.service(
|
||||
web::resource("/list/{media_file_token}")
|
||||
.route(web::get().to(list_media_file_tags_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/add/{media_file_token}")
|
||||
.route(web::post().to(add_media_file_tags_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/set/{media_file_token}")
|
||||
.route(web::post().to(set_media_file_tags_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/clear/{media_file_token}")
|
||||
.route(web::post().to(clear_media_file_tags_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
),
|
||||
)
|
||||
// Media-file listings by tag state
|
||||
.service(
|
||||
web::scope("/media_files")
|
||||
.service(
|
||||
web::resource("/list_untagged")
|
||||
.route(web::get().to(list_untagged_media_files_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/list_tagged")
|
||||
.route(web::get().to(list_tagged_media_files_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/with_tag/{tag_token}")
|
||||
.route(web::get().to(list_media_files_with_tag_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/bulk_list_tags")
|
||||
.route(web::post().to(bulk_list_media_file_tags_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
),
|
||||
)
|
||||
// NB: registered last so the literal segments above win the match.
|
||||
.service(
|
||||
web::resource("/{tag_token}")
|
||||
.route(web::delete().to(delete_tag_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user