diff --git a/.cargo/config.toml b/.cargo/config.toml
index d3b2f84..1611eb7 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -1,4 +1,4 @@
[env]
RUST_LOG = "warn,cosmic_ext_applet_clipboard_manager=debug"
-RUST_BACKTRACE = "full"
+# RUST_BACKTRACE = "full"
diff --git a/Cargo.lock b/Cargo.lock
index 449f598..3df7777 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -958,6 +958,7 @@ dependencies = [
"futures",
"i18n-embed",
"i18n-embed-fl",
+ "include_dir",
"libcosmic",
"mime 0.3.17",
"nucleo",
@@ -2554,6 +2555,25 @@ version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284"
+[[package]]
+name = "include_dir"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
+dependencies = [
+ "include_dir_macros",
+]
+
+[[package]]
+name = "include_dir_macros"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "indexmap"
version = "2.5.0"
diff --git a/Cargo.toml b/Cargo.toml
index ae47c0d..59c7e1f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -40,6 +40,7 @@ tracing-journald = "0.3"
constcat = "0.5"
nucleo = "0.5"
futures = "0.3"
+include_dir = "0.7.4"
[dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic"
diff --git a/i18n/en/cosmic_ext_applet_clipboard_manager.ftl b/i18n/en/cosmic_ext_applet_clipboard_manager.ftl
index dd445f5..360e045 100644
--- a/i18n/en/cosmic_ext_applet_clipboard_manager.ftl
+++ b/i18n/en/cosmic_ext_applet_clipboard_manager.ftl
@@ -5,4 +5,6 @@ clear_entries = Clear
show_qr_code = Show QR code
return_to_clipboard = Return to clipboard
qr_code_error = Error while generating the QR code
-horizontal_layout = Horizontal
\ No newline at end of file
+horizontal_layout = Horizontal
+add_favorite = Add Favorite
+remove_favorite = Remove Favorite
\ No newline at end of file
diff --git a/i18n/fr/cosmic_ext_applet_clipboard_manager.ftl b/i18n/fr/cosmic_ext_applet_clipboard_manager.ftl
index 56bbfc8..0603c87 100644
--- a/i18n/fr/cosmic_ext_applet_clipboard_manager.ftl
+++ b/i18n/fr/cosmic_ext_applet_clipboard_manager.ftl
@@ -5,4 +5,6 @@ clear_entries = Nettoyer
show_qr_code = Afficher le code QR
return_to_clipboard = Retourner au presse-papier
qr_code_error = Erreur pendant la génération du code QR
-horizontal_layout = Horizontal
\ No newline at end of file
+horizontal_layout = Horizontal
+add_favorite = Ajouter aux Favoris
+remove_favorite = Retirer des Favoris
\ No newline at end of file
diff --git a/justfile b/justfile
index fb274b3..207d970 100644
--- a/justfile
+++ b/justfile
@@ -16,8 +16,6 @@ bin-dst := base-dir / 'bin' / NAME
desktop-dst := share-dst / 'applications' / APPID + '.desktop'
icon-dst := share-dst / 'icons/hicolor/scalable/apps' / APPID + '-symbolic.svg'
env-dst := rootdir / 'etc/profile.d' / NAME + '.sh'
-migrations-dst := share-dst / NAME / 'migrations'
-
default: build-release
@@ -27,26 +25,18 @@ build-debug *args:
build-release *args:
cargo build --release {{args}}
-install: install-migrations
+install:
install -Dm0755 {{bin-src}} {{bin-dst}}
install -Dm0644 res/desktop_entry.desktop {{desktop-dst}}
install -Dm0644 res/app_icon.svg {{icon-dst}}
install -Dm0644 res/env.sh {{env-dst}}
-install-migrations:
- #!/usr/bin/env sh
- set -ex
- for file in ./migrations/*; do
- install -Dm0644 $file "{{migrations-dst}}/$(basename "$file")"
- done
-
uninstall:
rm {{bin-dst}}
rm {{desktop-dst}}
rm {{icon-dst}}
rm {{env-dst}}
- rm -r {{share-dst}}/{{NAME}}
clean:
cargo clean
diff --git a/migrations/20240929151638_favorite.sql b/migrations/20240929151638_favorite.sql
new file mode 100644
index 0000000..8034ea9
--- /dev/null
+++ b/migrations/20240929151638_favorite.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS FavoriteClipboardEntries (
+ id INTEGER PRIMARY KEY,
+ position INTEGER NOT NULL,
+ FOREIGN KEY (id) REFERENCES ClipboardEntries(creation) ON DELETE CASCADE,
+ -- UNIQUE (position),
+ CHECK (position >= 0)
+);
\ No newline at end of file
diff --git a/res/icons/star24.svg b/res/icons/star24.svg
new file mode 100644
index 0000000..a7ec68e
--- /dev/null
+++ b/res/icons/star24.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/icons/star_fill24.svg b/res/icons/star_fill24.svg
new file mode 100644
index 0000000..2b29564
--- /dev/null
+++ b/res/icons/star_fill24.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/app.rs b/src/app.rs
index c28fdbf..750d4cf 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -387,6 +387,20 @@ impl cosmic::Application for AppState {
config_set!(horizontal, horizontal);
}
},
+ AppMsg::AddFavorite(entry) => {
+ block_on(async {
+ if let Err(err) = self.db.add_favorite(&entry, None).await {
+ error!("{err}");
+ }
+ });
+ }
+ AppMsg::RemoveFavorite(entry) => {
+ block_on(async {
+ if let Err(err) = self.db.remove_favorite(&entry).await {
+ error!("{err}");
+ }
+ });
+ }
}
Command::none()
}
diff --git a/src/clipboard.rs b/src/clipboard.rs
index 18ed412..c95c202 100644
--- a/src/clipboard.rs
+++ b/src/clipboard.rs
@@ -150,7 +150,7 @@ pub fn sub() -> Subscription {
None
};
- let data = Entry::new_now(mime_type, contents, metadata);
+ let data = Entry::new_now(mime_type, contents, metadata, false);
info!("sending data to database: {:?}", data);
output.send(ClipboardMessage::Data(data)).await.unwrap();
diff --git a/src/db.rs b/src/db.rs
index ab1be32..5dba84c 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -11,7 +11,7 @@ use sqlx::{
};
use std::{
borrow::Cow,
- collections::{BTreeMap, HashMap},
+ collections::{BTreeMap, HashMap, HashSet},
fmt::{Debug, Display},
fs::{self, DirBuilder, File},
hash::{DefaultHasher, Hash, Hasher},
@@ -35,7 +35,7 @@ use crate::{
app::{APP, APPID, ORG, QUALIFIER},
config::Config,
message::AppMsg,
- utils::{self, now_millis, remove_dir_contents},
+ utils::{self, now_millis},
};
type TimeId = i64;
@@ -43,25 +43,27 @@ type TimeId = i64;
const DB_VERSION: &str = "4";
const DB_PATH: &str = constcat::concat!(APPID, "-db-", DB_VERSION, ".sqlite");
-// warning: if you change somethings in here, change the db version
#[derive(Clone, Eq, Derivative)]
-#[derivative(PartialEq, Hash)]
pub struct Entry {
- #[derivative(PartialEq = "ignore")]
- #[derivative(Hash = "ignore")]
pub creation: TimeId,
-
- #[derivative(PartialEq = "ignore")]
- #[derivative(Hash = "ignore")]
pub mime: String,
-
// todo: lazelly load image in memory, since we can't search them anyways
pub content: Vec,
-
- #[derivative(PartialEq = "ignore")]
- #[derivative(Hash = "ignore")]
/// (Mime, Content)
pub metadata: Option,
+ pub is_favorite: bool,
+}
+
+impl Hash for Entry {
+ fn hash(&self, state: &mut H) {
+ self.content.hash(state);
+ }
+}
+
+impl PartialEq for Entry {
+ fn eq(&self, other: &Self) -> bool {
+ self.content == other.content
+ }
}
#[derive(Debug, Clone, Eq, PartialEq)]
@@ -82,23 +84,39 @@ impl Entry {
mime: String,
content: Vec,
metadata: Option,
+ is_favorite: bool,
) -> Self {
Self {
creation,
mime,
content,
metadata,
+ is_favorite,
}
}
- pub fn new_now(mime: String, content: Vec, metadata: Option) -> Self {
- Self::new(Utc::now().timestamp_millis(), mime, content, metadata)
+ pub fn new_now(
+ mime: String,
+ content: Vec,
+ metadata: Option,
+ is_favorite: bool,
+ ) -> Self {
+ Self::new(
+ Utc::now().timestamp_millis(),
+ mime,
+ content,
+ metadata,
+ is_favorite,
+ )
}
/// SELECT creation, mime, content, metadataMime, metadata
- fn from_row(row: &SqliteRow) -> Result {
+ fn from_row(row: &SqliteRow, favorites: &Favorites) -> Result {
+ let id = row.get("creation");
+ let is_fav = favorites.contains(&id);
+
Ok(Entry::new(
- row.get("creation"),
+ id,
row.get("mime"),
row.get("content"),
row.try_get("metadataMime")
@@ -107,6 +125,7 @@ impl Entry {
mime: metadata_mime,
value: row.get("metadata"),
}),
+ is_fav,
))
}
}
@@ -207,9 +226,50 @@ pub struct Db {
query: String,
needle: Option,
matcher: Matcher,
- // time
- last_update: i64,
data_version: i64,
+ favorites: Favorites,
+}
+
+#[derive(Default)]
+struct Favorites {
+ favorites: Vec,
+ favorites_hash_set: HashSet,
+}
+
+impl Favorites {
+ fn contains(&self, id: &TimeId) -> bool {
+ self.favorites_hash_set.contains(id)
+ }
+ fn clear(&mut self) {
+ self.favorites.clear();
+ self.favorites_hash_set.clear();
+ }
+
+ fn insert_at(&mut self, id: TimeId, pos: Option) {
+ match pos {
+ Some(pos) => self.favorites.insert(pos, id),
+ None => self.favorites.push(id),
+ }
+ self.favorites_hash_set.insert(id);
+ }
+
+ fn remove(&mut self, id: &TimeId) -> Option {
+ self.favorites_hash_set.remove(id);
+ self.favorites.iter().position(|e| e == id).inspect(|i| {
+ self.favorites.remove(*i);
+ })
+ }
+
+ fn fav(&self) -> &Vec {
+ &self.favorites
+ }
+
+ fn change(&mut self, prev: &TimeId, new: TimeId) {
+ let pos = self.favorites.iter().position(|e| e == prev).unwrap();
+ self.favorites[pos] = new;
+ self.favorites_hash_set.remove(prev);
+ self.favorites_hash_set.insert(new);
+ }
}
impl Db {
@@ -235,7 +295,11 @@ impl Db {
let mut conn = SqliteConnection::connect(db_path).await?;
- let migration_path = Path::new(constcat::concat!("/usr/share/", APP, "/migrations"));
+ let migration_path = db_dir.join("migrations");
+ std::fs::create_dir_all(&migration_path)?;
+ include_dir::include_dir!("migrations")
+ .extract(&migration_path)
+ .unwrap();
match sqlx::migrate::Migrator::new(migration_path).await {
Ok(migrator) => migrator,
@@ -287,7 +351,7 @@ impl Db {
query: String::default(),
needle: None,
matcher: Matcher::new(nucleo::Config::DEFAULT),
- last_update: 0,
+ favorites: Favorites::default(),
};
db.reload().await?;
@@ -298,21 +362,52 @@ impl Db {
async fn reload(&mut self) -> Result<()> {
self.hashs.clear();
self.state.clear();
+ self.favorites.clear();
- let query_load_table = r#"
- SELECT creation, mime, content, metadataMime, metadata
- FROM ClipboardEntries
- "#;
+ {
+ let query_load_favs = r#"
+ SELECT id, position
+ FROM FavoriteClipboardEntries
+ "#;
- let rows = sqlx::query(query_load_table)
- .fetch_all(&mut self.conn)
- .await?;
+ let rows = sqlx::query(query_load_favs)
+ .fetch_all(&mut self.conn)
+ .await?;
- for row in &rows {
- let data = Entry::from_row(row)?;
+ let mut rows = rows
+ .iter()
+ .map(|row| {
+ let id: i64 = row.get("id");
+ let index: i32 = row.get("position");
+ (id, index as usize)
+ })
+ .collect::>();
- self.hashs.insert(data.get_hash(), data.creation);
- self.state.insert(data.creation, data);
+ rows.sort_by(|e1, e2| e1.1.cmp(&e2.1));
+
+ debug_assert_eq!(rows.last().map(|e| e.1 + 1).unwrap_or(0), rows.len());
+
+ for (id, pos) in rows {
+ self.favorites.insert_at(id, Some(pos));
+ }
+ }
+
+ {
+ let query_load_table = r#"
+ SELECT creation, mime, content, metadataMime, metadata
+ FROM ClipboardEntries
+ "#;
+
+ let rows = sqlx::query(query_load_table)
+ .fetch_all(&mut self.conn)
+ .await?;
+
+ for row in &rows {
+ let data = Entry::from_row(row, &self.favorites)?;
+
+ self.hashs.insert(data.get_hash(), data.creation);
+ self.state.insert(data.creation, data);
+ }
}
self.search();
@@ -331,7 +426,7 @@ impl Db {
let entry = sqlx::query(query_get_last_row)
.fetch_optional(&mut self.conn)
.await?
- .map(|e| Entry::from_row(&e).unwrap());
+ .map(|e| Entry::from_row(&e, &self.favorites).unwrap());
Ok(entry)
}
@@ -381,18 +476,39 @@ impl Db {
// safe to unwrap since we insert before
let last_row = self.get_last_row().await?.unwrap();
+ let new_id = last_row.creation;
+
let data_hash = data.get_hash();
if let Some(old_id) = self.hashs.remove(&data_hash) {
self.state.remove(&old_id);
+ if self.favorites.contains(&old_id) {
+ data.is_favorite = true;
+ let query_delete_old_id = r#"
+ UPDATE FavoriteClipboardEntries
+ SET id = $1
+ WHERE id = $2;
+ "#;
+
+ sqlx::query(query_delete_old_id)
+ .bind(new_id)
+ .bind(old_id)
+ .execute(&mut self.conn)
+ .await?;
+
+ self.favorites.change(&old_id, new_id);
+ } else {
+ data.is_favorite = false;
+ }
+
// in case 2 same data were inserted in a short period
// we don't want to remove the old_id
- if last_row.creation != old_id {
+ if new_id != old_id {
let query_delete_old_id = r#"
- DELETE FROM ClipboardEntries
- WHERE creation = ?;
- "#;
+ DELETE FROM ClipboardEntries
+ WHERE creation = ?;
+ "#;
sqlx::query(query_delete_old_id)
.bind(old_id)
@@ -401,11 +517,10 @@ impl Db {
}
}
- data.creation = last_row.creation;
+ data.creation = new_id;
self.hashs.insert(data_hash, data.creation);
self.state.insert(data.creation, data);
- self.last_update = now_millis();
self.search();
Ok(())
@@ -426,7 +541,10 @@ impl Db {
self.hashs.remove(&data.get_hash());
self.state.remove(&data.creation);
- self.last_update = now_millis();
+
+ if data.is_favorite {
+ self.favorites.remove(&data.creation);
+ }
self.search();
Ok(())
@@ -442,20 +560,94 @@ impl Db {
self.state.clear();
self.filtered.clear();
self.hashs.clear();
- self.last_update = now_millis();
+ self.favorites.clear();
Ok(())
}
+ pub async fn add_favorite(&mut self, entry: &Entry, index: Option) -> Result<()> {
+ debug_assert!(!self.favorites.fav().contains(&entry.creation));
+
+ self.favorites.insert_at(entry.creation, index);
+
+ if let Some(pos) = index {
+ let query = r#"
+ UPDATE FavoriteClipboardEntries
+ SET position = position + 1
+ WHERE position >= ?;
+ "#;
+ sqlx::query(query)
+ .bind(pos as i32)
+ .execute(&mut self.conn)
+ .await
+ .unwrap();
+ }
+
+ let index = index.unwrap_or(self.favorite_len() - 1);
+
+ {
+ let query = r#"
+ INSERT INTO FavoriteClipboardEntries (id, position)
+ VALUES ($1, $2);
+ "#;
+
+ sqlx::query(query)
+ .bind(entry.creation)
+ .bind(index as i32)
+ .execute(&mut self.conn)
+ .await?;
+ }
+
+ if let Some(e) = self.state.get_mut(&entry.creation) {
+ e.is_favorite = true;
+ }
+
+ Ok(())
+ }
+
+ pub async fn remove_favorite(&mut self, entry: &Entry) -> Result<()> {
+ debug_assert!(self.favorites.fav().contains(&entry.creation));
+
+ {
+ let query = r#"
+ DELETE FROM FavoriteClipboardEntries
+ WHERE id = ?;
+ "#;
+
+ sqlx::query(query)
+ .bind(entry.creation)
+ .execute(&mut self.conn)
+ .await?;
+ }
+
+ if let Some(pos) = self.favorites.remove(&entry.creation) {
+ let query = r#"
+ UPDATE FavoriteClipboardEntries
+ SET position = position - 1
+ WHERE position >= ?;
+ "#;
+ sqlx::query(query)
+ .bind(pos as i32)
+ .execute(&mut self.conn)
+ .await?;
+ }
+
+ if let Some(e) = self.state.get_mut(&entry.creation) {
+ e.is_favorite = false;
+ }
+ Ok(())
+ }
+
+ pub fn favorite_len(&self) -> usize {
+ self.favorites.favorites.len()
+ }
+
pub fn search(&mut self) {
if self.query.is_empty() {
self.filtered.clear();
} else if let Some(atom) = &self.needle {
- self.filtered = self
- .state
- .iter()
- .rev()
- .filter_map(|(id, data)| {
+ self.filtered = Self::iter_inner(&self.state, &self.favorites)
+ .filter_map(|data| {
data.get_searchable_text().and_then(|text| {
let mut buf = Vec::new();
@@ -466,7 +658,7 @@ impl Db {
let _res = atom.indices(haystack, &mut self.matcher, &mut indices);
if !indices.is_empty() {
- Some((*id, indices))
+ Some((data.creation, indices))
} else {
None
}
@@ -502,8 +694,7 @@ impl Db {
pub fn get(&self, index: usize) -> Option<&Entry> {
if self.query.is_empty() {
- // because we expose the tree in reverse
- self.state.iter().rev().nth(index).map(|e| e.1)
+ self.iter().nth(index)
} else {
self.filtered
.get(index)
@@ -513,7 +704,18 @@ impl Db {
pub fn iter(&self) -> impl Iterator- {
debug_assert!(self.query.is_empty());
- self.state.values().rev()
+ Self::iter_inner(&self.state, &self.favorites)
+ }
+
+ fn iter_inner<'a>(
+ state: &'a BTreeMap,
+ favorites: &'a Favorites,
+ ) -> impl Iterator
- + 'a {
+ favorites
+ .fav()
+ .iter()
+ .filter_map(|id| state.get(id))
+ .chain(state.values().filter(|e| !e.is_favorite).rev())
}
pub fn search_iter(&self) -> impl Iterator
- )> {
@@ -578,6 +780,7 @@ mod test {
use anyhow::Result;
use cosmic::{iced_sctk::util, widget::canvas::Path};
+ use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use crate::{
config::Config,
@@ -587,6 +790,16 @@ mod test {
use super::{Db, Entry};
fn prepare_db_dir() -> PathBuf {
+ let fmt_layer = fmt::layer().with_target(false);
+ let filter_layer = EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new(format!(
+ "warn,{}=info",
+ env!("CARGO_CRATE_NAME")
+ )));
+ let _ = tracing_subscriber::registry()
+ .with(filter_layer)
+ .with(fmt_layer)
+ .try_init();
+
let db_dir = PathBuf::from("tests");
let _ = std::fs::create_dir_all(&db_dir);
remove_dir_contents(&db_dir);
@@ -612,7 +825,12 @@ mod test {
async fn test_db(db: &mut Db) -> Result<()> {
assert!(db.len() == 0);
- let data = Entry::new_now("text/plain".into(), "content".as_bytes().into(), None);
+ let data = Entry::new_now(
+ "text/plain".into(),
+ "content".as_bytes().into(),
+ None,
+ false,
+ );
db.insert(data).await.unwrap();
@@ -620,7 +838,12 @@ mod test {
sleep(Duration::from_millis(1000));
- let data = Entry::new_now("text/plain".into(), "content".as_bytes().into(), None);
+ let data = Entry::new_now(
+ "text/plain".into(),
+ "content".as_bytes().into(),
+ None,
+ false,
+ );
db.insert(data).await.unwrap();
@@ -628,7 +851,12 @@ mod test {
sleep(Duration::from_millis(1000));
- let data = Entry::new_now("text/plain".into(), "content2".as_bytes().into(), None);
+ let data = Entry::new_now(
+ "text/plain".into(),
+ "content2".as_bytes().into(),
+ None,
+ false,
+ );
db.insert(data.clone()).await.unwrap();
@@ -649,12 +877,22 @@ mod test {
let mut db = Db::inner_new(&Config::default(), &db_path).await.unwrap();
- let data = Entry::new_now("text/plain".into(), "content".as_bytes().into(), None);
+ let data = Entry::new_now(
+ "text/plain".into(),
+ "content".as_bytes().into(),
+ None,
+ false,
+ );
db.insert(data).await.unwrap();
sleep(Duration::from_millis(100));
- let data = Entry::new_now("text/plain".into(), "content2".as_bytes().into(), None);
+ let data = Entry::new_now(
+ "text/plain".into(),
+ "content2".as_bytes().into(),
+ None,
+ false,
+ );
db.insert(data).await.unwrap();
assert!(db.len() == 2);
@@ -681,11 +919,23 @@ mod test {
let now = utils::now_millis();
- let data = Entry::new(now, "text/plain".into(), "content".as_bytes().into(), None);
+ let data = Entry::new(
+ now,
+ "text/plain".into(),
+ "content".as_bytes().into(),
+ None,
+ false,
+ );
db.insert(data).await.unwrap();
- let data = Entry::new(now, "text/plain".into(), "content".as_bytes().into(), None);
+ let data = Entry::new(
+ now,
+ "text/plain".into(),
+ "content".as_bytes().into(),
+ None,
+ false,
+ );
db.insert(data).await.unwrap();
assert!(db.len() == 1);
@@ -700,13 +950,112 @@ mod test {
let now = utils::now_millis();
- let data = Entry::new(now, "text/plain".into(), "content".as_bytes().into(), None);
+ let data = Entry::new(
+ now,
+ "text/plain".into(),
+ "content".as_bytes().into(),
+ None,
+ false,
+ );
db.insert(data).await.unwrap();
- let data = Entry::new(now, "text/plain".into(), "content2".as_bytes().into(), None);
+ let data = Entry::new(
+ now,
+ "text/plain".into(),
+ "content2".as_bytes().into(),
+ None,
+ false,
+ );
db.insert(data).await.unwrap();
assert!(db.len() == 2);
}
+
+ #[tokio::test]
+ #[serial]
+ async fn favorites() {
+ let db_path = prepare_db_dir();
+
+ let mut db = Db::inner_new(&Config::default(), &db_path).await.unwrap();
+
+ let now1 = 1000;
+
+ let data1 = Entry::new(
+ now1,
+ "text/plain".into(),
+ "content1".as_bytes().into(),
+ None,
+ false,
+ );
+
+ db.insert(data1).await.unwrap();
+
+ let now2 = 2000;
+
+ let data2 = Entry::new(
+ now2,
+ "text/plain".into(),
+ "content2".as_bytes().into(),
+ None,
+ false,
+ );
+
+ db.insert(data2).await.unwrap();
+
+ let now3 = 3000;
+
+ let data3 = Entry::new(
+ now3,
+ "text/plain".into(),
+ "content3".as_bytes().into(),
+ None,
+ false,
+ );
+
+ db.insert(data3.clone()).await.unwrap();
+
+ db.add_favorite(&db.state.get(&now3).unwrap().clone(), None)
+ .await
+ .unwrap();
+
+ db.delete(&db.state.get(&now3).unwrap().clone())
+ .await
+ .unwrap();
+
+ assert_eq!(db.favorite_len(), 0);
+
+ db.insert(data3).await.unwrap();
+
+ db.add_favorite(&db.state.get(&now1).unwrap().clone(), None)
+ .await
+ .unwrap();
+
+ db.add_favorite(&db.state.get(&now3).unwrap().clone(), None)
+ .await
+ .unwrap();
+
+ db.add_favorite(&db.state.get(&now2).unwrap().clone(), Some(1))
+ .await
+ .unwrap();
+
+ assert_eq!(db.favorite_len(), 3);
+
+ assert_eq!(db.favorites.fav(), &vec![now1, now2, now3]);
+
+ let db = Db::inner_new(
+ &Config {
+ maximum_entries_lifetime: None,
+ ..Default::default()
+ },
+ &db_path,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(db.len(), 3);
+
+ assert_eq!(db.favorite_len(), 3);
+ assert_eq!(db.favorites.fav(), &vec![now1, now2, now3]);
+ }
}
diff --git a/src/db_test.sql b/src/db_test.sql
index d0d0dbe..e42d856 100644
--- a/src/db_test.sql
+++ b/src/db_test.sql
@@ -1,25 +1,81 @@
-DROP TABLE IF EXISTS data;
+DROP TABLE IF EXISTS FavoriteClipboardEntries;
-CREATE TABLE data (
- creation INTEGER PRIMARY KEY,
- mime TEXT NOT NULL,
- content BLOB NOT NULL
-);
+DROP TABLE IF EXISTS ClipboardEntries;
+CREATE TABLE IF NOT EXISTS ClipboardEntries (
+ creation INTEGER PRIMARY KEY,
+ mime TEXT NOT NULL,
+ content BLOB NOT NULL,
+ metadataMime TEXT,
+ metadata TEXT,
+ CHECK (
+ (
+ metadataMime IS NULL
+ AND metadata IS NULL
+ )
+ OR (
+ metadataMime IS NOT NULL
+ AND metadata IS NOT NULL
+ )
+ )
+);
-INSERT INTO data (creation, mime, content)
-VALUES (1000, 'image/png', 'content');
-
-WITH last_row AS (
- SELECT creation, mime, content
- FROM data
- ORDER BY creation DESC
- LIMIT 1
-)
-INSERT INTO data (creation, mime, content)
-SELECT 1500, 'image/png', 'content'
-WHERE NOT EXISTS (
- SELECT 1
- FROM last_row AS lr
- WHERE lr.content = 'content' AND (1500 - lr.creation) <= 1000
+CREATE TABLE IF NOT EXISTS FavoriteClipboardEntries (
+ id INTEGER PRIMARY KEY,
+ position INTEGER NOT NULL,
+ FOREIGN KEY (id) REFERENCES ClipboardEntries(creation) ON DELETE CASCADE,
+ UNIQUE (position),
+ CHECK (position >= 0)
);
+
+INSERT INTO
+ ClipboardEntries (creation, mime, content)
+VALUES
+ (1000, 'image/png', 'content1');
+
+INSERT INTO
+ ClipboardEntries (creation, mime, content)
+VALUES
+ (2000, 'image/png', 'content2');
+
+INSERT INTO
+ ClipboardEntries (creation, mime, content)
+VALUES
+ (3000, 'image/png', 'content3');
+
+INSERT INTO
+ ClipboardEntries (creation, mime, content)
+VALUES
+ (4000, 'image/png', 'content4');
+
+INSERT INTO
+ ClipboardEntries (creation, mime, content)
+VALUES
+ (5000, 'image/png', 'content5');
+
+INSERT INTO
+ FavoriteClipboardEntries (id, position)
+VALUES
+ (1000, 0);
+
+INSERT INTO
+ FavoriteClipboardEntries (id, position)
+VALUES
+ (2000, 1);
+
+INSERT INTO
+ FavoriteClipboardEntries (id, position)
+VALUES
+ (4000, 2);
+
+UPDATE
+ FavoriteClipboardEntries
+SET
+ position = position + 1
+WHERE
+ position >= 2;
+
+INSERT INTO
+ FavoriteClipboardEntries (id, position)
+VALUES
+ (3000, 2);
\ No newline at end of file
diff --git a/src/message.rs b/src/message.rs
index 0bf0b1f..30ff356 100644
--- a/src/message.rs
+++ b/src/message.rs
@@ -24,6 +24,8 @@ pub enum AppMsg {
ShowQrCode(Entry),
ReturnToClipboard,
Config(ConfigMsg),
+ AddFavorite(Entry),
+ RemoveFavorite(Entry),
}
#[derive(Clone, Debug)]
diff --git a/src/view.rs b/src/view.rs
index c30a8b7..c30b5c4 100644
--- a/src/view.rs
+++ b/src/view.rs
@@ -32,6 +32,14 @@ use crate::{
utils::{formatted_value, horizontal_padding, vertical_padding},
};
+#[macro_export]
+macro_rules! icon {
+ ($name:literal) => {{
+ let bytes = include_bytes!(concat!("../../res/icons/", $name, "px.svg"));
+ cosmic::widget::icon::from_svg_bytes(bytes)
+ }};
+}
+
impl AppState {
pub fn quick_settings_view(&self) -> Element<'_, AppMsg> {
fn toggle_settings<'a>(
@@ -339,9 +347,21 @@ impl AppState {
menu::Tree::new(
button::text(fl!("show_qr_code"))
.on_press(AppMsg::ShowQrCode(entry.clone()))
- .width(Length::Fill)
- .style(Button::Destructive),
+ .width(Length::Fill),
),
+ if entry.is_favorite {
+ menu::Tree::new(
+ button::text(fl!("remove_favorite"))
+ .on_press(AppMsg::RemoveFavorite(entry.clone()))
+ .width(Length::Fill),
+ )
+ } else {
+ menu::Tree::new(
+ button::text(fl!("add_favorite"))
+ .on_press(AppMsg::AddFavorite(entry.clone()))
+ .width(Length::Fill),
+ )
+ },
]),
)
.into()