this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: starboard

isabel 23830de8 b80c842e

+904 -372
-2
.env.example
··· 1 1 DISCORD_TOKEN=discord token 2 2 GITHUB_TOKEN=github token 3 - NIXPKGS_DB=/var/lib/blahaj/packages.db 4 - NIXPKGS_HASH_FILE=/var/lib/blahaj/nixpkgs.hash
+2 -3
.gitignore
··· 3 3 result* 4 4 target 5 5 /.idea 6 - nixpkgs.db 7 - nixpkgs.hash 8 - color_me.db 6 + # Database files (stored in /var/lib/blahaj for systemd services) 7 + /var/lib/blahaj/
+49
Cargo.lock
··· 211 211 "brotli", 212 212 "chrono", 213 213 "color-eyre", 214 + "directories", 214 215 "dotenv", 215 216 "humantime", 216 217 "kittysay", ··· 645 646 dependencies = [ 646 647 "block-buffer", 647 648 "crypto-common", 649 + ] 650 + 651 + [[package]] 652 + name = "directories" 653 + version = "5.0.1" 654 + source = "registry+https://github.com/rust-lang/crates.io-index" 655 + checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" 656 + dependencies = [ 657 + "dirs-sys", 658 + ] 659 + 660 + [[package]] 661 + name = "dirs-sys" 662 + version = "0.4.1" 663 + source = "registry+https://github.com/rust-lang/crates.io-index" 664 + checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 665 + dependencies = [ 666 + "libc", 667 + "option-ext", 668 + "redox_users", 669 + "windows-sys 0.48.0", 648 670 ] 649 671 650 672 [[package]] ··· 1450 1472 checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" 1451 1473 1452 1474 [[package]] 1475 + name = "libredox" 1476 + version = "0.1.12" 1477 + source = "registry+https://github.com/rust-lang/crates.io-index" 1478 + checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" 1479 + dependencies = [ 1480 + "bitflags 2.10.0", 1481 + "libc", 1482 + ] 1483 + 1484 + [[package]] 1453 1485 name = "libsqlite3-sys" 1454 1486 version = "0.35.0" 1455 1487 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1667 1699 "pkg-config", 1668 1700 "vcpkg", 1669 1701 ] 1702 + 1703 + [[package]] 1704 + name = "option-ext" 1705 + version = "0.2.0" 1706 + source = "registry+https://github.com/rust-lang/crates.io-index" 1707 + checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 1670 1708 1671 1709 [[package]] 1672 1710 name = "owo-colors" ··· 1964 2002 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1965 2003 dependencies = [ 1966 2004 "bitflags 2.10.0", 2005 + ] 2006 + 2007 + [[package]] 2008 + name = "redox_users" 2009 + version = "0.4.6" 2010 + source = "registry+https://github.com/rust-lang/crates.io-index" 2011 + checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 2012 + dependencies = [ 2013 + "getrandom 0.2.16", 2014 + "libredox", 2015 + "thiserror 1.0.69", 1967 2016 ] 1968 2017 1969 2018 [[package]]
+1
Cargo.toml
··· 14 14 bottomify = "1.2.0" 15 15 chrono = "0.4" 16 16 color-eyre = "0.6.5" 17 + directories = "5.0" 17 18 dotenv = "0.15.0" 18 19 humantime = "2.3.0" 19 20 kittysay = "0.8.0"
-2
README.md
··· 39 39 | ------- | -------- | ----------- | 40 40 | `DISCORD_TOKEN` | No | The token for the Discord bot that you just created. | 41 41 | `GITHUB_TOKEN` | No | Github API token. | 42 - | `NIXPKGS_DB` | Yes | Path to a nixpkgs database file | 43 - | `NIXPKGS_HASH_FILE` | Yes | Path to a nixpkgs hash file | 44 42 45 43 Then run: 46 44
+2
src/commands/misc/mod.rs
··· 1 1 pub mod crates; 2 + pub mod starboard; 3 +
+129
src/commands/misc/starboard.rs
··· 1 + use crate::types::Context; 2 + use color_eyre::eyre::Result; 3 + use poise::CreateReply; 4 + use poise::serenity_prelude::ChannelId; 5 + use rusqlite::Connection; 6 + use std::sync::{LazyLock, Mutex}; 7 + 8 + static STARBOARD_DB: LazyLock<Mutex<Connection>> = LazyLock::new(|| { 9 + let db_path = crate::utils::get_data_dir().join("starboard.db"); 10 + Mutex::new(Connection::open(db_path).expect("Failed to open starboard database")) 11 + }); 12 + 13 + /// Enable the starboard feature for this server 14 + #[poise::command(slash_command, required_permissions = "ADMINISTRATOR")] 15 + pub async fn starboard_enable( 16 + ctx: Context<'_>, 17 + #[description = "Channel to post starred messages to"] channel: ChannelId, 18 + #[description = "Number of stars required to appear on starboard (default: 3)"] 19 + threshold: Option<i32>, 20 + ) -> Result<()> { 21 + let Some(guild_id) = ctx.guild_id() else { 22 + ctx.send( 23 + CreateReply::default() 24 + .content("This command can only be used in a server.") 25 + .ephemeral(true), 26 + ) 27 + .await?; 28 + return Ok(()); 29 + }; 30 + 31 + let threshold = threshold.unwrap_or(3).max(1).min(100); 32 + 33 + { 34 + let conn = STARBOARD_DB.lock().unwrap(); 35 + conn.execute( 36 + "INSERT OR REPLACE INTO starboard_config (guild_id, channel_id, threshold) VALUES (?, ?, ?)", 37 + [guild_id.get() as i64, channel.get() as i64, i64::from(threshold)], 38 + )?; 39 + } 40 + 41 + ctx.send( 42 + CreateReply::default() 43 + .content(format!( 44 + "✅ Starboard enabled for <#{channel}>! Messages with {threshold} or more ⭐ will be posted." 45 + )) 46 + .ephemeral(true), 47 + ) 48 + .await?; 49 + 50 + Ok(()) 51 + } 52 + 53 + /// Disable the starboard feature for this server 54 + #[poise::command(slash_command, required_permissions = "ADMINISTRATOR")] 55 + pub async fn starboard_disable(ctx: Context<'_>) -> Result<()> { 56 + let Some(guild_id) = ctx.guild_id() else { 57 + ctx.send( 58 + CreateReply::default() 59 + .content("This command can only be used in a server.") 60 + .ephemeral(true), 61 + ) 62 + .await?; 63 + return Ok(()); 64 + }; 65 + 66 + { 67 + let conn = STARBOARD_DB.lock().unwrap(); 68 + conn.execute( 69 + "DELETE FROM starboard_config WHERE guild_id = ?", 70 + [guild_id.get() as i64], 71 + )?; 72 + } 73 + 74 + ctx.send( 75 + CreateReply::default() 76 + .content("✅ Starboard disabled for this server.") 77 + .ephemeral(true), 78 + ) 79 + .await?; 80 + 81 + Ok(()) 82 + } 83 + 84 + /// Check the starboard configuration 85 + #[poise::command(slash_command)] 86 + pub async fn starboard_config(ctx: Context<'_>) -> Result<()> { 87 + let Some(guild_id) = ctx.guild_id() else { 88 + ctx.send( 89 + CreateReply::default() 90 + .content("This command can only be used in a server.") 91 + .ephemeral(true), 92 + ) 93 + .await?; 94 + return Ok(()); 95 + }; 96 + 97 + let config: Option<(u64, i32)> = { 98 + let conn = STARBOARD_DB.lock().unwrap(); 99 + conn 100 + .query_row( 101 + "SELECT channel_id, threshold FROM starboard_config WHERE guild_id = ?", 102 + [guild_id.get() as i64], 103 + |row| { 104 + let channel_id: i64 = row.get(0)?; 105 + let threshold: i32 = row.get(1)?; 106 + Ok((channel_id as u64, threshold)) 107 + }, 108 + ) 109 + .ok() 110 + }; 111 + 112 + let response = if let Some((channel_id, threshold)) = config { 113 + format!( 114 + "⭐ **Starboard Configuration**\n- **Channel**: <#{channel_id}>\n- **Threshold**: {threshold} stars" 115 + ) 116 + } else { 117 + "⭐ Starboard is not configured for this server. Use `/starboard_enable` to enable it." 118 + .to_string() 119 + }; 120 + 121 + ctx.send( 122 + CreateReply::default() 123 + .content(response) 124 + .ephemeral(true), 125 + ) 126 + .await?; 127 + 128 + Ok(()) 129 + }
+1 -1
src/commands/nix/nixpkg.rs
··· 5 5 use std::sync::Mutex; 6 6 7 7 static DB: std::sync::LazyLock<Mutex<Connection>> = std::sync::LazyLock::new(|| { 8 - let db_path = std::env::var("NIXPKGS_DB").unwrap_or_else(|_| "nixpkgs.db".to_string()); 8 + let db_path = crate::utils::get_data_dir().join("packages.db"); 9 9 Mutex::new(Connection::open(db_path).expect("Failed to open database")) 10 10 }); 11 11
+17 -17
src/commands/user/color_me.rs
··· 6 6 use std::sync::{LazyLock, Mutex}; 7 7 8 8 static COLOR_DB: LazyLock<Mutex<Connection>> = LazyLock::new(|| { 9 - let db_path = std::env::var("COLOR_ROLES_DB").unwrap_or_else(|_| "color_roles.db".to_string()); 9 + let db_path = crate::utils::get_data_dir().join("color_roles.db"); 10 10 let conn = Connection::open(db_path).expect("Failed to open color roles database"); 11 11 12 12 conn.execute( ··· 133 133 .await?; 134 134 135 135 if let Some(new_name) = &role_name { 136 - if new_name != &current_name { 136 + if new_name == &current_name { 137 + ctx.send( 138 + CreateReply::default() 139 + .content(format!("Updated your color to `#{color_str}`! <3")) 140 + .ephemeral(true), 141 + ) 142 + .await?; 143 + } else { 137 144 guild_id 138 145 .edit_role(ctx.http(), role_id, EditRole::new().name(new_name)) 139 146 .await?; ··· 142 149 ctx.send( 143 150 CreateReply::default() 144 151 .content(format!("Updated your color to `#{color_str}` and renamed your role to `{new_name}`! <3")) 145 - .ephemeral(true), 146 - ) 147 - .await?; 148 - } else { 149 - ctx.send( 150 - CreateReply::default() 151 - .content(format!("Updated your color to `#{color_str}`! <3")) 152 152 .ephemeral(true), 153 153 ) 154 154 .await?; ··· 194 194 None => { 195 195 if let Some(new_name) = role_name { 196 196 if let Some((role_id, current_name)) = existing_role { 197 - if new_name != current_name { 197 + if new_name == current_name { 198 + ctx.send( 199 + CreateReply::default() 200 + .content("Your role already has that name!") 201 + .ephemeral(true), 202 + ) 203 + .await?; 204 + } else { 198 205 guild_id 199 206 .edit_role(ctx.http(), role_id, EditRole::new().name(&new_name)) 200 207 .await?; ··· 203 210 ctx.send( 204 211 CreateReply::default() 205 212 .content(format!("Renamed your role to `{new_name}`! <3")) 206 - .ephemeral(true), 207 - ) 208 - .await?; 209 - } else { 210 - ctx.send( 211 - CreateReply::default() 212 - .content("Your role already has that name!") 213 213 .ephemeral(true), 214 214 ) 215 215 .await?;
+2 -1
src/event_handler/mod.rs
··· 4 4 mod blahaj_is_this_true; 5 5 mod code_expantion; 6 6 mod replace_link; 7 + mod starboard; 7 8 8 9 use crate::types::Data; 9 10 10 - // TODO: add starboard 11 11 pub async fn event_handler(ctx: &Context, event: &FullEvent, data: &Data) -> Result<()> { 12 12 if let FullEvent::Ready { data_about_bot } = event { 13 13 println!("Logged in as {}", data_about_bot.user.name); ··· 16 16 code_expantion::handle(ctx, event, data).await?; 17 17 replace_link::handle(ctx, event, data).await?; 18 18 blahaj_is_this_true::handle(ctx, event, data).await?; 19 + starboard::handle(ctx, event, data).await?; 19 20 20 21 Ok(()) 21 22 }
+291
src/event_handler/starboard.rs
··· 1 + use color_eyre::eyre::Result; 2 + use poise::serenity_prelude::{Context, FullEvent, ReactionType, Colour, EditMessage}; 3 + use rusqlite::Connection; 4 + use std::sync::{LazyLock, Mutex}; 5 + 6 + use crate::types::Data; 7 + 8 + static STARBOARD_DB: LazyLock<Mutex<Connection>> = LazyLock::new(|| { 9 + let db_path = crate::utils::get_data_dir().join("starboard.db"); 10 + let conn = Connection::open(db_path).expect("Failed to open starboard database"); 11 + 12 + conn.execute( 13 + "CREATE TABLE IF NOT EXISTS starred_messages ( 14 + message_id INTEGER PRIMARY KEY, 15 + guild_id INTEGER NOT NULL, 16 + channel_id INTEGER NOT NULL, 17 + starboard_message_id INTEGER, 18 + star_count INTEGER NOT NULL DEFAULT 1, 19 + UNIQUE(message_id) 20 + )", 21 + [], 22 + ) 23 + .expect("Failed to create starred_messages table"); 24 + 25 + conn.execute( 26 + "CREATE TABLE IF NOT EXISTS starboard_config ( 27 + guild_id INTEGER PRIMARY KEY, 28 + channel_id INTEGER NOT NULL, 29 + threshold INTEGER NOT NULL DEFAULT 3 30 + )", 31 + [], 32 + ) 33 + .expect("Failed to create starboard_config table"); 34 + 35 + Mutex::new(conn) 36 + }); 37 + 38 + pub async fn handle(ctx: &Context, event: &FullEvent, _data: &Data) -> Result<()> { 39 + match event { 40 + FullEvent::ReactionAdd { add_reaction } => { 41 + handle_reaction_add(ctx, add_reaction).await?; 42 + } 43 + FullEvent::ReactionRemove { removed_reaction } => { 44 + handle_reaction_remove(ctx, removed_reaction).await?; 45 + } 46 + _ => {} 47 + } 48 + 49 + Ok(()) 50 + } 51 + 52 + async fn handle_reaction_add( 53 + ctx: &Context, 54 + reaction: &poise::serenity_prelude::Reaction, 55 + ) -> Result<()> { 56 + // Only handle star reactions 57 + if reaction.emoji != ReactionType::Unicode("⭐".to_string()) { 58 + return Ok(()); 59 + } 60 + 61 + let Some(guild_id) = reaction.guild_id else { 62 + return Ok(()); 63 + }; 64 + 65 + // Get starboard config 66 + let config: Option<(u64, i32)> = { 67 + let conn = STARBOARD_DB.lock().unwrap(); 68 + conn.query_row( 69 + "SELECT channel_id, threshold FROM starboard_config WHERE guild_id = ?", 70 + [guild_id.get() as i64], 71 + |row| { 72 + let channel_id: i64 = row.get(0)?; 73 + let threshold: i32 = row.get(1)?; 74 + Ok((channel_id as u64, threshold)) 75 + }, 76 + ) 77 + .ok() 78 + }; 79 + 80 + let Some((starboard_channel_id, threshold)) = config else { 81 + return Ok(()); 82 + }; 83 + 84 + // Get the message 85 + let message = reaction.channel_id.message(ctx, reaction.message_id).await?; 86 + 87 + // Count star reactions 88 + let star_count = message 89 + .reactions 90 + .iter() 91 + .find(|r| r.reaction_type == ReactionType::Unicode("⭐".to_string())) 92 + .map_or(0, |r| r.count); 93 + 94 + if star_count < threshold as u64 { 95 + return Ok(()); 96 + } 97 + 98 + // Check if already starred 99 + let already_starred: bool = { 100 + let conn = STARBOARD_DB.lock().unwrap(); 101 + conn.query_row( 102 + "SELECT COUNT(*) FROM starred_messages WHERE message_id = ?", 103 + [reaction.message_id.get() as i64], 104 + |row| { 105 + let count: i32 = row.get(0)?; 106 + Ok(count > 0) 107 + }, 108 + ) 109 + .unwrap_or(false) 110 + }; 111 + 112 + if already_starred { 113 + // Update the star count 114 + { 115 + let conn = STARBOARD_DB.lock().unwrap(); 116 + conn.execute( 117 + "UPDATE starred_messages SET star_count = ? WHERE message_id = ?", 118 + [star_count as i64, reaction.message_id.get() as i64], 119 + ) 120 + .ok(); 121 + } 122 + 123 + // Update the starboard message if it exists 124 + let starboard_msg_id: Option<i64> = { 125 + let conn = STARBOARD_DB.lock().unwrap(); 126 + conn.query_row( 127 + "SELECT starboard_message_id FROM starred_messages WHERE message_id = ?", 128 + [reaction.message_id.get() as i64], 129 + |row| row.get::<_, i64>(0), 130 + ) 131 + .ok() 132 + }; 133 + 134 + if let Some(starboard_msg_id) = starboard_msg_id 135 + && let Ok(mut starboard_msg) = poise::serenity_prelude::ChannelId::new(starboard_channel_id) 136 + .message(ctx, poise::serenity_prelude::MessageId::new(starboard_msg_id as u64)) 137 + .await 138 + { 139 + let embed = create_star_embed(&message, star_count as i32); 140 + starboard_msg.edit(ctx, EditMessage::new().embed(embed)).await.ok(); 141 + } 142 + 143 + return Ok(()); 144 + } 145 + 146 + // Create starboard message 147 + let starboard_channel = poise::serenity_prelude::ChannelId::new(starboard_channel_id); 148 + let embed = create_star_embed(&message, star_count as i32); 149 + 150 + if let Ok(starboard_msg) = starboard_channel 151 + .send_message( 152 + ctx, 153 + poise::serenity_prelude::CreateMessage::new() 154 + .embed(embed) 155 + .content(format!("<#{}>", reaction.channel_id)), 156 + ) 157 + .await 158 + { 159 + // Save to database 160 + let conn = STARBOARD_DB.lock().unwrap(); 161 + conn.execute( 162 + "INSERT INTO starred_messages (message_id, guild_id, channel_id, starboard_message_id, star_count) VALUES (?, ?, ?, ?, ?)", 163 + [ 164 + reaction.message_id.get() as i64, 165 + guild_id.get() as i64, 166 + reaction.channel_id.get() as i64, 167 + starboard_msg.id.get() as i64, 168 + star_count as i64, 169 + ], 170 + ).ok(); 171 + } 172 + 173 + Ok(()) 174 + } 175 + 176 + async fn handle_reaction_remove( 177 + ctx: &Context, 178 + reaction: &poise::serenity_prelude::Reaction, 179 + ) -> Result<()> { 180 + // Only handle star reactions 181 + if reaction.emoji != ReactionType::Unicode("⭐".to_string()) { 182 + return Ok(()); 183 + } 184 + 185 + let Some(guild_id) = reaction.guild_id else { 186 + return Ok(()); 187 + }; 188 + 189 + // Get starboard config 190 + let config: Option<(u64, i32)> = { 191 + let conn = STARBOARD_DB.lock().unwrap(); 192 + conn.query_row( 193 + "SELECT channel_id, threshold FROM starboard_config WHERE guild_id = ?", 194 + [guild_id.get() as i64], 195 + |row| { 196 + let channel_id: i64 = row.get(0)?; 197 + let threshold: i32 = row.get(1)?; 198 + Ok((channel_id as u64, threshold)) 199 + }, 200 + ) 201 + .ok() 202 + }; 203 + 204 + let Some((starboard_channel_id, threshold)) = config else { 205 + return Ok(()); 206 + }; 207 + 208 + // Get the message 209 + let message = reaction.channel_id.message(ctx, reaction.message_id).await?; 210 + 211 + // Count star reactions 212 + let star_count = message 213 + .reactions 214 + .iter() 215 + .find(|r| r.reaction_type == ReactionType::Unicode("⭐".to_string())) 216 + .map_or(0, |r| r.count); 217 + 218 + // Check if in starboard 219 + let starboard_msg_id: Option<i64> = { 220 + let conn = STARBOARD_DB.lock().unwrap(); 221 + conn.query_row( 222 + "SELECT starboard_message_id FROM starred_messages WHERE message_id = ?", 223 + [reaction.message_id.get() as i64], 224 + |row| row.get(0), 225 + ) 226 + .ok() 227 + }; 228 + 229 + let Some(starboard_msg_id) = starboard_msg_id else { 230 + return Ok(()); 231 + }; 232 + 233 + if star_count < threshold as u64 { 234 + // Remove from starboard 235 + poise::serenity_prelude::ChannelId::new(starboard_channel_id) 236 + .delete_message(ctx, poise::serenity_prelude::MessageId::new(starboard_msg_id as u64)) 237 + .await 238 + .ok(); 239 + 240 + let conn = STARBOARD_DB.lock().unwrap(); 241 + conn.execute( 242 + "DELETE FROM starred_messages WHERE message_id = ?", 243 + [reaction.message_id.get() as i64], 244 + ) 245 + .ok(); 246 + } else { 247 + // Update star count 248 + { 249 + let conn = STARBOARD_DB.lock().unwrap(); 250 + conn.execute( 251 + "UPDATE starred_messages SET star_count = ? WHERE message_id = ?", 252 + [star_count as i64, reaction.message_id.get() as i64], 253 + ) 254 + .ok(); 255 + } 256 + 257 + // Update the starboard message 258 + if let Ok(mut starboard_msg) = poise::serenity_prelude::ChannelId::new(starboard_channel_id) 259 + .message(ctx, poise::serenity_prelude::MessageId::new(starboard_msg_id as u64)) 260 + .await 261 + { 262 + let embed = create_star_embed(&message, star_count as i32); 263 + starboard_msg.edit(ctx, EditMessage::new().embed(embed)).await.ok(); 264 + } 265 + } 266 + 267 + Ok(()) 268 + } 269 + 270 + fn create_star_embed( 271 + message: &poise::serenity_prelude::Message, 272 + star_count: i32, 273 + ) -> poise::serenity_prelude::CreateEmbed { 274 + let mut embed = poise::serenity_prelude::CreateEmbed::default() 275 + .author( 276 + poise::serenity_prelude::CreateEmbedAuthor::new(&message.author.name) 277 + .icon_url(message.author.face()), 278 + ) 279 + .description(&message.content) 280 + .footer( 281 + poise::serenity_prelude::CreateEmbedFooter::new(format!("⭐ {star_count}")), 282 + ) 283 + .colour(Colour::GOLD) 284 + .timestamp(message.timestamp); 285 + 286 + if let Some(first_attachment) = message.attachments.first() { 287 + embed = embed.image(&first_attachment.url); 288 + } 289 + 290 + embed 291 + }
+8 -346
src/main.rs
··· 1 1 mod commands; 2 2 mod event_handler; 3 3 mod types; 4 + mod utils; 5 + mod nixpkgs_db; 4 6 5 7 use dotenv::dotenv; 6 - use std::{env, path::Path}; 8 + use std::env; 7 9 8 10 use color_eyre::eyre::Result; 9 11 use poise::serenity_prelude::{ 10 12 ActivityData, ChannelId, ClientBuilder, CreateAttachment, CreateMessage, GatewayIntents, 11 13 }; 12 - use sha2::{Digest, Sha256}; 13 - 14 - #[derive(Debug)] 15 - struct NixpkgsRelease { 16 - url: String, 17 - hash: String, 18 - } 19 - 20 - async fn get_latest_nixpkgs_release() -> Result<NixpkgsRelease> { 21 - let base_url = env::var("NIXPKGS_CHANNEL") 22 - .unwrap_or_else(|_| "https://channels.nixos.org/nixpkgs-unstable".to_string()); 23 - 24 - let response = reqwest::get(&base_url).await?; 25 - let html = response.text().await?; 26 - 27 - let url_regex = 28 - regex::Regex::new(r"<a href='([^']+/packages\.json\.br)'>packages\.json\.br</a>")?; 29 - let hash_regex = regex::Regex::new( 30 - r"packages\.json\.br</a></td><td align='right'>\d+</td><td><tt>([a-f0-9]{64})</tt>", 31 - )?; 32 - 33 - let url = url_regex 34 - .captures(&html) 35 - .and_then(|cap| cap.get(1)) 36 - .map(|m| { 37 - let path = m.as_str(); 38 - if path.starts_with("http") { 39 - path.to_string() 40 - } else if path.starts_with('/') { 41 - format!("https://releases.nixos.org{path}") 42 - } else { 43 - format!("https://releases.nixos.org/{path}") 44 - } 45 - }) 46 - .ok_or_else(|| color_eyre::eyre::eyre!("Could not find packages.json.br URL"))?; 47 - 48 - let hash = hash_regex 49 - .captures(&html) 50 - .and_then(|cap| cap.get(1)) 51 - .map(|m| m.as_str().to_string()) 52 - .ok_or_else(|| color_eyre::eyre::eyre!("Could not find packages.json.br hash"))?; 53 - 54 - Ok(NixpkgsRelease { url, hash }) 55 - } 56 - 57 - fn get_stored_hash() -> Option<String> { 58 - let hash_path = env::var("NIXPKGS_HASH_FILE").unwrap_or_else(|_| "nixpkgs.hash".to_string()); 59 - std::fs::read_to_string(hash_path).ok() 60 - } 61 - 62 - fn store_hash(hash: &str) -> Result<()> { 63 - let hash_path = env::var("NIXPKGS_HASH_FILE").unwrap_or_else(|_| "nixpkgs.hash".to_string()); 64 - std::fs::write(hash_path, hash)?; 65 - Ok(()) 66 - } 67 - 68 - #[allow(clippy::too_many_lines)] 69 - async fn ensure_nixpkgs_database() -> Result<()> { 70 - let db_path = env::var("NIXPKGS_DB").unwrap_or_else(|_| "nixpkgs.db".to_string()); 71 - 72 - println!("Checking for nixpkgs updates..."); 73 - let release = get_latest_nixpkgs_release().await?; 74 - let stored_hash = get_stored_hash(); 75 - 76 - if Path::new(&db_path).exists() && stored_hash.as_deref() == Some(&release.hash) { 77 - println!("nixpkgs database is up to date"); 78 - return Ok(()); 79 - } 80 - 81 - if Path::new(&db_path).exists() { 82 - println!("New nixpkgs release detected, updating database..."); 83 - std::fs::remove_file(&db_path)?; 84 - } else { 85 - println!("nixpkgs database not found, building..."); 86 - } 87 - 88 - println!("Downloading from {}...", release.url); 89 - let response = reqwest::get(&release.url).await?; 90 - let compressed = response.bytes().await?; 91 - 92 - let mut hasher = Sha256::new(); 93 - hasher.update(&compressed); 94 - let computed_hash = format!("{:x}", hasher.finalize()); 95 - 96 - if computed_hash != release.hash { 97 - return Err(color_eyre::eyre::eyre!( 98 - "Hash mismatch! Expected {}, got {}", 99 - release.hash, 100 - computed_hash 101 - )); 102 - } 103 - 104 - println!("Hash verified, decompressing..."); 105 - let mut decompressed = Vec::new(); 106 - let mut decoder = brotli::Decompressor::new(compressed.as_ref(), 4096); 107 - std::io::copy(&mut decoder, &mut decompressed)?; 108 - 109 - println!("Parsing JSON..."); 110 - let json_data: serde_json::Value = serde_json::from_slice(&decompressed)?; 111 - 112 - let packages = json_data["packages"] 113 - .as_object() 114 - .ok_or_else(|| color_eyre::eyre::eyre!("Invalid packages.json format"))?; 115 - 116 - println!("Creating database with {} packages...", packages.len()); 117 - 118 - let mut conn = rusqlite::Connection::open(&db_path)?; 119 - 120 - conn.execute( 121 - "CREATE TABLE packages ( 122 - package_name TEXT PRIMARY KEY, 123 - pname TEXT, 124 - version TEXT, 125 - name TEXT, 126 - system TEXT, 127 - output_name TEXT, 128 - available INTEGER, 129 - broken INTEGER, 130 - description TEXT, 131 - homepage TEXT, 132 - insecure INTEGER, 133 - unfree INTEGER, 134 - unsupported INTEGER, 135 - position TEXT, 136 - long_description TEXT, 137 - main_program TEXT, 138 - license_spdx_id TEXT, 139 - license_full_name TEXT, 140 - license_free INTEGER, 141 - license_url TEXT 142 - )", 143 - [], 144 - )?; 145 - 146 - conn.execute( 147 - "CREATE TABLE maintainers ( 148 - id INTEGER PRIMARY KEY AUTOINCREMENT, 149 - package_name TEXT, 150 - name TEXT, 151 - email TEXT, 152 - github TEXT, 153 - github_id INTEGER, 154 - matrix TEXT, 155 - FOREIGN KEY (package_name) REFERENCES packages(package_name) 156 - )", 157 - [], 158 - )?; 159 - 160 - conn.execute( 161 - "CREATE INDEX idx_package_name ON packages(package_name)", 162 - [], 163 - )?; 164 - conn.execute("CREATE INDEX idx_pname ON packages(pname)", [])?; 165 - conn.execute( 166 - "CREATE INDEX idx_maintainers_package ON maintainers(package_name)", 167 - [], 168 - )?; 169 - 170 - let total = packages.len(); 171 - let mut count = 0; 172 - let batch_size = 1000; 173 - 174 - let mut package_batch = Vec::new(); 175 - let mut maintainer_batch = Vec::new(); 176 - 177 - for (pkg_name, pkg_data) in packages { 178 - let meta = &pkg_data["meta"]; 179 - let license_data = &meta["license"]; 180 - 181 - let license_spdx = match license_data { 182 - serde_json::Value::Object(obj) => obj 183 - .get("spdxId") 184 - .and_then(|v| v.as_str()) 185 - .map(std::string::ToString::to_string), 186 - serde_json::Value::Array(arr) => { 187 - let ids: Vec<&str> = arr 188 - .iter() 189 - .filter_map(|v| v.get("spdxId")) 190 - .filter_map(|v| v.as_str()) 191 - .collect(); 192 - if ids.is_empty() { 193 - None 194 - } else { 195 - Some(ids.join(", ")) 196 - } 197 - } 198 - serde_json::Value::String(s) => Some(s.clone()), 199 - _ => None, 200 - }; 201 - 202 - let homepage = meta.get("homepage").and_then(|h| match h { 203 - serde_json::Value::String(s) => Some(s.as_str()), 204 - serde_json::Value::Array(arr) => arr.first().and_then(|v| v.as_str()), 205 - _ => None, 206 - }); 207 - 208 - package_batch.push(( 209 - pkg_name.clone(), 210 - pkg_data 211 - .get("pname") 212 - .and_then(|v| v.as_str()) 213 - .map(std::string::ToString::to_string), 214 - pkg_data 215 - .get("version") 216 - .and_then(|v| v.as_str()) 217 - .map(std::string::ToString::to_string), 218 - pkg_data 219 - .get("name") 220 - .and_then(|v| v.as_str()) 221 - .map(std::string::ToString::to_string), 222 - pkg_data 223 - .get("system") 224 - .and_then(|v| v.as_str()) 225 - .map(std::string::ToString::to_string), 226 - pkg_data 227 - .get("outputName") 228 - .and_then(|v| v.as_str()) 229 - .map(std::string::ToString::to_string), 230 - i32::from( 231 - meta.get("available") 232 - .and_then(serde_json::Value::as_bool) 233 - .unwrap_or(false), 234 - ), 235 - i32::from( 236 - meta.get("broken") 237 - .and_then(serde_json::Value::as_bool) 238 - .unwrap_or(false), 239 - ), 240 - meta.get("description") 241 - .and_then(|v| v.as_str()) 242 - .map(std::string::ToString::to_string), 243 - homepage.map(std::string::ToString::to_string), 244 - i32::from( 245 - meta.get("insecure") 246 - .and_then(serde_json::Value::as_bool) 247 - .unwrap_or(false), 248 - ), 249 - i32::from( 250 - meta.get("unfree") 251 - .and_then(serde_json::Value::as_bool) 252 - .unwrap_or(false), 253 - ), 254 - i32::from( 255 - meta.get("unsupported") 256 - .and_then(serde_json::Value::as_bool) 257 - .unwrap_or(false), 258 - ), 259 - meta.get("position") 260 - .and_then(|v| v.as_str()) 261 - .map(std::string::ToString::to_string), 262 - meta.get("longDescription") 263 - .and_then(|v| v.as_str()) 264 - .map(std::string::ToString::to_string), 265 - meta.get("mainProgram") 266 - .and_then(|v| v.as_str()) 267 - .map(std::string::ToString::to_string), 268 - license_spdx, 269 - None::<String>, 270 - 0, 271 - None::<String>, 272 - )); 273 - 274 - if let Some(maintainers) = meta.get("maintainers").and_then(|v| v.as_array()) { 275 - for m in maintainers { 276 - if let Some(obj) = m.as_object() { 277 - maintainer_batch.push(( 278 - pkg_name.clone(), 279 - obj.get("name") 280 - .and_then(|v| v.as_str()) 281 - .map(std::string::ToString::to_string), 282 - obj.get("email") 283 - .and_then(|v| v.as_str()) 284 - .map(std::string::ToString::to_string), 285 - obj.get("github") 286 - .and_then(|v| v.as_str()) 287 - .map(std::string::ToString::to_string), 288 - obj.get("githubId").and_then(serde_json::Value::as_i64), 289 - obj.get("matrix") 290 - .and_then(|v| v.as_str()) 291 - .map(std::string::ToString::to_string), 292 - )); 293 - } 294 - } 295 - } 296 - 297 - count += 1; 298 - 299 - if package_batch.len() >= batch_size { 300 - let tx = conn.transaction()?; 301 - { 302 - let mut stmt = tx.prepare_cached("INSERT INTO packages VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")?; 303 - for p in &package_batch { 304 - stmt.execute(rusqlite::params![ 305 - p.0, p.1, p.2, p.3, p.4, p.5, p.6, p.7, p.8, p.9, p.10, p.11, p.12, p.13, 306 - p.14, p.15, p.16, p.17, p.18, p.19 307 - ])?; 308 - } 309 - } 310 - { 311 - let mut stmt = tx.prepare_cached("INSERT INTO maintainers (package_name, name, email, github, github_id, matrix) VALUES (?, ?, ?, ?, ?, ?)")?; 312 - for m in &maintainer_batch { 313 - stmt.execute(rusqlite::params![m.0, m.1, m.2, m.3, m.4, m.5])?; 314 - } 315 - } 316 - tx.commit()?; 317 - 318 - #[allow(clippy::cast_precision_loss)] 319 - let progress = (f64::from(count) / total as f64) * 100.0; 320 - 321 - println!("Progress: {count}/{total} ({progress:.1}%)"); 322 - package_batch.clear(); 323 - maintainer_batch.clear(); 324 - } 325 - } 326 - 327 - if !package_batch.is_empty() { 328 - let tx = conn.transaction()?; 329 - { 330 - let mut stmt = tx.prepare_cached("INSERT INTO packages VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")?; 331 - for p in &package_batch { 332 - stmt.execute(rusqlite::params![ 333 - p.0, p.1, p.2, p.3, p.4, p.5, p.6, p.7, p.8, p.9, p.10, p.11, p.12, p.13, p.14, 334 - p.15, p.16, p.17, p.18, p.19 335 - ])?; 336 - } 337 - } 338 - { 339 - let mut stmt = tx.prepare_cached("INSERT INTO maintainers (package_name, name, email, github, github_id, matrix) VALUES (?, ?, ?, ?, ?, ?)")?; 340 - for m in &maintainer_batch { 341 - stmt.execute(rusqlite::params![m.0, m.1, m.2, m.3, m.4, m.5])?; 342 - } 343 - } 344 - tx.commit()?; 345 - } 346 - 347 - println!("Vacuuming..."); 348 - conn.execute("VACUUM", [])?; 349 - 350 - store_hash(&release.hash)?; 351 - 352 - println!("Database created successfully: {db_path}"); 353 - Ok(()) 354 - } 355 14 356 15 #[tokio::main] 357 16 async fn main() -> Result<()> { ··· 360 19 361 20 // Enable color_eyre beacuse error handling ig 362 21 color_eyre::install()?; 363 - ensure_nixpkgs_database().await?; 22 + nixpkgs_db::ensure_nixpkgs_database().await?; 364 23 365 24 // Configure the client with your Discord bot token in the environment. 366 25 let token = env::var("DISCORD_TOKEN").expect("Expected DISCORD_TOKEN to be set"); ··· 381 40 commands::bot::bot::botinfo(), 382 41 // misc commands 383 42 commands::misc::crates::crates(), 43 + commands::misc::starboard::starboard_enable(), 44 + commands::misc::starboard::starboard_disable(), 45 + commands::misc::starboard::starboard_config(), 384 46 // moderation commands 385 47 commands::moderation::ban::ban(), 386 48 commands::moderation::kick::kick(), ··· 417 79 tokio::time::interval(std::time::Duration::from_secs(43_200)); 418 80 loop { 419 81 interval.tick().await; 420 - if let Err(e) = ensure_nixpkgs_database().await { 82 + if let Err(e) = nixpkgs_db::ensure_nixpkgs_database().await { 421 83 eprintln!("Failed to update nixpkgs database: {e}"); 422 84 } 423 85 }
+392
src/nixpkgs_db.rs
··· 1 + use color_eyre::eyre::Result; 2 + use sha2::{Digest, Sha256}; 3 + use std::path::Path; 4 + 5 + #[derive(Debug)] 6 + pub struct NixpkgsRelease { 7 + pub url: String, 8 + pub hash: String, 9 + } 10 + 11 + #[derive(Debug, Clone)] 12 + struct Package { 13 + name: String, 14 + pname: Option<String>, 15 + version: Option<String>, 16 + display_name: Option<String>, 17 + system: Option<String>, 18 + output_name: Option<String>, 19 + available: i32, 20 + broken: i32, 21 + description: Option<String>, 22 + homepage: Option<String>, 23 + insecure: i32, 24 + unfree: i32, 25 + unsupported: i32, 26 + position: Option<String>, 27 + long_description: Option<String>, 28 + main_program: Option<String>, 29 + license_spdx_id: Option<String>, 30 + license_full_name: Option<String>, 31 + license_free: i32, 32 + license_url: Option<String>, 33 + } 34 + 35 + #[derive(Debug, Clone)] 36 + struct Maintainer { 37 + package_name: String, 38 + name: Option<String>, 39 + email: Option<String>, 40 + github: Option<String>, 41 + github_id: Option<i64>, 42 + matrix: Option<String>, 43 + } 44 + 45 + pub async fn get_latest_nixpkgs_release() -> Result<NixpkgsRelease> { 46 + let base_url = std::env::var("NIXPKGS_CHANNEL") 47 + .unwrap_or_else(|_| "https://channels.nixos.org/nixpkgs-unstable".to_string()); 48 + 49 + let response = reqwest::get(&base_url).await?; 50 + let html = response.text().await?; 51 + 52 + let url_regex = 53 + regex::Regex::new(r"<a href='([^']+/packages\.json\.br)'>packages\.json\.br</a>")?; 54 + let hash_regex = regex::Regex::new( 55 + r"packages\.json\.br</a></td><td align='right'>\d+</td><td><tt>([a-f0-9]{64})</tt>", 56 + )?; 57 + 58 + let url = url_regex 59 + .captures(&html) 60 + .and_then(|cap| cap.get(1)) 61 + .map(|m| { 62 + let path = m.as_str(); 63 + if path.starts_with("http") { 64 + path.to_string() 65 + } else if path.starts_with('/') { 66 + format!("https://releases.nixos.org{path}") 67 + } else { 68 + format!("https://releases.nixos.org/{path}") 69 + } 70 + }) 71 + .ok_or_else(|| color_eyre::eyre::eyre!("Could not find packages.json.br URL"))?; 72 + 73 + let hash = hash_regex 74 + .captures(&html) 75 + .and_then(|cap| cap.get(1)) 76 + .map(|m| m.as_str().to_string()) 77 + .ok_or_else(|| color_eyre::eyre::eyre!("Could not find packages.json.br hash"))?; 78 + 79 + Ok(NixpkgsRelease { url, hash }) 80 + } 81 + 82 + fn get_stored_hash() -> Option<String> { 83 + let hash_path = crate::utils::get_data_dir().join("nixpkgs.hash"); 84 + std::fs::read_to_string(hash_path).ok() 85 + } 86 + 87 + fn store_hash(hash: &str) -> Result<()> { 88 + let hash_path = crate::utils::get_data_dir().join("nixpkgs.hash"); 89 + std::fs::write(hash_path, hash)?; 90 + Ok(()) 91 + } 92 + 93 + #[allow(clippy::too_many_lines)] 94 + pub async fn ensure_nixpkgs_database() -> Result<()> { 95 + let db_path = crate::utils::get_data_dir().join("packages.db"); 96 + 97 + println!("Checking for nixpkgs updates..."); 98 + let release = get_latest_nixpkgs_release().await?; 99 + let stored_hash = get_stored_hash(); 100 + 101 + if Path::new(&db_path).exists() && stored_hash.as_deref() == Some(&release.hash) { 102 + println!("nixpkgs database is up to date"); 103 + return Ok(()); 104 + } 105 + 106 + if Path::new(&db_path).exists() { 107 + println!("New nixpkgs release detected, updating database..."); 108 + std::fs::remove_file(&db_path)?; 109 + } else { 110 + println!("nixpkgs database not found, building..."); 111 + } 112 + 113 + println!("Downloading from {}...", release.url); 114 + let response = reqwest::get(&release.url).await?; 115 + let compressed = response.bytes().await?; 116 + 117 + let mut hasher = Sha256::new(); 118 + hasher.update(&compressed); 119 + let computed_hash = format!("{:x}", hasher.finalize()); 120 + 121 + if computed_hash != release.hash { 122 + return Err(color_eyre::eyre::eyre!( 123 + "Hash mismatch! Expected {}, got {}", 124 + release.hash, 125 + computed_hash 126 + )); 127 + } 128 + 129 + println!("Hash verified, decompressing..."); 130 + let mut decompressed = Vec::new(); 131 + let mut decoder = brotli::Decompressor::new(compressed.as_ref(), 4096); 132 + std::io::copy(&mut decoder, &mut decompressed)?; 133 + 134 + println!("Parsing JSON..."); 135 + let json_data: serde_json::Value = serde_json::from_slice(&decompressed)?; 136 + 137 + let packages = json_data["packages"] 138 + .as_object() 139 + .ok_or_else(|| color_eyre::eyre::eyre!("Invalid packages.json format"))?; 140 + 141 + println!("Creating database with {} packages...", packages.len()); 142 + 143 + let mut conn = rusqlite::Connection::open(&db_path)?; 144 + 145 + conn.execute( 146 + "CREATE TABLE packages ( 147 + package_name TEXT PRIMARY KEY, 148 + pname TEXT, 149 + version TEXT, 150 + name TEXT, 151 + system TEXT, 152 + output_name TEXT, 153 + available INTEGER, 154 + broken INTEGER, 155 + description TEXT, 156 + homepage TEXT, 157 + insecure INTEGER, 158 + unfree INTEGER, 159 + unsupported INTEGER, 160 + position TEXT, 161 + long_description TEXT, 162 + main_program TEXT, 163 + license_spdx_id TEXT, 164 + license_full_name TEXT, 165 + license_free INTEGER, 166 + license_url TEXT 167 + )", 168 + [], 169 + )?; 170 + 171 + conn.execute( 172 + "CREATE TABLE maintainers ( 173 + id INTEGER PRIMARY KEY AUTOINCREMENT, 174 + package_name TEXT, 175 + name TEXT, 176 + email TEXT, 177 + github TEXT, 178 + github_id INTEGER, 179 + matrix TEXT, 180 + FOREIGN KEY (package_name) REFERENCES packages(package_name) 181 + )", 182 + [], 183 + )?; 184 + 185 + conn.execute( 186 + "CREATE INDEX idx_package_name ON packages(package_name)", 187 + [], 188 + )?; 189 + conn.execute("CREATE INDEX idx_pname ON packages(pname)", [])?; 190 + conn.execute( 191 + "CREATE INDEX idx_maintainers_package ON maintainers(package_name)", 192 + [], 193 + )?; 194 + 195 + // Enable WAL mode for better concurrent access 196 + conn.pragma_update(None, "journal_mode", "WAL")?; 197 + 198 + // Increase cache size for better performance 199 + conn.pragma_update(None, "cache_size", "-64000")?; 200 + 201 + let total = packages.len(); 202 + let mut count = 0; 203 + const BATCH_SIZE: usize = 5000; // Increased from 1000 for better performance 204 + 205 + let mut package_batch = Vec::with_capacity(BATCH_SIZE); 206 + let mut maintainer_batch = Vec::with_capacity(BATCH_SIZE * 4); // Estimate 4 maintainers per package 207 + 208 + for (pkg_name, pkg_data) in packages { 209 + let meta = &pkg_data["meta"]; 210 + let license_data = &meta["license"]; 211 + 212 + let license_spdx = extract_license(license_data); 213 + let homepage = extract_homepage(meta); 214 + 215 + package_batch.push(Package { 216 + name: pkg_name.clone(), 217 + pname: extract_string(pkg_data, "pname"), 218 + version: extract_string(pkg_data, "version"), 219 + display_name: extract_string(pkg_data, "name"), 220 + system: extract_string(pkg_data, "system"), 221 + output_name: extract_string(pkg_data, "outputName"), 222 + available: i32::from( 223 + meta.get("available") 224 + .and_then(serde_json::Value::as_bool) 225 + .unwrap_or(false), 226 + ), 227 + broken: i32::from( 228 + meta.get("broken") 229 + .and_then(serde_json::Value::as_bool) 230 + .unwrap_or(false), 231 + ), 232 + description: extract_string(meta, "description"), 233 + homepage, 234 + insecure: i32::from( 235 + meta.get("insecure") 236 + .and_then(serde_json::Value::as_bool) 237 + .unwrap_or(false), 238 + ), 239 + unfree: i32::from( 240 + meta.get("unfree") 241 + .and_then(serde_json::Value::as_bool) 242 + .unwrap_or(false), 243 + ), 244 + unsupported: i32::from( 245 + meta.get("unsupported") 246 + .and_then(serde_json::Value::as_bool) 247 + .unwrap_or(false), 248 + ), 249 + position: extract_string(meta, "position"), 250 + long_description: extract_string(meta, "longDescription"), 251 + main_program: extract_string(meta, "mainProgram"), 252 + license_spdx_id: license_spdx, 253 + license_full_name: None, 254 + license_free: 0, 255 + license_url: None, 256 + }); 257 + 258 + if let Some(maintainers) = meta.get("maintainers").and_then(|v| v.as_array()) { 259 + for m in maintainers { 260 + if let Some(obj) = m.as_object() { 261 + maintainer_batch.push(Maintainer { 262 + package_name: pkg_name.clone(), 263 + name: obj.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()), 264 + email: obj.get("email").and_then(|v| v.as_str()).map(|s| s.to_string()), 265 + github: obj.get("github").and_then(|v| v.as_str()).map(|s| s.to_string()), 266 + github_id: obj.get("githubId").and_then(serde_json::Value::as_i64), 267 + matrix: obj.get("matrix").and_then(|v| v.as_str()).map(|s| s.to_string()), 268 + }); 269 + } 270 + } 271 + } 272 + 273 + count += 1; 274 + 275 + if package_batch.len() >= BATCH_SIZE { 276 + insert_batch(&mut conn, &package_batch, &maintainer_batch)?; 277 + print_progress(count, total); 278 + package_batch.clear(); 279 + maintainer_batch.clear(); 280 + } 281 + } 282 + 283 + if !package_batch.is_empty() { 284 + insert_batch(&mut conn, &package_batch, &maintainer_batch)?; 285 + } 286 + 287 + println!("Vacuuming..."); 288 + conn.execute("VACUUM", [])?; 289 + 290 + store_hash(&release.hash)?; 291 + 292 + println!("Database created successfully: {}", db_path.display()); 293 + Ok(()) 294 + } 295 + 296 + /// Extract a string value from a JSON object 297 + fn extract_string(obj: &serde_json::Value, key: &str) -> Option<String> { 298 + obj.get(key) 299 + .and_then(serde_json::Value::as_str) 300 + .map(|s| s.to_string()) 301 + } 302 + 303 + /// Extract license information from license data 304 + fn extract_license(license_data: &serde_json::Value) -> Option<String> { 305 + match license_data { 306 + serde_json::Value::Object(obj) => obj 307 + .get("spdxId") 308 + .and_then(|v| v.as_str()) 309 + .map(|s| s.to_string()), 310 + serde_json::Value::Array(arr) => { 311 + let ids: Vec<&str> = arr 312 + .iter() 313 + .filter_map(|v| v.get("spdxId")) 314 + .filter_map(|v| v.as_str()) 315 + .collect(); 316 + if ids.is_empty() { 317 + None 318 + } else { 319 + Some(ids.join(", ")) 320 + } 321 + } 322 + serde_json::Value::String(s) => Some(s.clone()), 323 + _ => None, 324 + } 325 + } 326 + 327 + /// Extract homepage from metadata 328 + fn extract_homepage(meta: &serde_json::Value) -> Option<String> { 329 + meta.get("homepage").and_then(|h| match h { 330 + serde_json::Value::String(s) => Some(s.clone()), 331 + serde_json::Value::Array(arr) => arr.first().and_then(|v| v.as_str()).map(|s| s.to_string()), 332 + _ => None, 333 + }) 334 + } 335 + 336 + /// Insert a batch of packages and maintainers into the database 337 + fn insert_batch( 338 + conn: &mut rusqlite::Connection, 339 + package_batch: &[Package], 340 + maintainer_batch: &[Maintainer], 341 + ) -> Result<()> { 342 + let tx = conn.transaction()?; 343 + { 344 + let mut stmt = tx.prepare_cached("INSERT INTO packages VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")?; 345 + for p in package_batch { 346 + stmt.execute(rusqlite::params![ 347 + p.name, 348 + p.pname, 349 + p.version, 350 + p.display_name, 351 + p.system, 352 + p.output_name, 353 + p.available, 354 + p.broken, 355 + p.description, 356 + p.homepage, 357 + p.insecure, 358 + p.unfree, 359 + p.unsupported, 360 + p.position, 361 + p.long_description, 362 + p.main_program, 363 + p.license_spdx_id, 364 + p.license_full_name, 365 + p.license_free, 366 + p.license_url, 367 + ])?; 368 + } 369 + } 370 + { 371 + let mut stmt = tx.prepare_cached("INSERT INTO maintainers (package_name, name, email, github, github_id, matrix) VALUES (?, ?, ?, ?, ?, ?)")?; 372 + for m in maintainer_batch { 373 + stmt.execute(rusqlite::params![ 374 + m.package_name, 375 + m.name, 376 + m.email, 377 + m.github, 378 + m.github_id, 379 + m.matrix, 380 + ])?; 381 + } 382 + } 383 + tx.commit()?; 384 + Ok(()) 385 + } 386 + 387 + /// Print progress information 388 + fn print_progress(count: usize, total: usize) { 389 + #[allow(clippy::cast_precision_loss)] 390 + let progress = (count as f64 / total as f64) * 100.0; 391 + println!("Progress: {count}/{total} ({progress:.1}%)"); 392 + }
+10
src/utils.rs
··· 1 + use std::path::PathBuf; 2 + 3 + /// Get the data directory for application files following XDG standards 4 + pub fn get_data_dir() -> PathBuf { 5 + directories::ProjectDirs::from("", "", "blahaj") 6 + .map_or_else( 7 + || PathBuf::from("/var/lib/blahaj"), 8 + |dirs| dirs.data_dir().to_path_buf(), 9 + ) 10 + }