chore: add category ordering to bookmarks
26ac8a01
3 file(s) · +101 −4
| 8 | 8 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 9 | 9 | short_id TEXT NOT NULL UNIQUE, |
|
| 10 | 10 | name TEXT NOT NULL UNIQUE, |
|
| 11 | + | position INTEGER NOT NULL DEFAULT 0, |
|
| 11 | 12 | created_at INTEGER NOT NULL |
|
| 12 | 13 | ); |
|
| 13 | 14 | ||
| 28 | 29 | pub id: i64, |
|
| 29 | 30 | pub short_id: String, |
|
| 30 | 31 | pub name: String, |
|
| 32 | + | pub position: i64, |
|
| 31 | 33 | } |
|
| 32 | 34 | ||
| 33 | 35 | #[derive(Debug, Clone, Serialize)] |
|
| 38 | 40 | pub url: String, |
|
| 39 | 41 | pub category_id: i64, |
|
| 40 | 42 | pub created_at: i64, |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | pub fn migrate(db: &Db) -> Result<(), DbError> { |
|
| 46 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 47 | + | let has_position: bool = conn |
|
| 48 | + | .prepare("SELECT 1 FROM pragma_table_info('categories') WHERE name = 'position'")? |
|
| 49 | + | .exists([])?; |
|
| 50 | + | if !has_position { |
|
| 51 | + | conn.execute( |
|
| 52 | + | "ALTER TABLE categories ADD COLUMN position INTEGER NOT NULL DEFAULT 0", |
|
| 53 | + | [], |
|
| 54 | + | )?; |
|
| 55 | + | conn.execute( |
|
| 56 | + | "UPDATE categories SET position = id WHERE position = 0", |
|
| 57 | + | [], |
|
| 58 | + | )?; |
|
| 59 | + | } |
|
| 60 | + | Ok(()) |
|
| 41 | 61 | } |
|
| 42 | 62 | ||
| 43 | 63 | pub fn list_categories(db: &Db) -> Result<Vec<Category>, DbError> { |
|
| 44 | 64 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 45 | - | let mut stmt = conn.prepare("SELECT id, short_id, name FROM categories ORDER BY name COLLATE NOCASE")?; |
|
| 65 | + | let mut stmt = conn.prepare( |
|
| 66 | + | "SELECT id, short_id, name, position FROM categories ORDER BY position ASC, name COLLATE NOCASE", |
|
| 67 | + | )?; |
|
| 46 | 68 | let rows = stmt.query_map([], |row| { |
|
| 47 | 69 | Ok(Category { |
|
| 48 | 70 | id: row.get(0)?, |
|
| 49 | 71 | short_id: row.get(1)?, |
|
| 50 | 72 | name: row.get(2)?, |
|
| 73 | + | position: row.get(3)?, |
|
| 51 | 74 | }) |
|
| 52 | 75 | })?; |
|
| 53 | 76 | Ok(rows.collect::<Result<Vec<_>, _>>()?) |
|
| 57 | 80 | let now = chrono::Utc::now().timestamp(); |
|
| 58 | 81 | let short_id = nanoid!(10); |
|
| 59 | 82 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 83 | + | let next_pos: i64 = conn |
|
| 84 | + | .query_row("SELECT COALESCE(MAX(position), 0) + 1 FROM categories", [], |r| r.get(0))?; |
|
| 60 | 85 | conn.execute( |
|
| 61 | - | "INSERT INTO categories (short_id, name, created_at) VALUES (?1, ?2, ?3)", |
|
| 62 | - | params![short_id, name, now], |
|
| 86 | + | "INSERT INTO categories (short_id, name, position, created_at) VALUES (?1, ?2, ?3, ?4)", |
|
| 87 | + | params![short_id, name, next_pos, now], |
|
| 63 | 88 | )?; |
|
| 64 | 89 | Ok(Category { |
|
| 65 | 90 | id: conn.last_insert_rowid(), |
|
| 66 | 91 | short_id, |
|
| 67 | 92 | name: name.to_string(), |
|
| 93 | + | position: next_pos, |
|
| 68 | 94 | }) |
|
| 69 | 95 | } |
|
| 70 | 96 | ||
| 97 | + | pub fn move_category(db: &Db, short_id: &str, direction: i64) -> Result<bool, DbError> { |
|
| 98 | + | let mut conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 99 | + | let tx = conn.transaction()?; |
|
| 100 | + | let current: Option<(i64, i64)> = tx |
|
| 101 | + | .query_row( |
|
| 102 | + | "SELECT id, position FROM categories WHERE short_id = ?1", |
|
| 103 | + | params![short_id], |
|
| 104 | + | |r| Ok((r.get(0)?, r.get(1)?)), |
|
| 105 | + | ) |
|
| 106 | + | .optional()?; |
|
| 107 | + | let Some((cur_id, cur_pos)) = current else { |
|
| 108 | + | return Ok(false); |
|
| 109 | + | }; |
|
| 110 | + | let neighbor: Option<(i64, i64)> = if direction < 0 { |
|
| 111 | + | tx.query_row( |
|
| 112 | + | "SELECT id, position FROM categories WHERE position < ?1 ORDER BY position DESC LIMIT 1", |
|
| 113 | + | params![cur_pos], |
|
| 114 | + | |r| Ok((r.get(0)?, r.get(1)?)), |
|
| 115 | + | ) |
|
| 116 | + | .optional()? |
|
| 117 | + | } else { |
|
| 118 | + | tx.query_row( |
|
| 119 | + | "SELECT id, position FROM categories WHERE position > ?1 ORDER BY position ASC LIMIT 1", |
|
| 120 | + | params![cur_pos], |
|
| 121 | + | |r| Ok((r.get(0)?, r.get(1)?)), |
|
| 122 | + | ) |
|
| 123 | + | .optional()? |
|
| 124 | + | }; |
|
| 125 | + | let Some((nb_id, nb_pos)) = neighbor else { |
|
| 126 | + | return Ok(false); |
|
| 127 | + | }; |
|
| 128 | + | tx.execute( |
|
| 129 | + | "UPDATE categories SET position = ?1 WHERE id = ?2", |
|
| 130 | + | params![nb_pos, cur_id], |
|
| 131 | + | )?; |
|
| 132 | + | tx.execute( |
|
| 133 | + | "UPDATE categories SET position = ?1 WHERE id = ?2", |
|
| 134 | + | params![cur_pos, nb_id], |
|
| 135 | + | )?; |
|
| 136 | + | tx.commit()?; |
|
| 137 | + | Ok(true) |
|
| 138 | + | } |
|
| 139 | + | ||
| 71 | 140 | pub fn delete_category_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 72 | 141 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 73 | 142 | let n = conn.execute("DELETE FROM categories WHERE short_id = ?1", params![short_id])?; |
|
| 78 | 147 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 79 | 148 | let cat = conn |
|
| 80 | 149 | .query_row( |
|
| 81 | - | "SELECT id, short_id, name FROM categories WHERE name = ?1", |
|
| 150 | + | "SELECT id, short_id, name, position FROM categories WHERE name = ?1", |
|
| 82 | 151 | params![name], |
|
| 83 | 152 | |row| { |
|
| 84 | 153 | Ok(Category { |
|
| 85 | 154 | id: row.get(0)?, |
|
| 86 | 155 | short_id: row.get(1)?, |
|
| 87 | 156 | name: row.get(2)?, |
|
| 157 | + | position: row.get(3)?, |
|
| 88 | 158 | }) |
|
| 89 | 159 | }, |
|
| 90 | 160 | ) |
|
| 220 | 220 | Redirect::to("/admin?success=Category+removed").into_response() |
|
| 221 | 221 | } |
|
| 222 | 222 | ||
| 223 | + | async fn admin_move_category( |
|
| 224 | + | _session: auth::AuthSession, |
|
| 225 | + | State(state): State<Arc<AppState>>, |
|
| 226 | + | Path((short_id, dir)): Path<(String, String)>, |
|
| 227 | + | ) -> Response { |
|
| 228 | + | let direction: i64 = match dir.as_str() { |
|
| 229 | + | "up" => -1, |
|
| 230 | + | "down" => 1, |
|
| 231 | + | _ => return Redirect::to("/admin?error=Invalid+direction").into_response(), |
|
| 232 | + | }; |
|
| 233 | + | match db::move_category(&state.db, &short_id, direction) { |
|
| 234 | + | Ok(_) => Redirect::to("/admin?success=Category+reordered").into_response(), |
|
| 235 | + | Err(e) => { |
|
| 236 | + | tracing::error!("move category: {e}"); |
|
| 237 | + | Redirect::to("/admin?error=Failed+to+reorder").into_response() |
|
| 238 | + | } |
|
| 239 | + | } |
|
| 240 | + | } |
|
| 241 | + | ||
| 223 | 242 | #[derive(Deserialize)] |
|
| 224 | 243 | struct AddLinkForm { |
|
| 225 | 244 | title: String, |
|
| 402 | 421 | conn.execute_batch(SESSION_SCHEMA).expect("session schema"); |
|
| 403 | 422 | conn.execute_batch(db::SCHEMA).expect("bookmarks schema"); |
|
| 404 | 423 | let db: Db = Arc::new(Mutex::new(conn)); |
|
| 424 | + | db::migrate(&db).expect("bookmarks migrate"); |
|
| 405 | 425 | ||
| 406 | 426 | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 407 | 427 | .map(|v| v.eq_ignore_ascii_case("true")) |
|
| 429 | 449 | .route("/admin", get(admin_handler)) |
|
| 430 | 450 | .route("/admin/categories", post(admin_add_category)) |
|
| 431 | 451 | .route("/admin/categories/{short_id}/delete", post(admin_delete_category)) |
|
| 452 | + | .route("/admin/categories/{short_id}/move/{dir}", post(admin_move_category)) |
|
| 432 | 453 | .route("/admin/links", post(admin_add_link)) |
|
| 433 | 454 | .route("/admin/links/{short_id}/delete", post(admin_delete_link)) |
|
| 434 | 455 | .route("/static/{*path}", get(static_handler)) |
|
| 56 | 56 | <span class="admin-list-title">{{ cat.name }}</span> |
|
| 57 | 57 | </div> |
|
| 58 | 58 | <div class="admin-list-actions"> |
|
| 59 | + | <form method="POST" action="/admin/categories/{{ cat.short_id }}/move/up" class="inline-form"> |
|
| 60 | + | <button type="submit" class="link-button">↑</button> |
|
| 61 | + | </form> |
|
| 62 | + | <form method="POST" action="/admin/categories/{{ cat.short_id }}/move/down" class="inline-form"> |
|
| 63 | + | <button type="submit" class="link-button">↓</button> |
|
| 64 | + | </form> |
|
| 59 | 65 | <form method="POST" action="/admin/categories/{{ cat.short_id }}/delete" class="inline-form"> |
|
| 60 | 66 | <button type="submit" class="link-button danger">delete</button> |
|
| 61 | 67 | </form> |