chore: add category ordering to bookmarks 26ac8a01
Steve · 2026-04-26 07:28 3 file(s) · +101 −4
apps/bookmarks/src/db.rs +74 −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
        )
apps/bookmarks/src/main.rs +21 −0
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))
apps/bookmarks/src/templates/admin.html +6 −0
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>