Bulk edit tags
This commit is contained in:
parent
8fac396d7e
commit
94269d82f0
8 changed files with 252 additions and 24 deletions
27
README.md
27
README.md
|
|
@ -6,31 +6,36 @@ Sam's small image board.
|
||||||
|
|
||||||
Still very much an early WIP.
|
Still very much an early WIP.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Image and video posts.
|
||||||
|
- Tagging with autocompletion.
|
||||||
|
- Post pools.
|
||||||
|
- RSS feeds.
|
||||||
|
|
||||||
### Known issues
|
### Known issues
|
||||||
|
|
||||||
- [ ] No way to close tag autocompletion on mobile
|
- No way to close tag autocompletion on mobile
|
||||||
|
|
||||||
### Roadmap
|
### Roadmap
|
||||||
|
|
||||||
- [ ] Favicon from post
|
- [ ] Favicon from post
|
||||||
- [ ] Bulk edit tag
|
- [ ] Delete pools
|
||||||
- [ ] RSS feed
|
- [ ] Logging and improved error handling
|
||||||
- [ ] Logging
|
|
||||||
- [ ] Improved error handling
|
|
||||||
- [ ] Caching
|
|
||||||
- [ ] Lossless compression
|
- [ ] Lossless compression
|
||||||
- [ ] User management
|
- [ ] Caching
|
||||||
- [ ] Cleanup/fixup background tasks
|
|
||||||
- [ ] Text media
|
- [ ] Text media
|
||||||
- [ ] Improve CSS
|
- [ ] Improve CSS
|
||||||
|
- [ ] Garbage collection background tasks (tags, pools)
|
||||||
|
- [ ] User management
|
||||||
- [ ] Migrate to Cot...?
|
- [ ] Migrate to Cot...?
|
||||||
|
|
||||||
## Prerequisites
|
## Running
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
- `ffmpeg` and `ffprobe`
|
- `ffmpeg` and `ffprobe`
|
||||||
|
|
||||||
## Running
|
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -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}/post", post(add_post_to_pool))
|
||||||
.route_with_tsr("/pool/{pool_id}/sort", put(sort_pool))
|
.route_with_tsr("/pool/{pool_id}/sort", put(sort_pool))
|
||||||
.route_with_tsr("/pool_post/{pool_post_id}", delete(remove_pool_post))
|
.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
|
// Settings routes
|
||||||
.route_with_tsr("/settings", get(settings).post(update_settings))
|
.route_with_tsr("/settings", get(settings).post(update_settings))
|
||||||
// Search routes
|
// Search routes
|
||||||
|
|
|
||||||
57
src/main.rs
57
src/main.rs
|
|
@ -1,3 +1,8 @@
|
||||||
|
use std::{
|
||||||
|
net::{IpAddr, Ipv6Addr},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use migration::{Migrator, MigratorTrait};
|
use migration::{Migrator, MigratorTrait};
|
||||||
use samey::{create_user, get_router};
|
use samey::{create_user, get_router};
|
||||||
|
|
@ -5,50 +10,80 @@ use sea_orm::Database;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
struct Config {
|
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(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
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,
|
Migrate,
|
||||||
|
|
||||||
AddAdminUser {
|
AddAdminUser {
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
username: String,
|
username: String,
|
||||||
|
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
password: String,
|
password: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Commands {
|
||||||
|
fn default() -> Self {
|
||||||
|
Commands::Run {
|
||||||
|
address: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||||
|
port: 3000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let db = Database::connect("sqlite:db.sqlite3?mode=rwc")
|
let config = Config::parse();
|
||||||
|
let db = Database::connect(config.database)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to connect to database");
|
.expect("Unable to connect to database");
|
||||||
let config = Config::parse();
|
match config.command.unwrap_or_default() {
|
||||||
match config.command {
|
Commands::Migrate => {
|
||||||
Some(Commands::Migrate) => {
|
|
||||||
Migrator::up(&db, None)
|
Migrator::up(&db, None)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to apply migrations");
|
.expect("Unable to apply migrations");
|
||||||
}
|
}
|
||||||
Some(Commands::AddAdminUser { username, password }) => {
|
|
||||||
|
Commands::AddAdminUser { username, password } => {
|
||||||
create_user(db, username, password, true)
|
create_user(db, username, password, true)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to add admin user");
|
.expect("Unable to add admin user");
|
||||||
}
|
}
|
||||||
Some(Commands::Run) | None => {
|
|
||||||
|
Commands::Run { address, port } => {
|
||||||
Migrator::up(&db, None)
|
Migrator::up(&db, None)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to apply migrations");
|
.expect("Unable to apply migrations");
|
||||||
let app = get_router(db, "files")
|
let app = get_router(db, config.files_directory)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to start router");
|
.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
|
.await
|
||||||
.expect("Unable to listen to port");
|
.expect("Unable to bind TCP listener");
|
||||||
println!("Listening on http://localhost:3000");
|
if address.is_ipv6() {
|
||||||
|
println!("Listening on http://[{}]:{}", address, port);
|
||||||
|
} else {
|
||||||
|
println!("Listening on http://{}:{}", address, port);
|
||||||
|
}
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
src/views.rs
142
src/views.rs
|
|
@ -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
|
// Settings views
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -1450,6 +1583,7 @@ struct SubmitPostDetailsTemplate {
|
||||||
parent_post: Option<PostOverview>,
|
parent_post: Option<PostOverview>,
|
||||||
sources: Vec<samey_post_source::Model>,
|
sources: Vec<samey_post_source::Model>,
|
||||||
tags: Vec<samey_tag::Model>,
|
tags: Vec<samey_tag::Model>,
|
||||||
|
tags_text: String,
|
||||||
can_edit: bool,
|
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.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
upload_tags
|
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()
|
let sources = SameyPostSource::find()
|
||||||
.filter(samey_post_source::Column::PostId.eq(post_id))
|
.filter(samey_post_source::Column::PostId.eq(post_id))
|
||||||
|
|
@ -1591,6 +1732,7 @@ pub(crate) async fn submit_post_details(
|
||||||
post,
|
post,
|
||||||
sources,
|
sources,
|
||||||
tags,
|
tags,
|
||||||
|
tags_text,
|
||||||
parent_post,
|
parent_post,
|
||||||
can_edit: true,
|
can_edit: true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ parent_post %}
|
||||||
<ul>
|
<ul>
|
||||||
{% for tag in tags %}
|
{% for tag in tags %}
|
||||||
<li>
|
<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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
37
templates/pages/bulk_edit_tag.html
Normal file
37
templates/pages/bulk_edit_tag.html
Normal 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="/">< 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>
|
||||||
|
|
@ -41,6 +41,9 @@
|
||||||
<a href="/create_pool">Create pool</a>
|
<a href="/create_pool">Create pool</a>
|
||||||
</li>
|
</li>
|
||||||
{% if user.is_admin %}
|
{% if user.is_admin %}
|
||||||
|
<li>
|
||||||
|
<a href="/bulk_edit_tag">Bulk edit tag</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,11 @@
|
||||||
<ul>
|
<ul>
|
||||||
{% for tag in tags %}
|
{% for tag in tags %}
|
||||||
<li>
|
<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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue