Compare commits

...

3 Commits

11 changed files with 1053 additions and 417 deletions

463
Cargo.lock generated
View File

@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anstream"
version = "0.6.18"
@ -38,7 +44,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
dependencies = [
"windows-sys",
"windows-sys 0.59.0",
]
[[package]]
@ -49,7 +55,7 @@ checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
dependencies = [
"anstyle",
"once_cell",
"windows-sys",
"windows-sys 0.59.0",
]
[[package]]
@ -58,12 +64,33 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "autocfg"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "bitflags"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.20"
@ -73,6 +100,12 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
version = "4.5.37"
@ -113,12 +146,117 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "cli"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"taggo",
]
[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@ -131,6 +269,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
@ -143,6 +287,8 @@ version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
@ -161,12 +307,58 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]]
name = "instability"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "libc"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libsqlite3-sys"
version = "0.33.0"
@ -178,12 +370,84 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]]
name = "mio"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.52.0",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "parking_lot"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-targets",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pkg-config"
version = "0.3.32"
@ -208,6 +472,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
dependencies = [
"bitflags",
]
[[package]]
name = "rusqlite"
version = "0.35.0"
@ -222,24 +516,113 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
dependencies = [
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.101"
@ -256,16 +639,53 @@ name = "taggo"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"rusqlite",
]
[[package]]
name = "tui"
version = "0.1.0"
dependencies = [
"anyhow",
"ratatui",
"taggo",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "utf8parse"
version = "0.2.2"
@ -278,6 +698,43 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"

View File

@ -3,7 +3,6 @@ name = "taggo"
version = "0.1.0"
edition = "2024"
authors = ["Wynd <wyndftw@proton.me>"]
description = "application for file tagging"
license = "GPL-3.0-or-later"
[lib]
@ -11,10 +10,16 @@ name = "taggo"
path = "src/lib.rs"
bench = false
[workspace]
members = ["cli", "tui"]
default-members = ["cli"]
[workspace.dependencies]
anyhow = { version = "1.0" }
[lints.rust]
unsafe_code = { level = "forbid" }
[dependencies]
clap = { version = "4.5", features = ["derive"] }
rusqlite = { version = "0.35", features = ["bundled"] }
anyhow = { version = "1.0" }

10
cli/Cargo.toml 100644
View File

@ -0,0 +1,10 @@
[package]
name = "cli"
version = "0.1.0"
edition = "2024"
description = "cli application for file tagging"
[dependencies]
taggo = { path = "../" }
clap = { version = "4.5", features = ["derive"] }
anyhow = { workspace = true }

116
cli/src/main.rs 100644
View File

@ -0,0 +1,116 @@
use std::{
io::{self, Write},
path::PathBuf,
};
use clap::Parser;
use cli::{CliArgs, FilesArgs, TagArgs, TagsArgs, UntagArgs};
use taggo::{init_db, try_database};
use anyhow::{Result, anyhow};
mod cli;
pub const TERM_RESET: &str = "\x1b[0m";
pub const TERM_BOLD: &str = "\x1b[1m";
fn main() -> anyhow::Result<()> {
let args = CliArgs::parse();
match args.commands {
cli::Commands::Init => init_db().map_err(|_| anyhow!("Database already exists!")),
cli::Commands::Tag {
args,
files,
file_tags,
} => handle_tag(args, files, file_tags),
cli::Commands::Untag(args) => handle_untag(args),
cli::Commands::Tags(args) => handle_tags(args),
cli::Commands::Files(args) => handle_files(args),
}
}
fn handle_files(args: FilesArgs) -> Result<()> {
let conn = try_database()?;
let mut files = taggo::list_files(&conn, args.all, args.query)?;
let mut w = io::stdout();
if !files.files.is_empty() {
if args.all {
writeln!(&mut w, "{TERM_BOLD}Tagged Files: {TERM_RESET}")?;
}
for (file, tags) in files.files {
// Remove already tagged files
if let Some(idx) = files.paths.iter().position(|v| *v == file) {
files.paths.swap_remove(idx);
}
if files.show_tags {
writeln!(&mut w, "{}: {}", file, tags)?;
} else {
writeln!(&mut w, "{}", file)?;
}
}
} else {
writeln!(&mut w, "No files registered!")?;
}
if args.all && !files.paths.is_empty() {
writeln!(&mut w, "\n{TERM_BOLD}Untagged Files: {TERM_RESET}")?;
for path in files.paths {
let path_str = path;
writeln!(&mut w, "{}", path_str)?;
}
}
w.flush()?;
Ok(())
}
pub fn handle_tag(args: TagArgs, files: Vec<PathBuf>, file_tags: Vec<String>) -> Result<()> {
let mut conn = try_database()?;
if let Some(file) = args.file {
taggo::tag_file(&mut conn, &file, &args.tags)?;
} else {
// TODO: Maybe rework this at some point so its more optimized for batch insertion ?
for file in &files {
taggo::tag_file(&mut conn, file, &file_tags)?;
}
}
Ok(())
}
pub fn handle_untag(args: UntagArgs) -> Result<()> {
let mut conn = try_database()?;
taggo::untag_file(&mut conn, args.file, args.tags)?;
Ok(())
}
pub fn handle_tags(args: TagsArgs) -> Result<()> {
let mut conn = try_database()?;
match args.commands {
Some(cli::TagsCommands::List) | None => {
let mut w = io::stdout();
let tags = taggo::list_tags(&conn)?;
if !tags.is_empty() {
for tag in tags {
writeln!(&mut w, "{} - {}", tag.0, tag.1)?;
}
} else {
writeln!(&mut w, "No tags registered!")?;
}
w.flush()?;
Ok(())
}
Some(cli::TagsCommands::Add { add }) => taggo::add_tags(&mut conn, add),
Some(cli::TagsCommands::Remove { remove }) => taggo::remove_tags(&mut conn, remove),
Some(cli::TagsCommands::Rename { renames }) => taggo::rename_tag(&conn, renames),
}
}

View File

@ -0,0 +1,309 @@
use std::fs;
use std::io::Write;
use std::str::FromStr;
use std::{io, path::PathBuf};
use anyhow::{Result, anyhow};
use rusqlite::{Connection, OpenFlags, params_from_iter, types::Value};
const DB_PATH: &str = ".tags";
pub struct Files {
pub files: Vec<(String, String)>,
pub paths: Vec<String>,
pub show_tags: bool,
}
pub fn init_db() -> anyhow::Result<()> {
let conn = Connection::open(DB_PATH)?;
conn.execute(
r#"CREATE TABLE tag(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL UNIQUE
);"#,
(),
)?;
conn.execute(
r#"CREATE TABLE file(
id INTEGER PRIMARY KEY AUTOINCREMENT,
path VARCHAR(255) NOT NULL UNIQUE
);"#,
(),
)?;
conn.execute(
r#"CREATE TABLE file_tag(
file_id INTEGER REFERENCES file(id) ON DELETE CASCADE,
tag_id INTEGER REFERENCES tag(id) ON DELETE CASCADE,
PRIMARY KEY (file_id, tag_id)
);"#,
(),
)?;
Ok(())
}
pub fn try_database() -> anyhow::Result<Connection> {
let conn = Connection::open_with_flags(
DB_PATH,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|_| {
anyhow!("Database does not exist! Run `taggo init` first to generate a new database.")
})?;
Ok(conn)
}
pub fn rename_tag(conn: &Connection, renames: Vec<String>) -> Result<()> {
if renames.is_empty() {
return Err(anyhow!("No input found!"));
} else if renames.len() % 2 != 0 {
return Err(anyhow!(
"Attempting to rename an uneven amount of entries: {}",
renames.len()
));
}
let mut stmt = conn.prepare("UPDATE tag SET name = ? WHERE name = ?")?;
let mut i = 0;
while i < renames.len() {
// TODO: Merge this when let chaining is stabilized in 1.88
if let Some(old) = renames.get(i) {
if let Some(new) = renames.get(i + 1) {
stmt.execute([&new, &old])?;
}
}
i += 2;
}
Ok(())
}
pub fn list_tags(conn: &Connection) -> Result<Vec<(String, i32)>> {
let mut stmt = conn.prepare(
r#"SELECT name, COUNT(file_id) as count FROM tag
LEFT JOIN file_tag ON file_tag.tag_id = tag.id
GROUP BY name
ORDER BY count DESC"#,
)?;
let result = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.flatten();
let mut tags: Vec<(String, i32)> = Vec::new();
for entry in result {
tags.push(entry);
}
Ok(tags)
}
pub fn remove_tags(conn: &mut Connection, tags: Vec<String>) -> Result<()> {
let tx = conn.transaction()?;
{
let mut query = r#"DELETE FROM tag WHERE name IN ("#.to_string();
for (i, _tag) in tags.iter().enumerate() {
query.push('?');
if i < tags.len() - 1 {
query.push(',');
}
}
query.push(')');
tx.execute(&query, params_from_iter(&tags))?;
if tx.changes() > 0 {
let mut w = io::stdout();
writeln!(&mut w, "Removed Tags:")?;
for tag in tags {
writeln!(&mut w, " {tag}")?;
}
}
}
tx.commit()?;
Ok(())
}
pub fn add_tags(conn: &mut Connection, tags: Vec<String>) -> Result<()> {
let tx = conn.transaction()?;
{
let mut query = r#"INSERT INTO tag(name) VALUES"#.to_string();
for (i, _tag) in tags.iter().enumerate() {
query.push_str("(?)");
if i < tags.len() - 1 {
query.push(',');
}
}
tx.execute(&query, params_from_iter(&tags))?;
if tx.changes() > 0 {
let mut w = io::stdout();
writeln!(&mut w, "Added Tags:")?;
for tag in tags {
writeln!(&mut w, " {tag}")?;
}
}
}
tx.commit()?;
Ok(())
}
pub fn tag_file(conn: &mut Connection, file: &PathBuf, tags: &Vec<String>) -> Result<()> {
let file = file
.to_str()
.ok_or(anyhow!("Unable to find path {:?}", file))?
.to_string();
let tx = conn.transaction()?;
{
let file_id: Option<i32> = tx
.query_row(r#"SELECT id FROM file WHERE path = ?"#, [&file], |row| {
row.get(0)
})
.ok();
let file_id = match file_id {
Some(id) => id,
None => {
let mut stmt = tx.prepare(r#"INSERT INTO file(path) VALUES (?) RETURNING id"#)?;
stmt.query_row([file], |row| -> Result<i32, rusqlite::Error> { row.get(0) })?
}
};
let mut sql = "SELECT id FROM tag WHERE name IN ".to_string();
sql.push('(');
let size = tags.len();
for (i, _tag) in tags.iter().enumerate() {
sql.push('?');
if i < size - 1 {
sql.push(',');
}
}
sql.push(')');
let mut stmt = tx.prepare(&sql)?;
let result = stmt
.query_map(
params_from_iter(tags),
|row| -> Result<i32, rusqlite::Error> { row.get(0) },
)?
.flatten();
let mut tags_ids = Vec::new();
for id in result {
tags_ids.push(id);
}
let mut stmt = tx.prepare(r#"INSERT INTO file_tag(file_id, tag_id) VALUES (?, ?)"#)?;
for tag_id in tags_ids {
stmt.execute((file_id, tag_id))?;
}
}
tx.commit()?;
Ok(())
}
pub fn untag_file(conn: &mut Connection, file: PathBuf, tags: Option<Vec<String>>) -> Result<()> {
let file = file
.to_str()
.ok_or(anyhow!("Unable to find path {:?}", file))?
.to_string();
let tx = conn.transaction()?;
{
let _file_id: i32 = tx
.query_row(r#"SELECT id FROM file WHERE path = ?"#, [&file], |row| {
row.get(0)
})
.map_err(|_| anyhow!("No {} file registered!", file))?;
let mut sql = r#"
DELETE FROM file_tag
WHERE ROWID IN (
SELECT file_tag.ROWID FROM file_tag
INNER JOIN file ON file_tag.file_id = file.id
INNER JOIN tag ON file_tag.tag_id = tag.id"#
.to_string();
let params = match tags {
Some(tags) => {
let tags = tags.join(",");
sql.push_str(" WHERE file.path = ?1 AND tag.name IN (?2))");
vec![Value::Text(file), Value::Text(tags)]
}
None => {
sql.push_str(" WHERE file.path = ?1)");
vec![Value::Text(file)]
}
};
let mut stmt = tx.prepare(&sql)?;
stmt.execute(params_from_iter(params))?;
}
tx.commit()?;
Ok(())
}
pub fn list_files(conn: &Connection, all_files: bool, query: Option<String>) -> Result<Files> {
let mut sql = r#"SELECT path, group_concat(name) as tags FROM file_tag
JOIN file ON file.id = file_tag.file_id
JOIN tag ON tag.id = file_tag.tag_id"#
.to_string();
let mut show_tags = true;
let mut paths: Vec<PathBuf> = vec![];
if all_files {
paths = fs::read_dir(".")?
.filter_map(|f| f.ok())
.filter(|f| f.metadata().is_ok() && f.metadata().unwrap().is_file())
.map(|f| f.path())
.collect();
} else if let Some(query) = query {
let path = PathBuf::from_str(&query).unwrap_or_default();
match path.exists() {
true => {
sql.push_str(&format!(" WHERE path IN ('{}')", query));
paths = vec![path];
}
false => {
sql.push_str(&format!(" WHERE tag.name LIKE '%{}%'", query));
show_tags = false;
}
};
}
let paths: Vec<String> = paths
.iter_mut()
.map(|f| f.to_string_lossy().replace("./", "").to_string())
.collect();
sql.push_str(" GROUP BY path");
let mut stmt = conn.prepare(&sql)?;
let files: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.flatten()
.collect();
Ok(Files {
files,
paths,
show_tags,
})
}

View File

@ -1,70 +0,0 @@
use anyhow::anyhow;
use clap::Parser;
use cli::CliArgs;
use rusqlite::{Connection, OpenFlags};
const DB_PATH: &str = ".tags";
mod cli;
mod tags;
fn main() -> anyhow::Result<()> {
let args = CliArgs::parse();
match args.commands {
cli::Commands::Init => init_db().map_err(|_| anyhow!("Database already exists!")),
cli::Commands::Tag {
args,
files,
file_tags,
} => tags::handle_tag(args, files, file_tags),
cli::Commands::Untag(args) => tags::handle_untag(args),
cli::Commands::Tags(args) => tags::handle_tags(args),
cli::Commands::Files(args) => tags::handle_files(args),
}
}
fn init_db() -> anyhow::Result<()> {
let conn = Connection::open(DB_PATH)?;
conn.execute(
r#"CREATE TABLE tag(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL UNIQUE
);"#,
(),
)?;
conn.execute(
r#"CREATE TABLE file(
id INTEGER PRIMARY KEY AUTOINCREMENT,
path VARCHAR(255) NOT NULL UNIQUE
);"#,
(),
)?;
conn.execute(
r#"CREATE TABLE file_tag(
file_id INTEGER REFERENCES file(id) ON DELETE CASCADE,
tag_id INTEGER REFERENCES tag(id) ON DELETE CASCADE,
PRIMARY KEY (file_id, tag_id)
);"#,
(),
)?;
Ok(())
}
pub fn try_database() -> anyhow::Result<Connection> {
let conn = Connection::open_with_flags(
DB_PATH,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|_| {
anyhow!("Database does not exist! Run `taggo init` first to generate a new database.")
})?;
Ok(conn)
}

