Bulk edit tags

This commit is contained in:
Bad Manners 2025-04-20 10:23:51 -03:00
parent 8fac396d7e
commit 94269d82f0
8 changed files with 252 additions and 24 deletions

View file

@ -6,31 +6,36 @@ Sam's small image board.
Still very much an early WIP.
### Features
- Image and video posts.
- Tagging with autocompletion.
- Post pools.
- RSS feeds.
### Known issues
- [ ] No way to close tag autocompletion on mobile
- No way to close tag autocompletion on mobile
### Roadmap
- [ ] Favicon from post
- [ ] Bulk edit tag
- [ ] RSS feed
- [ ] Logging
- [ ] Improved error handling
- [ ] Caching
- [ ] Delete pools
- [ ] Logging and improved error handling
- [ ] Lossless compression
- [ ] User management
- [ ] Cleanup/fixup background tasks
- [ ] Caching
- [ ] Text media
- [ ] Improve CSS
- [ ] Garbage collection background tasks (tags, pools)
- [ ] User management
- [ ] Migrate to Cot...?
## Prerequisites
## Running
### Prerequisites
- `ffmpeg` and `ffprobe`
## Running
### Development
```bash

View file

@ -129,6 +129,8 @@ pub async fn get_router(
.route_with_tsr("/pool/{pool_id}/post", post(add_post_to_pool))
.route_with_tsr("/pool/{pool_id}/sort", put(sort_pool))
.route_with_tsr("/pool_post/{pool_post_id}", delete(remove_pool_post))
// Bulk edit tag routes
.route_with_tsr("/bulk_edit_tag", get(bulk_edit_tag).post(edit_tag))
// Settings routes
.route_with_tsr("/settings", get(settings).post(update_settings))
// Search routes

View file

@ -1,3 +1,8 @@
use std::{
net::{IpAddr, Ipv6Addr},
path::PathBuf,
};
use clap::{Parser, Subcommand};
use migration::{Migrator, MigratorTrait};
use samey::{create_user, get_router};
@ -5,50 +10,80 @@ use sea_orm::Database;
#[derive(Parser)]
struct Config {
#[arg(short, long, default_value = "sqlite:db.sqlite3?mode=rwc")]
database: String,
#[arg(short, long, default_value = "files")]
files_directory: PathBuf,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Run,
Run {
#[arg(short, long, default_value_t = IpAddr::V6(Ipv6Addr::UNSPECIFIED))]
address: IpAddr,
#[arg(short, long, default_value_t = 3000)]
port: u16,
},
Migrate,
AddAdminUser {
#[arg(short, long)]
username: String,
#[arg(short, long)]
password: String,
},
}
impl Default for Commands {
fn default() -> Self {
Commands::Run {
address: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
port: 3000,
}
}
}
#[tokio::main]
async fn main() {
let db = Database::connect("sqlite:db.sqlite3?mode=rwc")
let config = Config::parse();
let db = Database::connect(config.database)
.await
.expect("Unable to connect to database");
let config = Config::parse();
match config.command {
Some(Commands::Migrate) => {
match config.command.unwrap_or_default() {
Commands::Migrate => {
Migrator::up(&db, None)
.await
.expect("Unable to apply migrations");
}
Some(Commands::AddAdminUser { username, password }) => {
Commands::AddAdminUser { username, password } => {
create_user(db, username, password, true)
.await
.expect("Unable to add admin user");
}
Some(Commands::Run) | None => {
Commands::Run { address, port } => {
Migrator::up(&db, None)
.await
.expect("Unable to apply migrations");
let app = get_router(db, "files")
let app = get_router(db, config.files_directory)
.await
.expect("Unable to start router");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
let listener = tokio::net::TcpListener::bind((address, port))
.await
.expect("Unable to listen to port");
println!("Listening on http://localhost:3000");
.expect("Unable to bind TCP listener");
if address.is_ipv6() {
println!("Listening on http://[{}]:{}", address, port);
} else {
println!("Listening on http://{}:{}", address, port);
}
axum::serve(listener, app).await.unwrap();
}
}

View file

@ -1148,6 +1148,139 @@ pub(crate) async fn sort_pool(
))
}
// Bulk edit tag views
enum BulkEditTagMessage {
None,
Success,
Failure(String),
}
#[derive(Template)]
#[template(path = "pages/bulk_edit_tag.html")]
struct BulkEditTagTemplate {
application_name: String,
age_confirmation: bool,
message: BulkEditTagMessage,
}
pub(crate) async fn bulk_edit_tag(
State(AppState { app_config, .. }): State<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
if auth_session.user.is_none_or(|user| !user.is_admin) {
return Err(SameyError::Forbidden);
}
let app_config = app_config.read().await;
let application_name = app_config.application_name.clone();
let age_confirmation = app_config.age_confirmation;
drop(app_config);
Ok(Html(
BulkEditTagTemplate {
application_name,
age_confirmation,
message: BulkEditTagMessage::None,
}
.render()?,
))
}
#[derive(Debug, Deserialize)]
pub(crate) struct EditTagForm {
tags: String,
new_tag: String,
}
pub(crate) async fn edit_tag(
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Form(body): Form<EditTagForm>,
) -> Result<impl IntoResponse, SameyError> {
if auth_session.user.is_none_or(|user| !user.is_admin) {
return Err(SameyError::Forbidden);
}
let app_config = app_config.read().await;
let application_name = app_config.application_name.clone();
let age_confirmation = app_config.age_confirmation;
drop(app_config);
let old_tag: Vec<_> = body.tags.split_whitespace().collect();
if old_tag.len() != 1 {
return Ok(Html(
BulkEditTagTemplate {
application_name,
age_confirmation,
message: BulkEditTagMessage::Failure("expected single tag to edit".into()),
}
.render()?,
));
}
let old_tag = old_tag.first().unwrap();
let normalized_old_tag = old_tag.to_lowercase();
let new_tag: Vec<_> = body.new_tag.split_whitespace().collect();
if new_tag.len() != 1 {
return Ok(Html(
BulkEditTagTemplate {
application_name,
age_confirmation,
message: BulkEditTagMessage::Failure("expected single new tag".into()),
}
.render()?,
));
}
let new_tag = new_tag.first().unwrap();
let normalized_new_tag = new_tag.to_lowercase();
let old_tag_db = SameyTag::find()
.filter(samey_tag::Column::NormalizedName.eq(&normalized_old_tag))
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
if let Some(new_tag_db) = SameyTag::find()
.filter(samey_tag::Column::NormalizedName.eq(&normalized_new_tag))
.one(&db)
.await?
{
let subquery = migration::Query::select()
.column((SameyTagPost, samey_tag_post::Column::PostId))
.from(SameyTagPost)
.and_where(samey_tag_post::Column::TagId.eq(new_tag_db.id))
.to_owned();
SameyTagPost::update_many()
.filter(samey_tag_post::Column::TagId.eq(old_tag_db.id))
.filter(samey_tag_post::Column::PostId.not_in_subquery(subquery))
.set(samey_tag_post::ActiveModel {
tag_id: Set(new_tag_db.id),
..Default::default()
})
.exec(&db)
.await?;
SameyTag::delete_by_id(old_tag_db.id).exec(&db).await?;
} else {
SameyTag::update(samey_tag::ActiveModel {
id: Set(old_tag_db.id),
name: Set(new_tag.to_string()),
normalized_name: Set(normalized_new_tag),
})
.exec(&db)
.await?;
}
Ok(Html(
BulkEditTagTemplate {
application_name,
age_confirmation,
message: BulkEditTagMessage::Success,
}
.render()?,
))
}
// Settings views
#[derive(Template)]
@ -1450,6 +1583,7 @@ struct SubmitPostDetailsTemplate {
parent_post: Option<PostOverview>,
sources: Vec<samey_post_source::Model>,
tags: Vec<samey_tag::Model>,
tags_text: String,
can_edit: bool,
}
@ -1580,6 +1714,13 @@ pub(crate) async fn submit_post_details(
upload_tags.sort_by(|a, b| a.name.cmp(&b.name));
upload_tags
};
let mut tags_text = String::new();
for tag in &tags {
if !tags_text.is_empty() {
tags_text.push(' ');
}
tags_text.push_str(&tag.name);
}
let sources = SameyPostSource::find()
.filter(samey_post_source::Column::PostId.eq(post_id))
@ -1591,6 +1732,7 @@ pub(crate) async fn submit_post_details(
post,
sources,
tags,
tags_text,
parent_post,
can_edit: true,
}

View file

@ -26,7 +26,7 @@ parent_post %}
<ul>
{% for tag in tags %}
<li>
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
<a href="/posts?tags={{ tags_text.replace(' ', "+") }}+{{ tag.name }}">+</a> <a href="/posts?tags={{ tags_text.replace(' ', "+") }}+-{{ tag.name }}">-</a> <a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
</li>
{% endfor %}
</ul>

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Bulk edit tag - {{ application_name }}</title>
<meta property="og:site_name" content="{{ application_name }}" />
{% include "fragments/common_headers.html" %}
</head>
<body>
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
%}{% endif %}
<div><a href="/">&lt; To home</a></div>
<main>
<h1>Bulk edit tag</h1>
<article>
<h2>Select tag to edit</h2>
<form method="post" action="/bulk_edit_tag">
{% let tags_value = "" %} {% include "fragments/tags_input.html" %}
<div
hx-trigger="keyup[key=='Escape'] from:previous .tags"
hx-target="next .tags-autocomplete"
hx-swap="innerHTML"
hx-delete="/remove"
hidden
></div>
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
<input type="text" name="new_tag" placeholder="New tag" />
<button type="submit">Submit</button>
{% match message %}{% when BulkEditTagMessage::Success %}
<div>Success!</div>
{% when BulkEditTagMessage::Failure with (msg) %}
<div>Error: {{ msg }}</div>
{% when BulkEditTagMessage::None %}{% endmatch %}
</form>
</article>
</main>
</body>
</html>

View file

@ -41,6 +41,9 @@
<a href="/create_pool">Create pool</a>
</li>
{% if user.is_admin %}
<li>
<a href="/bulk_edit_tag">Bulk edit tag</a>
</li>
<li>
<a href="/settings">Settings</a>
</li>

View file

@ -100,7 +100,11 @@
<ul>
{% for tag in tags %}
<li>
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
{% if let Some(tags_text) = tags_text %}
<a href="/posts?tags={{ tags_text.replace(' ', "+") }}+{{ tag.name }}">+</a> <a href="/posts?tags={{ tags_text.replace(' ', "+") }}+-{{ tag.name }}">-</a> <a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
{% else %}
<a href="/posts?tags={{ tag.name }}">+</a> <a href="/posts?tags=-{{ tag.name }}">-</a> <a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
{% endif %}
</li>
{% endfor %}
</ul>