View File

@ -1,342 +0,0 @@
use std::{
fs,
io::{self, Write},
path::PathBuf,
str::FromStr,
};
use anyhow::{Result, anyhow};
use rusqlite::{Connection, params_from_iter, types::Value};
pub const TERM_RESET: &str = "\x1b[0m";
pub const TERM_BOLD: &str = "\x1b[1m";
use crate::{
cli::{self, FilesArgs, TagArgs, TagsArgs, UntagArgs},
try_database,
};
pub fn handle_files(args: FilesArgs) -> Result<()> {
let conn = try_database()?;
let mut sql = r#"SELECT path, group_concat(name) as tags FROM file_tag
JOIN file ON file.id = file_tag.file_id
JOIN tag ON tag.id = file_tag.tag_id"#
.to_string();
let mut show_tags = true;
let mut paths: Vec<PathBuf> = vec![];
if args.all {
paths = fs::read_dir(".")?
.filter_map(|f| f.ok())
.filter(|f| f.metadata().is_ok() && f.metadata().unwrap().is_file())
.map(|f| f.path())
.collect();
} else if let Some(query) = args.query {
let path = PathBuf::from_str(&query).unwrap_or_default();
match path.exists() {
true => {
sql.push_str(&format!(" WHERE path IN ('{}')", query));
paths = vec![path];
}
false => {
sql.push_str(&format!(" WHERE tag.name LIKE '%{}%'", query));
show_tags = false;
}
};
}
let mut all_paths: Vec<String> = paths
.iter_mut()
.map(|f| f.to_string_lossy().replace("./", "").to_string())
.collect();
sql.push_str(" GROUP BY path");
let mut stmt = conn.prepare(&sql)?;
let tagged_files: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.flatten()
.collect();
let mut w = io::stdout();
if !tagged_files.is_empty() {
if args.all {
writeln!(&mut w, "{TERM_BOLD}Tagged Files: {TERM_RESET}")?;
}
for (file, tags) in tagged_files {
// Remove already tagged files
if let Some(idx) = all_paths.iter().position(|v| *v == file) {
all_paths.swap_remove(idx);
}
if show_tags {
writeln!(&mut w, "{}: {}", file, tags)?;
} else {
writeln!(&mut w, "{}", file)?;
}
}
} else {
writeln!(&mut w, "No files registered!")?;
}
if args.all && !all_paths.is_empty() {
writeln!(&mut w, "\n{TERM_BOLD}Untagged Files: {TERM_RESET}")?;
for path in all_paths {
let path_str = path;
writeln!(&mut w, "{}", path_str)?;
}
}
w.flush()?;
Ok(())
}
pub fn handle_tag(args: TagArgs, files: Vec<PathBuf>, file_tags: Vec<String>) -> Result<()> {
let mut conn = try_database()?;
if let Some(file) = args.file {
tag_file(&mut conn, &file, &args.tags)?;
} else {
// TODO: Maybe rework this at some point so its more optimized for batch insertion ?
for file in &files {
tag_file(&mut conn, file, &file_tags)?;
}
}
Ok(())
}
pub fn handle_untag(args: UntagArgs) -> Result<()> {
let mut conn = try_database()?;
untag_file(&mut conn, args.file, args.tags)?;
Ok(())
}
pub fn handle_tags(args: TagsArgs) -> Result<()> {
let mut conn = try_database()?;
match args.commands {
Some(cli::TagsCommands::List) | None => {
let mut w = io::stdout();
let tags = list_tags(&conn)?;
if !tags.is_empty() {
for tag in tags {
writeln!(&mut w, "{} - {}", tag.0, tag.1)?;
}
} else {
writeln!(&mut w, "No tags registered!")?;
}
w.flush()?;
Ok(())
}
Some(cli::TagsCommands::Add { add }) => add_tags(&mut conn, add),
Some(cli::TagsCommands::Remove { remove }) => remove_tags(&mut conn, remove),
Some(cli::TagsCommands::Rename { renames }) => rename_tag(&conn, renames),
}
}
fn rename_tag(conn: &Connection, renames: Vec<String>) -> Result<()> {
if renames.is_empty() {
return Err(anyhow!("No input found!"));
} else if renames.len() % 2 != 0 {
return Err(anyhow!(
"Attempting to rename an uneven amount of entries: {}",
renames.len()
));
}
let mut stmt = conn.prepare("UPDATE tag SET name = ? WHERE name = ?")?;
let mut i = 0;
while i < renames.len() {
// TODO: Merge this when let chaining is stabilized in 1.88
if let Some(old) = renames.get(i) {
if let Some(new) = renames.get(i + 1) {
stmt.execute([&new, &old])?;
}
}
i += 2;
}
Ok(())
}
fn list_tags(conn: &Connection) -> Result<Vec<(String, i32)>> {
let mut stmt = conn.prepare(
r#"SELECT name, COUNT(file_id) as count FROM tag
LEFT JOIN file_tag ON file_tag.tag_id = tag.id
GROUP BY name
ORDER BY count DESC"#,
)?;
let result = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.flatten();
let mut tags: Vec<(String, i32)> = Vec::new();
for entry in result {
tags.push(entry);
}
Ok(tags)
}
fn remove_tags(conn: &mut Connection, tags: Vec<String>) -> Result<()> {
let tx = conn.transaction()?;
{
let mut query = r#"DELETE FROM tag WHERE name IN ("#.to_string();
for (i, _tag) in tags.iter().enumerate() {
query.push('?');
if i < tags.len() - 1 {
query.push(',');
}
}
query.push(')');
tx.execute(&query, params_from_iter(&tags))?;
if tx.changes() > 0 {
let mut w = io::stdout();
writeln!(&mut w, "Removed Tags:")?;
for tag in tags {
writeln!(&mut w, " {tag}")?;
}
}
}
tx.commit()?;
Ok(())
}
fn add_tags(conn: &mut Connection, tags: Vec<String>) -> Result<()> {
let tx = conn.transaction()?;
{
let mut query = r#"INSERT INTO tag(name) VALUES"#.to_string();
for (i, _tag) in tags.iter().enumerate() {
query.push_str("(?)");
if i < tags.len() - 1 {
query.push(',');
}
}
tx.execute(&query, params_from_iter(&tags))?;
if tx.changes() > 0 {
let mut w = io::stdout();
writeln!(&mut w, "Added Tags:")?;
for tag in tags {
writeln!(&mut w, " {tag}")?;
}
}
}
tx.commit()?;
Ok(())
}
fn tag_file(conn: &mut Connection, file: &PathBuf, tags: &Vec<String>) -> Result<()> {
let file = file
.to_str()
.ok_or(anyhow!("Unable to find path {:?}", file))?
.to_string();
let tx = conn.transaction()?;
{
let file_id: Option<i32> = tx
.query_row(r#"SELECT id FROM file WHERE path = ?"#, [&file], |row| {
row.get(0)
})
.ok();
let file_id = match file_id {
Some(id) => id,
None => {
let mut stmt = tx.prepare(r#"INSERT INTO file(path) VALUES (?) RETURNING id"#)?;
stmt.query_row([file], |row| -> Result<i32, rusqlite::Error> { row.get(0) })?
}
};
let mut sql = "SELECT id FROM tag WHERE name IN ".to_string();
sql.push('(');
let size = tags.len();
for (i, _tag) in tags.iter().enumerate() {
sql.push('?');
if i < size - 1 {
sql.push(',');
}
}
sql.push(')');
let mut stmt = tx.prepare(&sql)?;
let result = stmt
.query_map(
params_from_iter(tags),
|row| -> Result<i32, rusqlite::Error> { row.get(0) },
)?
.flatten();
let mut tags_ids = Vec::new();
for id in result {
tags_ids.push(id);
}
let mut stmt = tx.prepare(r#"INSERT INTO file_tag(file_id, tag_id) VALUES (?, ?)"#)?;
for tag_id in tags_ids {
stmt.execute((file_id, tag_id))?;
}
}
tx.commit()?;
Ok(())
}
fn untag_file(conn: &mut Connection, file: PathBuf, tags: Option<Vec<String>>) -> Result<()> {
let file = file
.to_str()
.ok_or(anyhow!("Unable to find path {:?}", file))?
.to_string();
let tx = conn.transaction()?;
{
let _file_id: i32 = tx
.query_row(r#"SELECT id FROM file WHERE path = ?"#, [&file], |row| {
row.get(0)
})
.map_err(|_| anyhow!("No {} file registered!", file))?;
let mut sql = r#"
DELETE FROM file_tag
WHERE ROWID IN (
SELECT file_tag.ROWID FROM file_tag
INNER JOIN file ON file_tag.file_id = file.id
INNER JOIN tag ON file_tag.tag_id = tag.id"#
.to_string();
let params = match tags {
Some(tags) => {
let tags = tags.join(",");
sql.push_str(" WHERE file.path = ?1 AND tag.name IN (?2))");
vec![Value::Text(file), Value::Text(tags)]
}
None => {
sql.push_str(" WHERE file.path = ?1)");
vec![Value::Text(file)]
}
};
let mut stmt = tx.prepare(&sql)?;
stmt.execute(params_from_iter(params))?;
}
tx.commit()?;
Ok(())
}

10
tui/Cargo.toml 100644
View File

@ -0,0 +1,10 @@
[package]
name = "tui"
version = "0.1.0"
edition = "2024"
description = "tui application for file tagging"
[dependencies]
taggo = { path = "../" }
ratatui = { version = "0.29" }
anyhow = { workspace = true }

126
tui/src/app.rs 100644
View File

@ -0,0 +1,126 @@
use std::time::{Duration, Instant};
use anyhow::Result;
use ratatui::{
DefaultTerminal,
buffer::Buffer,
crossterm::event::{self, KeyCode, KeyEventKind},
layout::{Constraint::*, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{
Block, BorderType, Borders, HighlightSpacing, List, ListState, StatefulWidget, Widget,
},
};
use taggo::try_database;
const SELECTED_STYLE: Style = Style::new()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD);
pub struct App {
is_running: bool,
tick: u64,
files: taggo::Files,
list_state: ListState,
}
impl App {
pub fn new() -> Result<Self> {
let conn = try_database()?;
let files = taggo::list_files(&conn, true, None)?;
Ok(Self {
is_running: true,
tick: 0,
files,
list_state: ListState::default(),
})
}
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
let tick_rate = Duration::from_millis(20);
let mut last_tick = Instant::now();
while self.is_running {
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
self.handle_events()?;
}
if last_tick.elapsed() >= tick_rate {
self.update();
last_tick = Instant::now();
}
}
Ok(())
}
fn handle_events(&mut self) -> Result<()> {
if let ratatui::crossterm::event::Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('j') | KeyCode::Down => self.list_state.select_next(),
KeyCode::Char('k') | KeyCode::Up => self.list_state.select_previous(),
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => self.quit(),
_ => {}
}
}
}
Ok(())
}
fn update(&mut self) {
self.tick = self.tick.wrapping_add(1);
}
fn quit(&mut self) {
self.is_running = false;
}
fn render_files_list(&mut self, area: Rect, buf: &mut Buffer) {
let block = Block::new()
.borders(Borders::all())
.border_type(BorderType::Rounded);
let items: Vec<String> = self.files.files.iter().map(|f| f.0.clone()).collect();
let list = List::new(items)
.block(block)
.highlight_style(SELECTED_STYLE)
.highlight_symbol(">")
.highlight_spacing(HighlightSpacing::Always);
StatefulWidget::render(list, area, buf, &mut self.list_state);
}
}
impl Widget for &mut App {
fn render(self, area: Rect, buf: &mut Buffer) {
let layout = Layout::horizontal([Percentage(50), Percentage(50)]);
let [list_area, info_area] = layout.areas(area);
let block = Block::new()
.borders(Borders::all())
.border_type(BorderType::Rounded);
if let Some(i) = self.list_state.selected() {
let tags = self.files.files[i].clone().1;
let tags: Vec<&str> = tags.split(',').collect();
let list = List::new(tags)
.block(block)
.highlight_style(SELECTED_STYLE)
.highlight_symbol(">")
.highlight_spacing(HighlightSpacing::Always);
StatefulWidget::render(list, info_area, buf, &mut ListState::default());
}
self.render_files_list(list_area, buf);
}
}

15
tui/src/main.rs 100644
View File

@ -0,0 +1,15 @@
use anyhow::Result;
use app::App;
mod app;
fn main() -> Result<()> {
let term = ratatui::init();
let app = App::new()?;
let result = app.run(term);
ratatui::restore();
result
}