pinakes: import in parallel; various UI improvements
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I1eb47cd79cd4145c56af966f6756fe1d6a6a6964
This commit is contained in:
parent
278bcaa4b0
commit
116fe7b059
42 changed files with 4316 additions and 316 deletions
127
Cargo.lock
generated
127
Cargo.lock
generated
|
|
@ -126,6 +126,15 @@ dependencies = [
|
||||||
"derive_arbitrary",
|
"derive_arbitrary",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arc-swap"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "argon2"
|
name = "argon2"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -156,6 +165,17 @@ version = "0.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-lock"
|
||||||
|
version = "3.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
|
|
@ -297,6 +317,28 @@ dependencies = [
|
||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-server"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||||
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"bytes",
|
||||||
|
"fs-err",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base16"
|
name = "base16"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -699,6 +741,15 @@ dependencies = [
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "concurrent-queue"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-serialize"
|
name = "const-serialize"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -2006,6 +2057,27 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener"
|
||||||
|
version = "5.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"parking",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener-strategy"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fallible-iterator"
|
name = "fallible-iterator"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2198,6 +2270,16 @@ dependencies = [
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs-err"
|
||||||
|
version = "3.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fs_extra"
|
name = "fs_extra"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
|
|
@ -3911,6 +3993,26 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moka"
|
||||||
|
version = "0.12.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e"
|
||||||
|
dependencies = [
|
||||||
|
"async-lock",
|
||||||
|
"crossbeam-channel",
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"equivalent",
|
||||||
|
"event-listener",
|
||||||
|
"futures-util",
|
||||||
|
"parking_lot",
|
||||||
|
"portable-atomic",
|
||||||
|
"smallvec",
|
||||||
|
"tagptr",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moxcms"
|
name = "moxcms"
|
||||||
version = "0.7.11"
|
version = "0.7.11"
|
||||||
|
|
@ -4418,6 +4520,12 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
|
|
@ -4710,6 +4818,7 @@ dependencies = [
|
||||||
"lopdf",
|
"lopdf",
|
||||||
"matroska",
|
"matroska",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"moka",
|
||||||
"notify",
|
"notify",
|
||||||
"pinakes-plugin-api",
|
"pinakes-plugin-api",
|
||||||
"postgres-types",
|
"postgres-types",
|
||||||
|
|
@ -4753,9 +4862,11 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-server",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"governor",
|
"governor",
|
||||||
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pinakes-core",
|
"pinakes-core",
|
||||||
|
|
@ -4803,6 +4914,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
|
"futures",
|
||||||
"gray_matter",
|
"gray_matter",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|
@ -5747,6 +5859,15 @@ dependencies = [
|
||||||
"security-framework 3.5.1",
|
"security-framework 3.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pemfile"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
|
|
@ -6412,6 +6533,12 @@ dependencies = [
|
||||||
"version-compare",
|
"version-compare",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tagptr"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tao"
|
name = "tao"
|
||||||
version = "0.34.5"
|
version = "0.34.5"
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ winnow = "0.7.14"
|
||||||
# HTTP server
|
# HTTP server
|
||||||
axum = { version = "0.8.8", features = ["macros"] }
|
axum = { version = "0.8.8", features = ["macros"] }
|
||||||
tower = "0.5.3"
|
tower = "0.5.3"
|
||||||
tower-http = { version = "0.6.8", features = ["cors", "trace"] }
|
tower-http = { version = "0.6.8", features = ["cors", "trace", "set-header"] }
|
||||||
governor = "0.8.1"
|
governor = "0.8.1"
|
||||||
tower_governor = "0.6.0"
|
tower_governor = "0.6.0"
|
||||||
|
|
||||||
|
|
@ -93,6 +93,9 @@ dioxus = { version = "0.7.3", features = ["desktop", "router"] }
|
||||||
# Async trait (dyn-compatible async methods)
|
# Async trait (dyn-compatible async methods)
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# Async utilities
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
# Image processing (thumbnails)
|
# Image processing (thumbnails)
|
||||||
image = { version = "0.25.9", default-features = false, features = [
|
image = { version = "0.25.9", default-features = false, features = [
|
||||||
"jpeg",
|
"jpeg",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ image = { workspace = true }
|
||||||
tokio-util = { workspace = true }
|
tokio-util = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
argon2 = { workspace = true }
|
argon2 = { workspace = true }
|
||||||
|
moka = { version = "0.12", features = ["future"] }
|
||||||
|
|
||||||
# Plugin system
|
# Plugin system
|
||||||
pinakes-plugin-api = { path = "../pinakes-plugin-api" }
|
pinakes-plugin-api = { path = "../pinakes-plugin-api" }
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,501 @@
|
||||||
use std::collections::HashMap;
|
//! High-performance caching layer using moka.
|
||||||
|
//!
|
||||||
|
//! This module provides a comprehensive caching solution with:
|
||||||
|
//! - LRU eviction with configurable size limits
|
||||||
|
//! - TTL-based expiration
|
||||||
|
//! - Smart cache invalidation
|
||||||
|
//! - Metrics tracking (hit rate, size, evictions)
|
||||||
|
//! - Specialized caches for different data types
|
||||||
|
|
||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::RwLock;
|
use moka::future::Cache as MokaCache;
|
||||||
|
|
||||||
struct CacheEntry<V> {
|
use crate::model::MediaId;
|
||||||
value: V,
|
|
||||||
inserted_at: Instant,
|
/// Cache statistics for monitoring and debugging.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CacheStats {
|
||||||
|
pub hits: u64,
|
||||||
|
pub misses: u64,
|
||||||
|
pub evictions: u64,
|
||||||
|
pub size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A simple TTL-based in-memory cache with periodic eviction.
|
impl CacheStats {
|
||||||
pub struct Cache<K, V> {
|
pub fn hit_rate(&self) -> f64 {
|
||||||
entries: Arc<RwLock<HashMap<K, CacheEntry<V>>>>,
|
let total = self.hits + self.misses;
|
||||||
ttl: Duration,
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.hits as f64 / total as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic counters for cache metrics.
|
||||||
|
struct CacheMetrics {
|
||||||
|
hits: AtomicU64,
|
||||||
|
misses: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CacheMetrics {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hits: AtomicU64::new(0),
|
||||||
|
misses: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheMetrics {
|
||||||
|
fn record_hit(&self) {
|
||||||
|
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_miss(&self) {
|
||||||
|
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stats(&self) -> (u64, u64) {
|
||||||
|
(
|
||||||
|
self.hits.load(Ordering::Relaxed),
|
||||||
|
self.misses.load(Ordering::Relaxed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A high-performance cache with LRU eviction and TTL support.
|
||||||
|
pub struct Cache<K, V>
|
||||||
|
where
|
||||||
|
K: Hash + Eq + Send + Sync + 'static,
|
||||||
|
V: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
inner: MokaCache<K, V>,
|
||||||
|
metrics: Arc<CacheMetrics>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<K, V> Cache<K, V>
|
impl<K, V> Cache<K, V>
|
||||||
where
|
where
|
||||||
K: Eq + Hash + Clone + Send + Sync + 'static,
|
K: Hash + Eq + Send + Sync + 'static,
|
||||||
V: Clone + Send + Sync + 'static,
|
V: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
pub fn new(ttl: Duration) -> Self {
|
/// Create a new cache with the specified TTL and maximum capacity.
|
||||||
let cache = Self {
|
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
||||||
entries: Arc::new(RwLock::new(HashMap::new())),
|
let inner = MokaCache::builder()
|
||||||
ttl,
|
.time_to_live(ttl)
|
||||||
};
|
.max_capacity(max_capacity)
|
||||||
|
.build();
|
||||||
|
|
||||||
// Spawn periodic eviction task
|
Self {
|
||||||
let entries = cache.entries.clone();
|
inner,
|
||||||
let ttl = cache.ttl;
|
metrics: Arc::new(CacheMetrics::default()),
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(ttl);
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut map = entries.write().await;
|
|
||||||
map.retain(|_, entry| now.duration_since(entry.inserted_at) < ttl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cache
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get(&self, key: &K) -> Option<V> {
|
|
||||||
let map = self.entries.read().await;
|
|
||||||
if let Some(entry) = map.get(key)
|
|
||||||
&& entry.inserted_at.elapsed() < self.ttl
|
|
||||||
{
|
|
||||||
return Some(entry.value.clone());
|
|
||||||
}
|
}
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new cache with TTL, max capacity, and time-to-idle.
|
||||||
|
pub fn new_with_idle(ttl: Duration, tti: Duration, max_capacity: u64) -> Self {
|
||||||
|
let inner = MokaCache::builder()
|
||||||
|
.time_to_live(ttl)
|
||||||
|
.time_to_idle(tti)
|
||||||
|
.max_capacity(max_capacity)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
metrics: Arc::new(CacheMetrics::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a value from the cache.
|
||||||
|
pub async fn get(&self, key: &K) -> Option<V> {
|
||||||
|
match self.inner.get(key).await {
|
||||||
|
Some(value) => {
|
||||||
|
self.metrics.record_hit();
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.metrics.record_miss();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a value into the cache.
|
||||||
pub async fn insert(&self, key: K, value: V) {
|
pub async fn insert(&self, key: K, value: V) {
|
||||||
let mut map = self.entries.write().await;
|
self.inner.insert(key, value).await;
|
||||||
map.insert(
|
|
||||||
key,
|
|
||||||
CacheEntry {
|
|
||||||
value,
|
|
||||||
inserted_at: Instant::now(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a specific key from the cache.
|
||||||
pub async fn invalidate(&self, key: &K) {
|
pub async fn invalidate(&self, key: &K) {
|
||||||
let mut map = self.entries.write().await;
|
self.inner.invalidate(key).await;
|
||||||
map.remove(key);
|
}
|
||||||
|
|
||||||
|
/// Clear all entries from the cache.
|
||||||
|
pub async fn invalidate_all(&self) {
|
||||||
|
self.inner.invalidate_all();
|
||||||
|
// Run pending tasks to ensure immediate invalidation
|
||||||
|
self.inner.run_pending_tasks().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current number of entries in the cache.
|
||||||
|
pub fn entry_count(&self) -> u64 {
|
||||||
|
self.inner.entry_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cache statistics.
|
||||||
|
pub fn stats(&self) -> CacheStats {
|
||||||
|
let (hits, misses) = self.metrics.stats();
|
||||||
|
CacheStats {
|
||||||
|
hits,
|
||||||
|
misses,
|
||||||
|
evictions: 0, // Moka doesn't expose this directly
|
||||||
|
size: self.entry_count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specialized cache for search query results.
|
||||||
|
pub struct QueryCache {
|
||||||
|
/// Cache keyed by (query_hash, offset, limit)
|
||||||
|
inner: Cache<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryCache {
|
||||||
|
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Cache::new(ttl, max_capacity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a cache key from query parameters.
|
||||||
|
fn make_key(query: &str, offset: u64, limit: u64, sort: Option<&str>) -> String {
|
||||||
|
use std::hash::{DefaultHasher, Hasher};
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
hasher.write(query.as_bytes());
|
||||||
|
hasher.write(&offset.to_le_bytes());
|
||||||
|
hasher.write(&limit.to_le_bytes());
|
||||||
|
if let Some(s) = sort {
|
||||||
|
hasher.write(s.as_bytes());
|
||||||
|
}
|
||||||
|
format!("q:{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
offset: u64,
|
||||||
|
limit: u64,
|
||||||
|
sort: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let key = Self::make_key(query, offset, limit, sort);
|
||||||
|
self.inner.get(&key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
offset: u64,
|
||||||
|
limit: u64,
|
||||||
|
sort: Option<&str>,
|
||||||
|
result: String,
|
||||||
|
) {
|
||||||
|
let key = Self::make_key(query, offset, limit, sort);
|
||||||
|
self.inner.insert(key, result).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn invalidate_all(&self) {
|
pub async fn invalidate_all(&self) {
|
||||||
let mut map = self.entries.write().await;
|
self.inner.invalidate_all().await;
|
||||||
map.clear();
|
}
|
||||||
|
|
||||||
|
pub fn stats(&self) -> CacheStats {
|
||||||
|
self.inner.stats()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Application-level cache layer wrapping multiple caches for different data types.
|
/// Specialized cache for metadata extraction results.
|
||||||
pub struct CacheLayer {
|
pub struct MetadataCache {
|
||||||
/// Cache for serialized API responses, keyed by request path + query string.
|
/// Cache keyed by content hash
|
||||||
pub responses: Cache<String, String>,
|
inner: Cache<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CacheLayer {
|
impl MetadataCache {
|
||||||
pub fn new(ttl_secs: u64) -> Self {
|
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
||||||
let ttl = Duration::from_secs(ttl_secs);
|
|
||||||
Self {
|
Self {
|
||||||
responses: Cache::new(ttl),
|
inner: Cache::new(ttl, max_capacity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, content_hash: &str) -> Option<String> {
|
||||||
|
self.inner.get(&content_hash.to_string()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(&self, content_hash: &str, metadata_json: String) {
|
||||||
|
self.inner
|
||||||
|
.insert(content_hash.to_string(), metadata_json)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invalidate(&self, content_hash: &str) {
|
||||||
|
self.inner.invalidate(&content_hash.to_string()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stats(&self) -> CacheStats {
|
||||||
|
self.inner.stats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specialized cache for media item data.
|
||||||
|
pub struct MediaCache {
|
||||||
|
inner: Cache<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaCache {
|
||||||
|
pub fn new(ttl: Duration, max_capacity: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Cache::new(ttl, max_capacity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, media_id: MediaId) -> Option<String> {
|
||||||
|
self.inner.get(&media_id.to_string()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(&self, media_id: MediaId, item_json: String) {
|
||||||
|
self.inner.insert(media_id.to_string(), item_json).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invalidate(&self, media_id: MediaId) {
|
||||||
|
self.inner.invalidate(&media_id.to_string()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invalidate_all(&self) {
|
||||||
|
self.inner.invalidate_all().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stats(&self) -> CacheStats {
|
||||||
|
self.inner.stats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for the cache layer.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CacheConfig {
|
||||||
|
/// TTL for response cache in seconds
|
||||||
|
pub response_ttl_secs: u64,
|
||||||
|
/// Maximum number of cached responses
|
||||||
|
pub response_max_entries: u64,
|
||||||
|
/// TTL for query cache in seconds
|
||||||
|
pub query_ttl_secs: u64,
|
||||||
|
/// Maximum number of cached query results
|
||||||
|
pub query_max_entries: u64,
|
||||||
|
/// TTL for metadata cache in seconds
|
||||||
|
pub metadata_ttl_secs: u64,
|
||||||
|
/// Maximum number of cached metadata entries
|
||||||
|
pub metadata_max_entries: u64,
|
||||||
|
/// TTL for media cache in seconds
|
||||||
|
pub media_ttl_secs: u64,
|
||||||
|
/// Maximum number of cached media items
|
||||||
|
pub media_max_entries: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CacheConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
response_ttl_secs: 60,
|
||||||
|
response_max_entries: 1000,
|
||||||
|
query_ttl_secs: 300,
|
||||||
|
query_max_entries: 500,
|
||||||
|
metadata_ttl_secs: 3600,
|
||||||
|
metadata_max_entries: 10000,
|
||||||
|
media_ttl_secs: 300,
|
||||||
|
media_max_entries: 5000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Application-level cache layer wrapping multiple specialized caches.
|
||||||
|
pub struct CacheLayer {
|
||||||
|
/// Cache for serialized API responses
|
||||||
|
pub responses: Cache<String, String>,
|
||||||
|
/// Cache for search query results
|
||||||
|
pub queries: QueryCache,
|
||||||
|
/// Cache for metadata extraction results
|
||||||
|
pub metadata: MetadataCache,
|
||||||
|
/// Cache for individual media items
|
||||||
|
pub media: MediaCache,
|
||||||
|
/// Configuration
|
||||||
|
config: CacheConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheLayer {
|
||||||
|
/// Create a new cache layer with the specified TTL (using defaults for other settings).
|
||||||
|
pub fn new(ttl_secs: u64) -> Self {
|
||||||
|
let config = CacheConfig {
|
||||||
|
response_ttl_secs: ttl_secs,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Self::with_config(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new cache layer with full configuration.
|
||||||
|
pub fn with_config(config: CacheConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
responses: Cache::new(
|
||||||
|
Duration::from_secs(config.response_ttl_secs),
|
||||||
|
config.response_max_entries,
|
||||||
|
),
|
||||||
|
queries: QueryCache::new(
|
||||||
|
Duration::from_secs(config.query_ttl_secs),
|
||||||
|
config.query_max_entries,
|
||||||
|
),
|
||||||
|
metadata: MetadataCache::new(
|
||||||
|
Duration::from_secs(config.metadata_ttl_secs),
|
||||||
|
config.metadata_max_entries,
|
||||||
|
),
|
||||||
|
media: MediaCache::new(
|
||||||
|
Duration::from_secs(config.media_ttl_secs),
|
||||||
|
config.media_max_entries,
|
||||||
|
),
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate all caches related to a media item update.
|
||||||
|
pub async fn invalidate_for_media_update(&self, media_id: MediaId) {
|
||||||
|
self.media.invalidate(media_id).await;
|
||||||
|
// Query cache should be invalidated as search results may change
|
||||||
|
self.queries.invalidate_all().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate all caches related to a media item deletion.
|
||||||
|
pub async fn invalidate_for_media_delete(&self, media_id: MediaId) {
|
||||||
|
self.media.invalidate(media_id).await;
|
||||||
|
self.queries.invalidate_all().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate all caches (useful after bulk imports or major changes).
|
||||||
|
pub async fn invalidate_all(&self) {
|
||||||
|
self.responses.invalidate_all().await;
|
||||||
|
self.queries.invalidate_all().await;
|
||||||
|
self.media.invalidate_all().await;
|
||||||
|
// Keep metadata cache as it's keyed by content hash which doesn't change
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get aggregated statistics for all caches.
|
||||||
|
pub fn stats(&self) -> CacheLayerStats {
|
||||||
|
CacheLayerStats {
|
||||||
|
responses: self.responses.stats(),
|
||||||
|
queries: self.queries.stats(),
|
||||||
|
metadata: self.metadata.stats(),
|
||||||
|
media: self.media.stats(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current configuration.
|
||||||
|
pub fn config(&self) -> &CacheConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated statistics for the entire cache layer.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CacheLayerStats {
|
||||||
|
pub responses: CacheStats,
|
||||||
|
pub queries: CacheStats,
|
||||||
|
pub metadata: CacheStats,
|
||||||
|
pub media: CacheStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheLayerStats {
|
||||||
|
/// Get the overall hit rate across all caches.
|
||||||
|
pub fn overall_hit_rate(&self) -> f64 {
|
||||||
|
let total_hits =
|
||||||
|
self.responses.hits + self.queries.hits + self.metadata.hits + self.media.hits;
|
||||||
|
let total_requests = total_hits
|
||||||
|
+ self.responses.misses
|
||||||
|
+ self.queries.misses
|
||||||
|
+ self.metadata.misses
|
||||||
|
+ self.media.misses;
|
||||||
|
|
||||||
|
if total_requests == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
total_hits as f64 / total_requests as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the total number of entries across all caches.
|
||||||
|
pub fn total_entries(&self) -> u64 {
|
||||||
|
self.responses.size + self.queries.size + self.metadata.size + self.media.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_basic_operations() {
|
||||||
|
let cache: Cache<String, String> = Cache::new(Duration::from_secs(60), 100);
|
||||||
|
|
||||||
|
// Insert and get
|
||||||
|
cache.insert("key1".to_string(), "value1".to_string()).await;
|
||||||
|
assert_eq!(
|
||||||
|
cache.get(&"key1".to_string()).await,
|
||||||
|
Some("value1".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Miss
|
||||||
|
assert_eq!(cache.get(&"key2".to_string()).await, None);
|
||||||
|
|
||||||
|
// Invalidate
|
||||||
|
cache.invalidate(&"key1".to_string()).await;
|
||||||
|
assert_eq!(cache.get(&"key1".to_string()).await, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_stats() {
|
||||||
|
let cache: Cache<String, String> = Cache::new(Duration::from_secs(60), 100);
|
||||||
|
|
||||||
|
cache.insert("key1".to_string(), "value1".to_string()).await;
|
||||||
|
let _ = cache.get(&"key1".to_string()).await; // hit
|
||||||
|
let _ = cache.get(&"key2".to_string()).await; // miss
|
||||||
|
|
||||||
|
let stats = cache.stats();
|
||||||
|
assert_eq!(stats.hits, 1);
|
||||||
|
assert_eq!(stats.misses, 1);
|
||||||
|
assert!((stats.hit_rate() - 0.5).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_query_cache() {
|
||||||
|
let cache = QueryCache::new(Duration::from_secs(60), 100);
|
||||||
|
|
||||||
|
cache
|
||||||
|
.insert("test query", 0, 10, Some("name"), "results".to_string())
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
cache.get("test query", 0, 10, Some("name")).await,
|
||||||
|
Some("results".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Different parameters should miss
|
||||||
|
assert_eq!(cache.get("test query", 10, 10, Some("name")).await, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_layer() {
|
||||||
|
let layer = CacheLayer::new(60);
|
||||||
|
|
||||||
|
let media_id = MediaId::new();
|
||||||
|
layer.media.insert(media_id, "{}".to_string()).await;
|
||||||
|
assert!(layer.media.get(media_id).await.is_some());
|
||||||
|
|
||||||
|
layer.invalidate_for_media_delete(media_id).await;
|
||||||
|
assert!(layer.media.get(media_id).await.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,85 @@ pub struct ServerConfig {
|
||||||
/// If set, all requests (except /health) must include `Authorization: Bearer <key>`.
|
/// If set, all requests (except /health) must include `Authorization: Bearer <key>`.
|
||||||
/// Can also be set via `PINAKES_API_KEY` environment variable.
|
/// Can also be set via `PINAKES_API_KEY` environment variable.
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
|
/// TLS/HTTPS configuration
|
||||||
|
#[serde(default)]
|
||||||
|
pub tls: TlsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TLS/HTTPS configuration for secure connections
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TlsConfig {
|
||||||
|
/// Enable TLS (HTTPS)
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Path to the TLS certificate file (PEM format)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cert_path: Option<PathBuf>,
|
||||||
|
/// Path to the TLS private key file (PEM format)
|
||||||
|
#[serde(default)]
|
||||||
|
pub key_path: Option<PathBuf>,
|
||||||
|
/// Enable HTTP to HTTPS redirect (starts a second listener on http_port)
|
||||||
|
#[serde(default)]
|
||||||
|
pub redirect_http: bool,
|
||||||
|
/// Port for HTTP redirect listener (default: 80)
|
||||||
|
#[serde(default = "default_http_port")]
|
||||||
|
pub http_port: u16,
|
||||||
|
/// Enable HSTS (HTTP Strict Transport Security) header
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub hsts_enabled: bool,
|
||||||
|
/// HSTS max-age in seconds (default: 1 year)
|
||||||
|
#[serde(default = "default_hsts_max_age")]
|
||||||
|
pub hsts_max_age: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_http_port() -> u16 {
|
||||||
|
80
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_hsts_max_age() -> u64 {
|
||||||
|
31536000 // 1 year in seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TlsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
cert_path: None,
|
||||||
|
key_path: None,
|
||||||
|
redirect_http: false,
|
||||||
|
http_port: default_http_port(),
|
||||||
|
hsts_enabled: true,
|
||||||
|
hsts_max_age: default_hsts_max_age(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TlsConfig {
|
||||||
|
/// Validate TLS configuration
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.enabled {
|
||||||
|
if self.cert_path.is_none() {
|
||||||
|
return Err("TLS enabled but cert_path not specified".into());
|
||||||
|
}
|
||||||
|
if self.key_path.is_none() {
|
||||||
|
return Err("TLS enabled but key_path not specified".into());
|
||||||
|
}
|
||||||
|
if let Some(ref cert_path) = self.cert_path {
|
||||||
|
if !cert_path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"TLS certificate file not found: {}",
|
||||||
|
cert_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref key_path) = self.key_path {
|
||||||
|
if !key_path.exists() {
|
||||||
|
return Err(format!("TLS key file not found: {}", key_path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
|
@ -564,6 +643,8 @@ impl Config {
|
||||||
if self.scanning.import_concurrency == 0 || self.scanning.import_concurrency > 256 {
|
if self.scanning.import_concurrency == 0 || self.scanning.import_concurrency > 256 {
|
||||||
return Err("import_concurrency must be between 1 and 256".into());
|
return Err("import_concurrency must be between 1 and 256".into());
|
||||||
}
|
}
|
||||||
|
// Validate TLS configuration
|
||||||
|
self.server.tls.validate()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -609,6 +690,7 @@ impl Default for Config {
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: 3000,
|
port: 3000,
|
||||||
api_key: None,
|
api_key: None,
|
||||||
|
tls: TlsConfig::default(),
|
||||||
},
|
},
|
||||||
ui: UiConfig::default(),
|
ui: UiConfig::default(),
|
||||||
accounts: AccountsConfig::default(),
|
accounts: AccountsConfig::default(),
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ pub enum PinakesError {
|
||||||
|
|
||||||
#[error("authorization error: {0}")]
|
#[error("authorization error: {0}")]
|
||||||
Authorization(String),
|
Authorization(String),
|
||||||
|
|
||||||
|
#[error("path not allowed: {0}")]
|
||||||
|
PathNotAllowed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<rusqlite::Error> for PinakesError {
|
impl From<rusqlite::Error> for PinakesError {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
|
@ -14,9 +15,29 @@ use crate::thumbnail;
|
||||||
pub struct ImportResult {
|
pub struct ImportResult {
|
||||||
pub media_id: MediaId,
|
pub media_id: MediaId,
|
||||||
pub was_duplicate: bool,
|
pub was_duplicate: bool,
|
||||||
|
/// True if the file was skipped because it hasn't changed since last scan
|
||||||
|
pub was_skipped: bool,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Options for import operations
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ImportOptions {
|
||||||
|
/// Skip files that haven't changed since last scan (based on mtime)
|
||||||
|
pub incremental: bool,
|
||||||
|
/// Force re-import even if mtime hasn't changed
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the modification time of a file as a Unix timestamp
|
||||||
|
fn get_file_mtime(path: &Path) -> Option<i64> {
|
||||||
|
std::fs::metadata(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check that a canonicalized path falls under at least one configured root directory.
|
/// Check that a canonicalized path falls under at least one configured root directory.
|
||||||
/// If no roots are configured, all paths are allowed (for ad-hoc imports).
|
/// If no roots are configured, all paths are allowed (for ad-hoc imports).
|
||||||
pub async fn validate_path_in_roots(storage: &DynStorageBackend, path: &Path) -> Result<()> {
|
pub async fn validate_path_in_roots(storage: &DynStorageBackend, path: &Path) -> Result<()> {
|
||||||
|
|
@ -38,6 +59,15 @@ pub async fn validate_path_in_roots(storage: &DynStorageBackend, path: &Path) ->
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn import_file(storage: &DynStorageBackend, path: &Path) -> Result<ImportResult> {
|
pub async fn import_file(storage: &DynStorageBackend, path: &Path) -> Result<ImportResult> {
|
||||||
|
import_file_with_options(storage, path, &ImportOptions::default()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a file with configurable options for incremental scanning
|
||||||
|
pub async fn import_file_with_options(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
path: &Path,
|
||||||
|
options: &ImportOptions,
|
||||||
|
) -> Result<ImportResult> {
|
||||||
let path = path.canonicalize()?;
|
let path = path.canonicalize()?;
|
||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|
@ -49,12 +79,38 @@ pub async fn import_file(storage: &DynStorageBackend, path: &Path) -> Result<Imp
|
||||||
let media_type = MediaType::from_path(&path)
|
let media_type = MediaType::from_path(&path)
|
||||||
.ok_or_else(|| PinakesError::UnsupportedMediaType(path.clone()))?;
|
.ok_or_else(|| PinakesError::UnsupportedMediaType(path.clone()))?;
|
||||||
|
|
||||||
|
let current_mtime = get_file_mtime(&path);
|
||||||
|
|
||||||
|
// Check for incremental scan: skip if file hasn't changed
|
||||||
|
if options.incremental && !options.force {
|
||||||
|
if let Some(existing) = storage.get_media_by_path(&path).await? {
|
||||||
|
// Compare mtimes - if they match, skip this file
|
||||||
|
if let (Some(stored_mtime), Some(curr_mtime)) = (existing.file_mtime, current_mtime) {
|
||||||
|
if stored_mtime == curr_mtime {
|
||||||
|
return Ok(ImportResult {
|
||||||
|
media_id: existing.id,
|
||||||
|
was_duplicate: false,
|
||||||
|
was_skipped: true,
|
||||||
|
path: path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let content_hash = compute_file_hash(&path).await?;
|
let content_hash = compute_file_hash(&path).await?;
|
||||||
|
|
||||||
if let Some(existing) = storage.get_media_by_hash(&content_hash).await? {
|
if let Some(existing) = storage.get_media_by_hash(&content_hash).await? {
|
||||||
|
// Update the mtime even for duplicates so incremental scan works
|
||||||
|
if current_mtime.is_some() && existing.file_mtime != current_mtime {
|
||||||
|
let mut updated = existing.clone();
|
||||||
|
updated.file_mtime = current_mtime;
|
||||||
|
let _ = storage.update_media(&updated).await;
|
||||||
|
}
|
||||||
return Ok(ImportResult {
|
return Ok(ImportResult {
|
||||||
media_id: existing.id,
|
media_id: existing.id,
|
||||||
was_duplicate: true,
|
was_duplicate: true,
|
||||||
|
was_skipped: false,
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +165,7 @@ pub async fn import_file(storage: &DynStorageBackend, path: &Path) -> Result<Imp
|
||||||
description: extracted.description,
|
description: extracted.description,
|
||||||
thumbnail_path: thumb_path,
|
thumbnail_path: thumb_path,
|
||||||
custom_fields: std::collections::HashMap::new(),
|
custom_fields: std::collections::HashMap::new(),
|
||||||
|
file_mtime: current_mtime,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
@ -144,6 +201,7 @@ pub async fn import_file(storage: &DynStorageBackend, path: &Path) -> Result<Imp
|
||||||
Ok(ImportResult {
|
Ok(ImportResult {
|
||||||
media_id,
|
media_id,
|
||||||
was_duplicate: false,
|
was_duplicate: false,
|
||||||
|
was_skipped: false,
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -180,8 +238,14 @@ pub async fn import_directory(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
) -> Result<Vec<std::result::Result<ImportResult, PinakesError>>> {
|
) -> Result<Vec<std::result::Result<ImportResult, PinakesError>>> {
|
||||||
import_directory_with_concurrency(storage, dir, ignore_patterns, DEFAULT_IMPORT_CONCURRENCY)
|
import_directory_with_options(
|
||||||
.await
|
storage,
|
||||||
|
dir,
|
||||||
|
ignore_patterns,
|
||||||
|
DEFAULT_IMPORT_CONCURRENCY,
|
||||||
|
&ImportOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn import_directory_with_concurrency(
|
pub async fn import_directory_with_concurrency(
|
||||||
|
|
@ -189,10 +253,29 @@ pub async fn import_directory_with_concurrency(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
concurrency: usize,
|
concurrency: usize,
|
||||||
|
) -> Result<Vec<std::result::Result<ImportResult, PinakesError>>> {
|
||||||
|
import_directory_with_options(
|
||||||
|
storage,
|
||||||
|
dir,
|
||||||
|
ignore_patterns,
|
||||||
|
concurrency,
|
||||||
|
&ImportOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a directory with full options including incremental scanning support
|
||||||
|
pub async fn import_directory_with_options(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
dir: &Path,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
concurrency: usize,
|
||||||
|
options: &ImportOptions,
|
||||||
) -> Result<Vec<std::result::Result<ImportResult, PinakesError>>> {
|
) -> Result<Vec<std::result::Result<ImportResult, PinakesError>>> {
|
||||||
let concurrency = concurrency.clamp(1, 256);
|
let concurrency = concurrency.clamp(1, 256);
|
||||||
let dir = dir.to_path_buf();
|
let dir = dir.to_path_buf();
|
||||||
let patterns = ignore_patterns.to_vec();
|
let patterns = ignore_patterns.to_vec();
|
||||||
|
let options = options.clone();
|
||||||
|
|
||||||
let entries: Vec<PathBuf> = {
|
let entries: Vec<PathBuf> = {
|
||||||
let dir = dir.clone();
|
let dir = dir.clone();
|
||||||
|
|
@ -213,15 +296,14 @@ pub async fn import_directory_with_concurrency(
|
||||||
|
|
||||||
let mut results = Vec::with_capacity(entries.len());
|
let mut results = Vec::with_capacity(entries.len());
|
||||||
let mut join_set = tokio::task::JoinSet::new();
|
let mut join_set = tokio::task::JoinSet::new();
|
||||||
let mut pending_paths: Vec<PathBuf> = Vec::new();
|
|
||||||
|
|
||||||
for entry_path in entries {
|
for entry_path in entries {
|
||||||
let storage = storage.clone();
|
let storage = storage.clone();
|
||||||
let path = entry_path.clone();
|
let path = entry_path.clone();
|
||||||
pending_paths.push(entry_path);
|
let opts = options.clone();
|
||||||
|
|
||||||
join_set.spawn(async move {
|
join_set.spawn(async move {
|
||||||
let result = import_file(&storage, &path).await;
|
let result = import_file_with_options(&storage, &path, &opts).await;
|
||||||
(path, result)
|
(path, result)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -231,4 +231,41 @@ impl JobQueue {
|
||||||
job.updated_at = Utc::now();
|
job.updated_at = Utc::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get job queue statistics
|
||||||
|
pub async fn stats(&self) -> JobQueueStats {
|
||||||
|
let jobs = self.jobs.read().await;
|
||||||
|
let mut pending = 0;
|
||||||
|
let mut running = 0;
|
||||||
|
let mut completed = 0;
|
||||||
|
let mut failed = 0;
|
||||||
|
|
||||||
|
for job in jobs.values() {
|
||||||
|
match job.status {
|
||||||
|
JobStatus::Pending => pending += 1,
|
||||||
|
JobStatus::Running { .. } => running += 1,
|
||||||
|
JobStatus::Completed { .. } => completed += 1,
|
||||||
|
JobStatus::Failed { .. } => failed += 1,
|
||||||
|
JobStatus::Cancelled => {} // Don't count cancelled jobs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JobQueueStats {
|
||||||
|
pending,
|
||||||
|
running,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
total: jobs.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics about the job queue
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct JobQueueStats {
|
||||||
|
pub pending: usize,
|
||||||
|
pub running: usize,
|
||||||
|
pub completed: usize,
|
||||||
|
pub failed: usize,
|
||||||
|
pub total: usize,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ pub mod media_type;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod opener;
|
pub mod opener;
|
||||||
|
pub mod path_validation;
|
||||||
pub mod playlists;
|
pub mod playlists;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ pub struct MediaItem {
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub thumbnail_path: Option<PathBuf>,
|
pub thumbnail_path: Option<PathBuf>,
|
||||||
pub custom_fields: HashMap<String, CustomField>,
|
pub custom_fields: HashMap<String, CustomField>,
|
||||||
|
/// File modification time (Unix timestamp in seconds), used for incremental scanning
|
||||||
|
pub file_mtime: Option<i64>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
@ -126,6 +128,7 @@ pub struct AuditEntry {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AuditAction {
|
pub enum AuditAction {
|
||||||
|
// Media actions
|
||||||
Imported,
|
Imported,
|
||||||
Updated,
|
Updated,
|
||||||
Deleted,
|
Deleted,
|
||||||
|
|
@ -135,11 +138,50 @@ pub enum AuditAction {
|
||||||
RemovedFromCollection,
|
RemovedFromCollection,
|
||||||
Opened,
|
Opened,
|
||||||
Scanned,
|
Scanned,
|
||||||
|
|
||||||
|
// Authentication actions
|
||||||
|
LoginSuccess,
|
||||||
|
LoginFailed,
|
||||||
|
Logout,
|
||||||
|
SessionExpired,
|
||||||
|
|
||||||
|
// Authorization actions
|
||||||
|
PermissionDenied,
|
||||||
|
RoleChanged,
|
||||||
|
LibraryAccessGranted,
|
||||||
|
LibraryAccessRevoked,
|
||||||
|
|
||||||
|
// User management
|
||||||
|
UserCreated,
|
||||||
|
UserUpdated,
|
||||||
|
UserDeleted,
|
||||||
|
|
||||||
|
// Plugin actions
|
||||||
|
PluginInstalled,
|
||||||
|
PluginUninstalled,
|
||||||
|
PluginEnabled,
|
||||||
|
PluginDisabled,
|
||||||
|
|
||||||
|
// Configuration actions
|
||||||
|
ConfigChanged,
|
||||||
|
RootDirectoryAdded,
|
||||||
|
RootDirectoryRemoved,
|
||||||
|
|
||||||
|
// Social/Sharing actions
|
||||||
|
ShareLinkCreated,
|
||||||
|
ShareLinkAccessed,
|
||||||
|
|
||||||
|
// System actions
|
||||||
|
DatabaseVacuumed,
|
||||||
|
DatabaseCleared,
|
||||||
|
ExportCompleted,
|
||||||
|
IntegrityCheckCompleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for AuditAction {
|
impl fmt::Display for AuditAction {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
let s = match self {
|
let s = match self {
|
||||||
|
// Media actions
|
||||||
Self::Imported => "imported",
|
Self::Imported => "imported",
|
||||||
Self::Updated => "updated",
|
Self::Updated => "updated",
|
||||||
Self::Deleted => "deleted",
|
Self::Deleted => "deleted",
|
||||||
|
|
@ -149,6 +191,44 @@ impl fmt::Display for AuditAction {
|
||||||
Self::RemovedFromCollection => "removed_from_collection",
|
Self::RemovedFromCollection => "removed_from_collection",
|
||||||
Self::Opened => "opened",
|
Self::Opened => "opened",
|
||||||
Self::Scanned => "scanned",
|
Self::Scanned => "scanned",
|
||||||
|
|
||||||
|
// Authentication actions
|
||||||
|
Self::LoginSuccess => "login_success",
|
||||||
|
Self::LoginFailed => "login_failed",
|
||||||
|
Self::Logout => "logout",
|
||||||
|
Self::SessionExpired => "session_expired",
|
||||||
|
|
||||||
|
// Authorization actions
|
||||||
|
Self::PermissionDenied => "permission_denied",
|
||||||
|
Self::RoleChanged => "role_changed",
|
||||||
|
Self::LibraryAccessGranted => "library_access_granted",
|
||||||
|
Self::LibraryAccessRevoked => "library_access_revoked",
|
||||||
|
|
||||||
|
// User management
|
||||||
|
Self::UserCreated => "user_created",
|
||||||
|
Self::UserUpdated => "user_updated",
|
||||||
|
Self::UserDeleted => "user_deleted",
|
||||||
|
|
||||||
|
// Plugin actions
|
||||||
|
Self::PluginInstalled => "plugin_installed",
|
||||||
|
Self::PluginUninstalled => "plugin_uninstalled",
|
||||||
|
Self::PluginEnabled => "plugin_enabled",
|
||||||
|
Self::PluginDisabled => "plugin_disabled",
|
||||||
|
|
||||||
|
// Configuration actions
|
||||||
|
Self::ConfigChanged => "config_changed",
|
||||||
|
Self::RootDirectoryAdded => "root_directory_added",
|
||||||
|
Self::RootDirectoryRemoved => "root_directory_removed",
|
||||||
|
|
||||||
|
// Social/Sharing actions
|
||||||
|
Self::ShareLinkCreated => "share_link_created",
|
||||||
|
Self::ShareLinkAccessed => "share_link_accessed",
|
||||||
|
|
||||||
|
// System actions
|
||||||
|
Self::DatabaseVacuumed => "database_vacuumed",
|
||||||
|
Self::DatabaseCleared => "database_cleared",
|
||||||
|
Self::ExportCompleted => "export_completed",
|
||||||
|
Self::IntegrityCheckCompleted => "integrity_check_completed",
|
||||||
};
|
};
|
||||||
write!(f, "{s}")
|
write!(f, "{s}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
310
crates/pinakes-core/src/path_validation.rs
Normal file
310
crates/pinakes-core/src/path_validation.rs
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
//! Path validation utilities to prevent path traversal attacks.
|
||||||
|
//!
|
||||||
|
//! This module provides functions to validate and sanitize file paths,
|
||||||
|
//! ensuring they remain within allowed root directories and don't contain
|
||||||
|
//! malicious path traversal sequences.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
|
||||||
|
/// Validates that a path is within one of the allowed root directories.
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Canonicalizes the path to resolve any symlinks and `..` sequences
|
||||||
|
/// 2. Checks that the canonical path starts with one of the allowed roots
|
||||||
|
/// 3. Returns the canonical path if valid, or an error if not
|
||||||
|
///
|
||||||
|
/// # Security
|
||||||
|
///
|
||||||
|
/// This prevents path traversal attacks where an attacker might try to
|
||||||
|
/// access files outside the allowed directories using sequences like:
|
||||||
|
/// - `../../../etc/passwd`
|
||||||
|
/// - `/media/../../../etc/passwd`
|
||||||
|
/// - Symlinks pointing outside allowed roots
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - The path to validate
|
||||||
|
/// * `allowed_roots` - List of allowed root directories
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The canonicalized path if valid, or a `PathNotAllowed` error if the path
|
||||||
|
/// is outside all allowed roots.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use std::path::PathBuf;
|
||||||
|
/// use pinakes_core::path_validation::validate_path;
|
||||||
|
///
|
||||||
|
/// let allowed_roots = vec![PathBuf::from("/media"), PathBuf::from("/home/user/documents")];
|
||||||
|
/// let path = PathBuf::from("/media/music/song.mp3");
|
||||||
|
///
|
||||||
|
/// let validated = validate_path(&path, &allowed_roots).unwrap();
|
||||||
|
/// ```
|
||||||
|
pub fn validate_path(path: &Path, allowed_roots: &[PathBuf]) -> Result<PathBuf> {
|
||||||
|
// Handle the case where no roots are configured
|
||||||
|
if allowed_roots.is_empty() {
|
||||||
|
return Err(PinakesError::PathNotAllowed(
|
||||||
|
"no allowed roots configured".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check if the path exists
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(PinakesError::PathNotAllowed(format!(
|
||||||
|
"path does not exist: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonicalize to resolve symlinks and relative components
|
||||||
|
let canonical = path.canonicalize().map_err(|e| {
|
||||||
|
PinakesError::PathNotAllowed(format!(
|
||||||
|
"failed to canonicalize path {}: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Check if the canonical path is within any allowed root
|
||||||
|
let canonical_roots: Vec<PathBuf> = allowed_roots
|
||||||
|
.iter()
|
||||||
|
.filter_map(|root| root.canonicalize().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if canonical_roots.is_empty() {
|
||||||
|
return Err(PinakesError::PathNotAllowed(
|
||||||
|
"no accessible allowed roots".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_allowed = canonical_roots
|
||||||
|
.iter()
|
||||||
|
.any(|root| canonical.starts_with(root));
|
||||||
|
|
||||||
|
if is_allowed {
|
||||||
|
Ok(canonical)
|
||||||
|
} else {
|
||||||
|
Err(PinakesError::PathNotAllowed(format!(
|
||||||
|
"path {} is outside allowed roots",
|
||||||
|
path.display()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a path relative to a single root directory.
|
||||||
|
///
|
||||||
|
/// This is a convenience wrapper for `validate_path` when you only have one root.
|
||||||
|
pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> {
|
||||||
|
validate_path(path, &[root.to_path_buf()])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a path appears to contain traversal sequences without canonicalizing.
|
||||||
|
///
|
||||||
|
/// This is a quick pre-check that can reject obviously malicious paths without
|
||||||
|
/// hitting the filesystem. It should be used in addition to `validate_path`,
|
||||||
|
/// not as a replacement.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - The path string to check
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// `true` if the path appears safe (no obvious traversal sequences),
|
||||||
|
/// `false` if it contains suspicious patterns.
|
||||||
|
pub fn path_looks_safe(path: &str) -> bool {
|
||||||
|
// Reject paths with obvious traversal patterns
|
||||||
|
!path.contains("..")
|
||||||
|
&& !path.contains("//")
|
||||||
|
&& !path.starts_with('/')
|
||||||
|
&& path.chars().filter(|c| *c == '/').count() < 50 // Reasonable depth limit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitizes a filename by removing or replacing dangerous characters.
|
||||||
|
///
|
||||||
|
/// This removes:
|
||||||
|
/// - Path separators (`/`, `\`)
|
||||||
|
/// - Null bytes
|
||||||
|
/// - Control characters
|
||||||
|
/// - Leading dots (to prevent hidden files)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `filename` - The filename to sanitize
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A sanitized filename safe for use on most filesystems.
|
||||||
|
pub fn sanitize_filename(filename: &str) -> String {
|
||||||
|
let sanitized: String = filename
|
||||||
|
.chars()
|
||||||
|
.filter(|c| {
|
||||||
|
// Allow alphanumeric, common punctuation, and unicode letters
|
||||||
|
c.is_alphanumeric() || matches!(*c, '-' | '_' | '.' | ' ' | '(' | ')' | '[' | ']')
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Remove leading dots to prevent hidden files
|
||||||
|
let sanitized = sanitized.trim_start_matches('.');
|
||||||
|
|
||||||
|
// Remove leading/trailing whitespace
|
||||||
|
let sanitized = sanitized.trim();
|
||||||
|
|
||||||
|
// Ensure the filename isn't empty after sanitization
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
"unnamed".to_string()
|
||||||
|
} else {
|
||||||
|
sanitized.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins a base path with a relative path safely.
|
||||||
|
///
|
||||||
|
/// This ensures the resulting path doesn't escape the base directory
|
||||||
|
/// through use of `..` or absolute paths in the relative component.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `base` - The base directory
|
||||||
|
/// * `relative` - The relative path to join
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The joined path if safe, or an error if the relative path would escape the base.
|
||||||
|
pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
|
||||||
|
// Reject absolute paths in the relative component
|
||||||
|
if relative.starts_with('/') || relative.starts_with('\\') {
|
||||||
|
return Err(PinakesError::PathNotAllowed(
|
||||||
|
"relative path cannot be absolute".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject paths with .. traversal
|
||||||
|
if relative.contains("..") {
|
||||||
|
return Err(PinakesError::PathNotAllowed(
|
||||||
|
"relative path cannot contain '..'".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the path and validate it stays within base
|
||||||
|
let joined = base.join(relative);
|
||||||
|
|
||||||
|
// Canonicalize base for comparison
|
||||||
|
let canonical_base = base.canonicalize().map_err(|e| {
|
||||||
|
PinakesError::PathNotAllowed(format!(
|
||||||
|
"failed to canonicalize base {}: {}",
|
||||||
|
base.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// The joined path might not exist yet, so we can't canonicalize it directly.
|
||||||
|
// Instead, we check each component
|
||||||
|
let mut current = canonical_base.clone();
|
||||||
|
for component in Path::new(relative).components() {
|
||||||
|
use std::path::Component;
|
||||||
|
match component {
|
||||||
|
Component::Normal(name) => {
|
||||||
|
current = current.join(name);
|
||||||
|
}
|
||||||
|
Component::ParentDir => {
|
||||||
|
return Err(PinakesError::PathNotAllowed(
|
||||||
|
"path traversal detected".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Component::CurDir => continue,
|
||||||
|
_ => {
|
||||||
|
return Err(PinakesError::PathNotAllowed(
|
||||||
|
"invalid path component".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(joined)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup_test_dirs() -> TempDir {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(temp.path().join("allowed")).unwrap();
|
||||||
|
fs::create_dir_all(temp.path().join("forbidden")).unwrap();
|
||||||
|
fs::write(temp.path().join("allowed/file.txt"), "test").unwrap();
|
||||||
|
fs::write(temp.path().join("forbidden/secret.txt"), "secret").unwrap();
|
||||||
|
temp
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_path_allowed() {
|
||||||
|
let temp = setup_test_dirs();
|
||||||
|
let allowed_roots = vec![temp.path().join("allowed")];
|
||||||
|
let path = temp.path().join("allowed/file.txt");
|
||||||
|
|
||||||
|
let result = validate_path(&path, &allowed_roots);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_path_forbidden() {
|
||||||
|
let temp = setup_test_dirs();
|
||||||
|
let allowed_roots = vec![temp.path().join("allowed")];
|
||||||
|
let path = temp.path().join("forbidden/secret.txt");
|
||||||
|
|
||||||
|
let result = validate_path(&path, &allowed_roots);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_path_traversal() {
|
||||||
|
let temp = setup_test_dirs();
|
||||||
|
let allowed_roots = vec![temp.path().join("allowed")];
|
||||||
|
let path = temp.path().join("allowed/../forbidden/secret.txt");
|
||||||
|
|
||||||
|
let result = validate_path(&path, &allowed_roots);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_filename() {
|
||||||
|
assert_eq!(sanitize_filename("normal.txt"), "normal.txt");
|
||||||
|
assert_eq!(sanitize_filename("../../../etc/passwd"), "etcpasswd");
|
||||||
|
assert_eq!(sanitize_filename(".hidden"), "hidden");
|
||||||
|
assert_eq!(sanitize_filename("file<with>bad:chars"), "filewithbadchars");
|
||||||
|
assert_eq!(sanitize_filename(""), "unnamed");
|
||||||
|
assert_eq!(sanitize_filename("..."), "unnamed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_path_looks_safe() {
|
||||||
|
assert!(path_looks_safe("normal/path/file.txt"));
|
||||||
|
assert!(!path_looks_safe("../../../etc/passwd"));
|
||||||
|
assert!(!path_looks_safe("path//double/slash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_safe_join() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let base = temp.path();
|
||||||
|
|
||||||
|
// Valid join
|
||||||
|
let result = safe_join(base, "subdir/file.txt");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Traversal attempt
|
||||||
|
let result = safe_join(base, "../etc/passwd");
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
// Absolute path attempt
|
||||||
|
let result = safe_join(base, "/etc/passwd");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,9 +14,20 @@ pub struct ScanStatus {
|
||||||
pub scanning: bool,
|
pub scanning: bool,
|
||||||
pub files_found: usize,
|
pub files_found: usize,
|
||||||
pub files_processed: usize,
|
pub files_processed: usize,
|
||||||
|
/// Number of files skipped because they haven't changed (incremental scan)
|
||||||
|
pub files_skipped: usize,
|
||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Options for scanning operations
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ScanOptions {
|
||||||
|
/// Use incremental scanning (skip unchanged files based on mtime)
|
||||||
|
pub incremental: bool,
|
||||||
|
/// Force full rescan even for incremental mode
|
||||||
|
pub force_full: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared scan progress that can be read by the status endpoint while a scan runs.
|
/// Shared scan progress that can be read by the status endpoint while a scan runs.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ScanProgress {
|
pub struct ScanProgress {
|
||||||
|
|
@ -50,6 +61,7 @@ impl ScanProgress {
|
||||||
scanning: self.is_scanning.load(Ordering::Acquire),
|
scanning: self.is_scanning.load(Ordering::Acquire),
|
||||||
files_found: self.files_found.load(Ordering::Acquire),
|
files_found: self.files_found.load(Ordering::Acquire),
|
||||||
files_processed: self.files_processed.load(Ordering::Acquire),
|
files_processed: self.files_processed.load(Ordering::Acquire),
|
||||||
|
files_skipped: 0, // Not tracked in real-time progress
|
||||||
errors,
|
errors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +101,20 @@ pub async fn scan_directory(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
) -> Result<ScanStatus> {
|
) -> Result<ScanStatus> {
|
||||||
scan_directory_with_progress(storage, dir, ignore_patterns, None).await
|
scan_directory_with_options(storage, dir, ignore_patterns, None, &ScanOptions::default()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan a directory with incremental scanning support
|
||||||
|
pub async fn scan_directory_incremental(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
dir: &Path,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
) -> Result<ScanStatus> {
|
||||||
|
let options = ScanOptions {
|
||||||
|
incremental: true,
|
||||||
|
force_full: false,
|
||||||
|
};
|
||||||
|
scan_directory_with_options(storage, dir, ignore_patterns, None, &options).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn scan_directory_with_progress(
|
pub async fn scan_directory_with_progress(
|
||||||
|
|
@ -98,20 +123,62 @@ pub async fn scan_directory_with_progress(
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
progress: Option<&ScanProgress>,
|
progress: Option<&ScanProgress>,
|
||||||
) -> Result<ScanStatus> {
|
) -> Result<ScanStatus> {
|
||||||
info!(dir = %dir.display(), "starting directory scan");
|
scan_directory_with_options(
|
||||||
|
storage,
|
||||||
|
dir,
|
||||||
|
ignore_patterns,
|
||||||
|
progress,
|
||||||
|
&ScanOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan a directory with full options including progress tracking and incremental mode
|
||||||
|
pub async fn scan_directory_with_options(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
dir: &Path,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
progress: Option<&ScanProgress>,
|
||||||
|
scan_options: &ScanOptions,
|
||||||
|
) -> Result<ScanStatus> {
|
||||||
|
info!(
|
||||||
|
dir = %dir.display(),
|
||||||
|
incremental = scan_options.incremental,
|
||||||
|
force = scan_options.force_full,
|
||||||
|
"starting directory scan"
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(p) = progress {
|
if let Some(p) = progress {
|
||||||
p.begin();
|
p.begin();
|
||||||
}
|
}
|
||||||
|
|
||||||
let results = import::import_directory(storage, dir, ignore_patterns).await?;
|
// Convert scan options to import options
|
||||||
// Note: for configurable concurrency, use import_directory_with_concurrency directly
|
let import_options = import::ImportOptions {
|
||||||
|
incremental: scan_options.incremental && !scan_options.force_full,
|
||||||
|
force: scan_options.force_full,
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = import::import_directory_with_options(
|
||||||
|
storage,
|
||||||
|
dir,
|
||||||
|
ignore_patterns,
|
||||||
|
8, // Default concurrency
|
||||||
|
&import_options,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
let mut processed = 0;
|
let mut processed = 0;
|
||||||
|
let mut skipped = 0;
|
||||||
for result in &results {
|
for result in &results {
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => processed += 1,
|
Ok(r) => {
|
||||||
|
if r.was_skipped {
|
||||||
|
skipped += 1;
|
||||||
|
} else {
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = e.to_string();
|
let msg = e.to_string();
|
||||||
if let Some(p) = progress {
|
if let Some(p) = progress {
|
||||||
|
|
@ -132,9 +199,20 @@ pub async fn scan_directory_with_progress(
|
||||||
scanning: false,
|
scanning: false,
|
||||||
files_found: results.len(),
|
files_found: results.len(),
|
||||||
files_processed: processed,
|
files_processed: processed,
|
||||||
|
files_skipped: skipped,
|
||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if scan_options.incremental {
|
||||||
|
info!(
|
||||||
|
dir = %dir.display(),
|
||||||
|
found = status.files_found,
|
||||||
|
processed = status.files_processed,
|
||||||
|
skipped = status.files_skipped,
|
||||||
|
"incremental scan complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,19 +220,43 @@ pub async fn scan_all_roots(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
) -> Result<Vec<ScanStatus>> {
|
) -> Result<Vec<ScanStatus>> {
|
||||||
scan_all_roots_with_progress(storage, ignore_patterns, None).await
|
scan_all_roots_with_options(storage, ignore_patterns, None, &ScanOptions::default()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan all roots incrementally (skip unchanged files)
|
||||||
|
pub async fn scan_all_roots_incremental(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
) -> Result<Vec<ScanStatus>> {
|
||||||
|
let options = ScanOptions {
|
||||||
|
incremental: true,
|
||||||
|
force_full: false,
|
||||||
|
};
|
||||||
|
scan_all_roots_with_options(storage, ignore_patterns, None, &options).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn scan_all_roots_with_progress(
|
pub async fn scan_all_roots_with_progress(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
ignore_patterns: &[String],
|
ignore_patterns: &[String],
|
||||||
progress: Option<&ScanProgress>,
|
progress: Option<&ScanProgress>,
|
||||||
|
) -> Result<Vec<ScanStatus>> {
|
||||||
|
scan_all_roots_with_options(storage, ignore_patterns, progress, &ScanOptions::default()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan all roots with full options including progress and incremental mode
|
||||||
|
pub async fn scan_all_roots_with_options(
|
||||||
|
storage: &DynStorageBackend,
|
||||||
|
ignore_patterns: &[String],
|
||||||
|
progress: Option<&ScanProgress>,
|
||||||
|
scan_options: &ScanOptions,
|
||||||
) -> Result<Vec<ScanStatus>> {
|
) -> Result<Vec<ScanStatus>> {
|
||||||
let roots = storage.list_root_dirs().await?;
|
let roots = storage.list_root_dirs().await?;
|
||||||
let mut statuses = Vec::new();
|
let mut statuses = Vec::new();
|
||||||
|
|
||||||
for root in roots {
|
for root in roots {
|
||||||
match scan_directory_with_progress(storage, &root, ignore_patterns, progress).await {
|
match scan_directory_with_options(storage, &root, ignore_patterns, progress, scan_options)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(status) => statuses.push(status),
|
Ok(status) => statuses.push(status),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(root = %root.display(), error = %e, "failed to scan root directory");
|
warn!(root = %root.display(), error = %e, "failed to scan root directory");
|
||||||
|
|
@ -162,6 +264,7 @@ pub async fn scan_all_roots_with_progress(
|
||||||
scanning: false,
|
scanning: false,
|
||||||
files_found: 0,
|
files_found: 0,
|
||||||
files_processed: 0,
|
files_processed: 0,
|
||||||
|
files_skipped: 0,
|
||||||
errors: vec![e.to_string()],
|
errors: vec![e.to_string()],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ use winnow::{ModalResult, Parser};
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum SearchQuery {
|
pub enum SearchQuery {
|
||||||
FullText(String),
|
FullText(String),
|
||||||
FieldMatch { field: String, value: String },
|
FieldMatch {
|
||||||
|
field: String,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
And(Vec<SearchQuery>),
|
And(Vec<SearchQuery>),
|
||||||
Or(Vec<SearchQuery>),
|
Or(Vec<SearchQuery>),
|
||||||
Not(Box<SearchQuery>),
|
Not(Box<SearchQuery>),
|
||||||
|
|
@ -14,6 +17,45 @@ pub enum SearchQuery {
|
||||||
Fuzzy(String),
|
Fuzzy(String),
|
||||||
TypeFilter(String),
|
TypeFilter(String),
|
||||||
TagFilter(String),
|
TagFilter(String),
|
||||||
|
/// Range query: field:start..end (inclusive)
|
||||||
|
RangeQuery {
|
||||||
|
field: String,
|
||||||
|
start: Option<i64>,
|
||||||
|
end: Option<i64>,
|
||||||
|
},
|
||||||
|
/// Comparison query: field:>value, field:<value, field:>=value, field:<=value
|
||||||
|
CompareQuery {
|
||||||
|
field: String,
|
||||||
|
op: CompareOp,
|
||||||
|
value: i64,
|
||||||
|
},
|
||||||
|
/// Date query: created:today, modified:last-week, etc.
|
||||||
|
DateQuery {
|
||||||
|
field: String,
|
||||||
|
value: DateValue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CompareOp {
|
||||||
|
GreaterThan,
|
||||||
|
GreaterOrEqual,
|
||||||
|
LessThan,
|
||||||
|
LessOrEqual,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DateValue {
|
||||||
|
Today,
|
||||||
|
Yesterday,
|
||||||
|
ThisWeek,
|
||||||
|
LastWeek,
|
||||||
|
ThisMonth,
|
||||||
|
LastMonth,
|
||||||
|
ThisYear,
|
||||||
|
LastYear,
|
||||||
|
/// Days ago: last-7d, last-30d
|
||||||
|
DaysAgo(u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -69,14 +111,143 @@ fn not_expr(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
.parse_next(input)
|
.parse_next(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a date value like "today", "yesterday", "last-week", "last-30d"
|
||||||
|
fn parse_date_value(s: &str) -> Option<DateValue> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"today" => Some(DateValue::Today),
|
||||||
|
"yesterday" => Some(DateValue::Yesterday),
|
||||||
|
"this-week" | "thisweek" => Some(DateValue::ThisWeek),
|
||||||
|
"last-week" | "lastweek" => Some(DateValue::LastWeek),
|
||||||
|
"this-month" | "thismonth" => Some(DateValue::ThisMonth),
|
||||||
|
"last-month" | "lastmonth" => Some(DateValue::LastMonth),
|
||||||
|
"this-year" | "thisyear" => Some(DateValue::ThisYear),
|
||||||
|
"last-year" | "lastyear" => Some(DateValue::LastYear),
|
||||||
|
other => {
|
||||||
|
// Try to parse "last-Nd" format (e.g., "last-7d", "last-30d")
|
||||||
|
if let Some(rest) = other.strip_prefix("last-") {
|
||||||
|
if let Some(days_str) = rest.strip_suffix('d') {
|
||||||
|
if let Ok(days) = days_str.parse::<u32>() {
|
||||||
|
return Some(DateValue::DaysAgo(days));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse size strings like "10MB", "1GB", "500KB" to bytes
|
||||||
|
fn parse_size_value(s: &str) -> Option<i64> {
|
||||||
|
let s = s.to_uppercase();
|
||||||
|
if let Some(num) = s.strip_suffix("GB") {
|
||||||
|
num.parse::<i64>().ok().map(|n| n * 1024 * 1024 * 1024)
|
||||||
|
} else if let Some(num) = s.strip_suffix("MB") {
|
||||||
|
num.parse::<i64>().ok().map(|n| n * 1024 * 1024)
|
||||||
|
} else if let Some(num) = s.strip_suffix("KB") {
|
||||||
|
num.parse::<i64>().ok().map(|n| n * 1024)
|
||||||
|
} else if let Some(num) = s.strip_suffix('B') {
|
||||||
|
num.parse::<i64>().ok()
|
||||||
|
} else {
|
||||||
|
s.parse::<i64>().ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn field_match(input: &mut &str) -> ModalResult<SearchQuery> {
|
fn field_match(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||||
let field_name =
|
let field_name =
|
||||||
take_while(1.., |c: char| c.is_alphanumeric() || c == '_').map(|s: &str| s.to_string());
|
take_while(1.., |c: char| c.is_alphanumeric() || c == '_').map(|s: &str| s.to_string());
|
||||||
(field_name, ':', word_or_quoted)
|
(field_name, ':', word_or_quoted)
|
||||||
.map(|(field, _, value)| match field.as_str() {
|
.map(|(field, _, value)| {
|
||||||
"type" => SearchQuery::TypeFilter(value),
|
// Handle special field types
|
||||||
"tag" => SearchQuery::TagFilter(value),
|
match field.as_str() {
|
||||||
_ => SearchQuery::FieldMatch { field, value },
|
"type" => return SearchQuery::TypeFilter(value),
|
||||||
|
"tag" => return SearchQuery::TagFilter(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for range queries: field:start..end
|
||||||
|
if value.contains("..") {
|
||||||
|
let parts: Vec<&str> = value.split("..").collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let start = if parts[0].is_empty() {
|
||||||
|
None
|
||||||
|
} else if field == "size" {
|
||||||
|
parse_size_value(parts[0])
|
||||||
|
} else {
|
||||||
|
parts[0].parse().ok()
|
||||||
|
};
|
||||||
|
let end = if parts[1].is_empty() {
|
||||||
|
None
|
||||||
|
} else if field == "size" {
|
||||||
|
parse_size_value(parts[1])
|
||||||
|
} else {
|
||||||
|
parts[1].parse().ok()
|
||||||
|
};
|
||||||
|
return SearchQuery::RangeQuery { field, start, end };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for comparison queries: >=, <=, >, <
|
||||||
|
if let Some(rest) = value.strip_prefix(">=") {
|
||||||
|
let val = if field == "size" {
|
||||||
|
parse_size_value(rest).unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
rest.parse().unwrap_or(0)
|
||||||
|
};
|
||||||
|
return SearchQuery::CompareQuery {
|
||||||
|
field,
|
||||||
|
op: CompareOp::GreaterOrEqual,
|
||||||
|
value: val,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(rest) = value.strip_prefix("<=") {
|
||||||
|
let val = if field == "size" {
|
||||||
|
parse_size_value(rest).unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
rest.parse().unwrap_or(0)
|
||||||
|
};
|
||||||
|
return SearchQuery::CompareQuery {
|
||||||
|
field,
|
||||||
|
op: CompareOp::LessOrEqual,
|
||||||
|
value: val,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(rest) = value.strip_prefix('>') {
|
||||||
|
let val = if field == "size" {
|
||||||
|
parse_size_value(rest).unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
rest.parse().unwrap_or(0)
|
||||||
|
};
|
||||||
|
return SearchQuery::CompareQuery {
|
||||||
|
field,
|
||||||
|
op: CompareOp::GreaterThan,
|
||||||
|
value: val,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(rest) = value.strip_prefix('<') {
|
||||||
|
let val = if field == "size" {
|
||||||
|
parse_size_value(rest).unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
rest.parse().unwrap_or(0)
|
||||||
|
};
|
||||||
|
return SearchQuery::CompareQuery {
|
||||||
|
field,
|
||||||
|
op: CompareOp::LessThan,
|
||||||
|
value: val,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for date queries on created/modified fields
|
||||||
|
if field == "created" || field == "modified" {
|
||||||
|
if let Some(date_val) = parse_date_value(&value) {
|
||||||
|
return SearchQuery::DateQuery {
|
||||||
|
field,
|
||||||
|
value: date_val,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: simple field match
|
||||||
|
SearchQuery::FieldMatch { field, value }
|
||||||
})
|
})
|
||||||
.parse_next(input)
|
.parse_next(input)
|
||||||
}
|
}
|
||||||
|
|
@ -253,4 +424,131 @@ mod tests {
|
||||||
let q = parse_search_query("\"hello world\"").unwrap();
|
let q = parse_search_query("\"hello world\"").unwrap();
|
||||||
assert_eq!(q, SearchQuery::FullText("hello world".into()));
|
assert_eq!(q, SearchQuery::FullText("hello world".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_range_query_year() {
|
||||||
|
let q = parse_search_query("year:2020..2023").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::RangeQuery {
|
||||||
|
field: "year".into(),
|
||||||
|
start: Some(2020),
|
||||||
|
end: Some(2023)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_range_query_open_start() {
|
||||||
|
let q = parse_search_query("year:..2023").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::RangeQuery {
|
||||||
|
field: "year".into(),
|
||||||
|
start: None,
|
||||||
|
end: Some(2023)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_range_query_open_end() {
|
||||||
|
let q = parse_search_query("year:2020..").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::RangeQuery {
|
||||||
|
field: "year".into(),
|
||||||
|
start: Some(2020),
|
||||||
|
end: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compare_greater_than() {
|
||||||
|
let q = parse_search_query("year:>2020").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::CompareQuery {
|
||||||
|
field: "year".into(),
|
||||||
|
op: CompareOp::GreaterThan,
|
||||||
|
value: 2020
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compare_less_or_equal() {
|
||||||
|
let q = parse_search_query("year:<=2023").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::CompareQuery {
|
||||||
|
field: "year".into(),
|
||||||
|
op: CompareOp::LessOrEqual,
|
||||||
|
value: 2023
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_compare_mb() {
|
||||||
|
let q = parse_search_query("size:>10MB").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::CompareQuery {
|
||||||
|
field: "size".into(),
|
||||||
|
op: CompareOp::GreaterThan,
|
||||||
|
value: 10 * 1024 * 1024
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_range_gb() {
|
||||||
|
let q = parse_search_query("size:1GB..2GB").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::RangeQuery {
|
||||||
|
field: "size".into(),
|
||||||
|
start: Some(1024 * 1024 * 1024),
|
||||||
|
end: Some(2 * 1024 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_date_query_today() {
|
||||||
|
let q = parse_search_query("created:today").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::DateQuery {
|
||||||
|
field: "created".into(),
|
||||||
|
value: DateValue::Today
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_date_query_last_week() {
|
||||||
|
let q = parse_search_query("modified:last-week").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::DateQuery {
|
||||||
|
field: "modified".into(),
|
||||||
|
value: DateValue::LastWeek
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_date_query_days_ago() {
|
||||||
|
let q = parse_search_query("created:last-30d").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
q,
|
||||||
|
SearchQuery::DateQuery {
|
||||||
|
field: "created".into(),
|
||||||
|
value: DateValue::DaysAgo(30)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ pub trait StorageBackend: Send + Sync + 'static {
|
||||||
async fn get_media(&self, id: MediaId) -> Result<MediaItem>;
|
async fn get_media(&self, id: MediaId) -> Result<MediaItem>;
|
||||||
async fn count_media(&self) -> Result<u64>;
|
async fn count_media(&self) -> Result<u64>;
|
||||||
async fn get_media_by_hash(&self, hash: &ContentHash) -> Result<Option<MediaItem>>;
|
async fn get_media_by_hash(&self, hash: &ContentHash) -> Result<Option<MediaItem>>;
|
||||||
|
/// Get a media item by its file path (used for incremental scanning)
|
||||||
|
async fn get_media_by_path(&self, path: &std::path::Path) -> Result<Option<MediaItem>>;
|
||||||
async fn list_media(&self, pagination: &Pagination) -> Result<Vec<MediaItem>>;
|
async fn list_media(&self, pagination: &Pagination) -> Result<Vec<MediaItem>>;
|
||||||
async fn update_media(&self, item: &MediaItem) -> Result<()>;
|
async fn update_media(&self, item: &MediaItem) -> Result<()>;
|
||||||
async fn delete_media(&self, id: MediaId) -> Result<()>;
|
async fn delete_media(&self, id: MediaId) -> Result<()>;
|
||||||
|
|
@ -232,6 +234,59 @@ pub trait StorageBackend: Send + Sync + 'static {
|
||||||
root_path: &str,
|
root_path: &str,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
|
/// Check if a user has access to a specific media item based on library permissions.
|
||||||
|
/// Returns the permission level if access is granted, or an error if denied.
|
||||||
|
/// Admin users (role=admin) bypass library checks and have full access.
|
||||||
|
async fn check_library_access(
|
||||||
|
&self,
|
||||||
|
user_id: crate::users::UserId,
|
||||||
|
media_id: crate::model::MediaId,
|
||||||
|
) -> Result<crate::users::LibraryPermission> {
|
||||||
|
// Default implementation: get the media item's path and check against user's library access
|
||||||
|
let media = self.get_media(media_id).await?;
|
||||||
|
let path_str = media.path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Get user's library permissions
|
||||||
|
let libraries = self.get_user_libraries(user_id).await?;
|
||||||
|
|
||||||
|
// If user has no library restrictions, they have no access (unless they're admin)
|
||||||
|
// This default impl requires at least one matching library permission
|
||||||
|
for lib in &libraries {
|
||||||
|
if path_str.starts_with(&lib.root_path) {
|
||||||
|
return Ok(lib.permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(crate::error::PinakesError::Authorization(format!(
|
||||||
|
"user {} has no access to media {}",
|
||||||
|
user_id, media_id
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a user has at least read access to a media item
|
||||||
|
async fn has_media_read_access(
|
||||||
|
&self,
|
||||||
|
user_id: crate::users::UserId,
|
||||||
|
media_id: crate::model::MediaId,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match self.check_library_access(user_id, media_id).await {
|
||||||
|
Ok(perm) => Ok(perm.can_read()),
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a user has write access to a media item
|
||||||
|
async fn has_media_write_access(
|
||||||
|
&self,
|
||||||
|
user_id: crate::users::UserId,
|
||||||
|
media_id: crate::model::MediaId,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match self.check_library_access(user_id, media_id).await {
|
||||||
|
Ok(perm) => Ok(perm.can_write()),
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Ratings =====
|
// ===== Ratings =====
|
||||||
async fn rate_media(
|
async fn rate_media(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ fn row_to_media_item(row: &Row) -> Result<MediaItem> {
|
||||||
.get::<_, Option<String>>("thumbnail_path")
|
.get::<_, Option<String>>("thumbnail_path")
|
||||||
.map(PathBuf::from),
|
.map(PathBuf::from),
|
||||||
custom_fields: HashMap::new(),
|
custom_fields: HashMap::new(),
|
||||||
|
file_mtime: row.get("file_mtime"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
updated_at: row.get("updated_at"),
|
updated_at: row.get("updated_at"),
|
||||||
})
|
})
|
||||||
|
|
@ -198,11 +199,61 @@ fn build_search_inner(
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return Ok("TRUE".to_string());
|
return Ok("TRUE".to_string());
|
||||||
}
|
}
|
||||||
let idx = *offset;
|
// Combine FTS with trigram similarity and ILIKE for comprehensive fuzzy matching
|
||||||
|
// This allows partial matches like "mus" -> "music"
|
||||||
|
let idx_fts = *offset;
|
||||||
*offset += 1;
|
*offset += 1;
|
||||||
|
let idx_prefix = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
let idx_ilike = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
let idx_sim_title = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
let idx_sim_artist = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
let idx_sim_album = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
let idx_sim_filename = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
|
||||||
|
// Sanitize for tsquery prefix matching
|
||||||
|
let sanitized = text.replace(['&', '|', '!', '(', ')', ':', '*', '\\', '\''], "");
|
||||||
|
let prefix_query = if sanitized.contains(' ') {
|
||||||
|
// For multi-word, join with & and add :* to last word
|
||||||
|
let words: Vec<&str> = sanitized.split_whitespace().collect();
|
||||||
|
if let Some((last, rest)) = words.split_last() {
|
||||||
|
let prefix_parts: Vec<String> = rest.iter().map(|w| w.to_string()).collect();
|
||||||
|
if prefix_parts.is_empty() {
|
||||||
|
format!("{}:*", last)
|
||||||
|
} else {
|
||||||
|
format!("{} & {}:*", prefix_parts.join(" & "), last)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("{}:*", sanitized)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("{}:*", sanitized)
|
||||||
|
};
|
||||||
|
|
||||||
params.push(Box::new(text.clone()));
|
params.push(Box::new(text.clone()));
|
||||||
|
params.push(Box::new(prefix_query));
|
||||||
|
params.push(Box::new(format!("%{}%", text)));
|
||||||
|
params.push(Box::new(text.clone()));
|
||||||
|
params.push(Box::new(text.clone()));
|
||||||
|
params.push(Box::new(text.clone()));
|
||||||
|
params.push(Box::new(text.clone()));
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"search_vector @@ plainto_tsquery('english', ${idx})"
|
"(\
|
||||||
|
search_vector @@ plainto_tsquery('english', ${idx_fts}) OR \
|
||||||
|
search_vector @@ to_tsquery('english', ${idx_prefix}) OR \
|
||||||
|
LOWER(COALESCE(title, '')) LIKE LOWER(${idx_ilike}) OR \
|
||||||
|
LOWER(COALESCE(file_name, '')) LIKE LOWER(${idx_ilike}) OR \
|
||||||
|
similarity(COALESCE(title, ''), ${idx_sim_title}) > 0.3 OR \
|
||||||
|
similarity(COALESCE(artist, ''), ${idx_sim_artist}) > 0.3 OR \
|
||||||
|
similarity(COALESCE(album, ''), ${idx_sim_album}) > 0.3 OR \
|
||||||
|
similarity(COALESCE(file_name, ''), ${idx_sim_filename}) > 0.25\
|
||||||
|
)"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
SearchQuery::Prefix(term) => {
|
SearchQuery::Prefix(term) => {
|
||||||
|
|
@ -214,14 +265,31 @@ fn build_search_inner(
|
||||||
Ok(format!("search_vector @@ to_tsquery('english', ${idx})"))
|
Ok(format!("search_vector @@ to_tsquery('english', ${idx})"))
|
||||||
}
|
}
|
||||||
SearchQuery::Fuzzy(term) => {
|
SearchQuery::Fuzzy(term) => {
|
||||||
|
// Use trigram similarity on multiple fields
|
||||||
let idx_title = *offset;
|
let idx_title = *offset;
|
||||||
*offset += 1;
|
*offset += 1;
|
||||||
let idx_artist = *offset;
|
let idx_artist = *offset;
|
||||||
*offset += 1;
|
*offset += 1;
|
||||||
|
let idx_album = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
let idx_filename = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
let idx_ilike = *offset;
|
||||||
|
*offset += 1;
|
||||||
params.push(Box::new(term.clone()));
|
params.push(Box::new(term.clone()));
|
||||||
params.push(Box::new(term.clone()));
|
params.push(Box::new(term.clone()));
|
||||||
|
params.push(Box::new(term.clone()));
|
||||||
|
params.push(Box::new(term.clone()));
|
||||||
|
params.push(Box::new(format!("%{}%", term)));
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"(similarity(COALESCE(title, ''), ${idx_title}) > 0.3 OR similarity(COALESCE(artist, ''), ${idx_artist}) > 0.3)"
|
"(\
|
||||||
|
similarity(COALESCE(title, ''), ${idx_title}) > 0.3 OR \
|
||||||
|
similarity(COALESCE(artist, ''), ${idx_artist}) > 0.3 OR \
|
||||||
|
similarity(COALESCE(album, ''), ${idx_album}) > 0.3 OR \
|
||||||
|
similarity(COALESCE(file_name, ''), ${idx_filename}) > 0.25 OR \
|
||||||
|
LOWER(COALESCE(title, '')) LIKE LOWER(${idx_ilike}) OR \
|
||||||
|
LOWER(COALESCE(file_name, '')) LIKE LOWER(${idx_ilike})\
|
||||||
|
)"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
SearchQuery::FieldMatch { field, value } => {
|
SearchQuery::FieldMatch { field, value } => {
|
||||||
|
|
@ -277,6 +345,86 @@ fn build_search_inner(
|
||||||
let frag = build_search_inner(inner, offset, params, type_filters, tag_filters)?;
|
let frag = build_search_inner(inner, offset, params, type_filters, tag_filters)?;
|
||||||
Ok(format!("NOT ({frag})"))
|
Ok(format!("NOT ({frag})"))
|
||||||
}
|
}
|
||||||
|
SearchQuery::RangeQuery { field, start, end } => {
|
||||||
|
let col = match field.as_str() {
|
||||||
|
"year" => "year",
|
||||||
|
"size" | "file_size" => "file_size",
|
||||||
|
"duration" => "duration_secs",
|
||||||
|
_ => return Ok("TRUE".to_string()), // Unknown field, ignore
|
||||||
|
};
|
||||||
|
match (start, end) {
|
||||||
|
(Some(s), Some(e)) => {
|
||||||
|
let idx_start = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
let idx_end = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
params.push(Box::new(*s));
|
||||||
|
params.push(Box::new(*e));
|
||||||
|
Ok(format!("({col} >= ${idx_start} AND {col} <= ${idx_end})"))
|
||||||
|
}
|
||||||
|
(Some(s), None) => {
|
||||||
|
let idx = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
params.push(Box::new(*s));
|
||||||
|
Ok(format!("{col} >= ${idx}"))
|
||||||
|
}
|
||||||
|
(None, Some(e)) => {
|
||||||
|
let idx = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
params.push(Box::new(*e));
|
||||||
|
Ok(format!("{col} <= ${idx}"))
|
||||||
|
}
|
||||||
|
(None, None) => Ok("TRUE".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SearchQuery::CompareQuery { field, op, value } => {
|
||||||
|
let col = match field.as_str() {
|
||||||
|
"year" => "year",
|
||||||
|
"size" | "file_size" => "file_size",
|
||||||
|
"duration" => "duration_secs",
|
||||||
|
_ => return Ok("TRUE".to_string()), // Unknown field, ignore
|
||||||
|
};
|
||||||
|
let op_sql = match op {
|
||||||
|
crate::search::CompareOp::GreaterThan => ">",
|
||||||
|
crate::search::CompareOp::GreaterOrEqual => ">=",
|
||||||
|
crate::search::CompareOp::LessThan => "<",
|
||||||
|
crate::search::CompareOp::LessOrEqual => "<=",
|
||||||
|
};
|
||||||
|
let idx = *offset;
|
||||||
|
*offset += 1;
|
||||||
|
params.push(Box::new(*value));
|
||||||
|
Ok(format!("{col} {op_sql} ${idx}"))
|
||||||
|
}
|
||||||
|
SearchQuery::DateQuery { field, value } => {
|
||||||
|
let col = match field.as_str() {
|
||||||
|
"created" => "created_at",
|
||||||
|
"modified" | "updated" => "updated_at",
|
||||||
|
_ => return Ok("TRUE".to_string()),
|
||||||
|
};
|
||||||
|
Ok(date_value_to_postgres_expr(col, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a DateValue to a PostgreSQL datetime comparison expression
|
||||||
|
fn date_value_to_postgres_expr(col: &str, value: &crate::search::DateValue) -> String {
|
||||||
|
use crate::search::DateValue;
|
||||||
|
match value {
|
||||||
|
DateValue::Today => format!("{col}::date = CURRENT_DATE"),
|
||||||
|
DateValue::Yesterday => format!("{col}::date = CURRENT_DATE - INTERVAL '1 day'"),
|
||||||
|
DateValue::ThisWeek => format!("{col} >= date_trunc('week', CURRENT_DATE)"),
|
||||||
|
DateValue::LastWeek => format!(
|
||||||
|
"{col} >= date_trunc('week', CURRENT_DATE) - INTERVAL '7 days' AND {col} < date_trunc('week', CURRENT_DATE)"
|
||||||
|
),
|
||||||
|
DateValue::ThisMonth => format!("{col} >= date_trunc('month', CURRENT_DATE)"),
|
||||||
|
DateValue::LastMonth => format!(
|
||||||
|
"{col} >= date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' AND {col} < date_trunc('month', CURRENT_DATE)"
|
||||||
|
),
|
||||||
|
DateValue::ThisYear => format!("{col} >= date_trunc('year', CURRENT_DATE)"),
|
||||||
|
DateValue::LastYear => format!(
|
||||||
|
"{col} >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' AND {col} < date_trunc('year', CURRENT_DATE)"
|
||||||
|
),
|
||||||
|
DateValue::DaysAgo(days) => format!("{col} >= CURRENT_DATE - INTERVAL '{days} days'"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,7 +626,7 @@ impl StorageBackend for PostgresBackend {
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT id, path, file_name, media_type, content_hash, file_size,
|
"SELECT id, path, file_name, media_type, content_hash, file_size,
|
||||||
title, artist, album, genre, year, duration_secs, description,
|
title, artist, album, genre, year, duration_secs, description,
|
||||||
thumbnail_path, created_at, updated_at
|
thumbnail_path, file_mtime, created_at, updated_at
|
||||||
FROM media_items WHERE content_hash = $1",
|
FROM media_items WHERE content_hash = $1",
|
||||||
&[&hash.0],
|
&[&hash.0],
|
||||||
)
|
)
|
||||||
|
|
@ -494,6 +642,34 @@ impl StorageBackend for PostgresBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_media_by_path(&self, path: &std::path::Path) -> Result<Option<MediaItem>> {
|
||||||
|
let path_str = path.to_string_lossy().to_string();
|
||||||
|
let client = self
|
||||||
|
.pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||||
|
|
||||||
|
let row = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT id, path, file_name, media_type, content_hash, file_size,
|
||||||
|
title, artist, album, genre, year, duration_secs, description,
|
||||||
|
thumbnail_path, file_mtime, created_at, updated_at
|
||||||
|
FROM media_items WHERE path = $1",
|
||||||
|
&[&path_str],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(r) => {
|
||||||
|
let mut item = row_to_media_item(&r)?;
|
||||||
|
item.custom_fields = self.get_custom_fields(item.id).await?;
|
||||||
|
Ok(Some(item))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_media(&self, pagination: &Pagination) -> Result<Vec<MediaItem>> {
|
async fn list_media(&self, pagination: &Pagination) -> Result<Vec<MediaItem>> {
|
||||||
let client = self
|
let client = self
|
||||||
.pool
|
.pool
|
||||||
|
|
@ -671,6 +847,59 @@ impl StorageBackend for PostgresBackend {
|
||||||
Ok(count as u64)
|
Ok(count as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Batch Operations ----
|
||||||
|
|
||||||
|
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let client = self
|
||||||
|
.pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||||
|
|
||||||
|
// Use ANY with array for efficient batch delete
|
||||||
|
let uuids: Vec<Uuid> = ids.iter().map(|id| id.0).collect();
|
||||||
|
let rows = client
|
||||||
|
.execute("DELETE FROM media_items WHERE id = ANY($1)", &[&uuids])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn batch_tag_media(&self, media_ids: &[MediaId], tag_ids: &[Uuid]) -> Result<u64> {
|
||||||
|
if media_ids.is_empty() || tag_ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let client = self
|
||||||
|
.pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||||
|
|
||||||
|
// Use UNNEST for efficient batch insert
|
||||||
|
let mut media_uuids = Vec::new();
|
||||||
|
let mut tag_uuids = Vec::new();
|
||||||
|
for mid in media_ids {
|
||||||
|
for tid in tag_ids {
|
||||||
|
media_uuids.push(mid.0);
|
||||||
|
tag_uuids.push(*tid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = client
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO media_tags (media_id, tag_id)
|
||||||
|
SELECT * FROM UNNEST($1::uuid[], $2::uuid[])
|
||||||
|
ON CONFLICT DO NOTHING",
|
||||||
|
&[&media_uuids, &tag_uuids],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Tags ----
|
// ---- Tags ----
|
||||||
|
|
||||||
async fn create_tag(&self, name: &str, parent_id: Option<Uuid>) -> Result<Tag> {
|
async fn create_tag(&self, name: &str, parent_id: Option<Uuid>) -> Result<Tag> {
|
||||||
|
|
@ -3155,6 +3384,9 @@ fn query_has_fts(query: &SearchQuery) -> bool {
|
||||||
SearchQuery::FieldMatch { .. } => false,
|
SearchQuery::FieldMatch { .. } => false,
|
||||||
SearchQuery::TypeFilter(_) => false,
|
SearchQuery::TypeFilter(_) => false,
|
||||||
SearchQuery::TagFilter(_) => false,
|
SearchQuery::TagFilter(_) => false,
|
||||||
|
SearchQuery::RangeQuery { .. } => false,
|
||||||
|
SearchQuery::CompareQuery { .. } => false,
|
||||||
|
SearchQuery::DateQuery { .. } => false,
|
||||||
SearchQuery::And(children) | SearchQuery::Or(children) => {
|
SearchQuery::And(children) | SearchQuery::Or(children) => {
|
||||||
children.iter().any(query_has_fts)
|
children.iter().any(query_has_fts)
|
||||||
}
|
}
|
||||||
|
|
@ -3173,7 +3405,7 @@ fn find_first_fts_param(query: &SearchQuery) -> i32 {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let idx = *offset;
|
let idx = *offset;
|
||||||
*offset += 1;
|
*offset += 7; // FullText now uses 7 params (fts, prefix, ilike, sim_title, sim_artist, sim_album, sim_filename)
|
||||||
Some(idx)
|
Some(idx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3183,7 +3415,7 @@ fn find_first_fts_param(query: &SearchQuery) -> i32 {
|
||||||
Some(idx)
|
Some(idx)
|
||||||
}
|
}
|
||||||
SearchQuery::Fuzzy(_) => {
|
SearchQuery::Fuzzy(_) => {
|
||||||
*offset += 2; // fuzzy uses two params
|
*offset += 5; // Fuzzy now uses 5 params (sim_title, sim_artist, sim_album, sim_filename, ilike)
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
SearchQuery::FieldMatch { .. } => {
|
SearchQuery::FieldMatch { .. } => {
|
||||||
|
|
@ -3191,6 +3423,21 @@ fn find_first_fts_param(query: &SearchQuery) -> i32 {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
SearchQuery::TypeFilter(_) | SearchQuery::TagFilter(_) => None,
|
SearchQuery::TypeFilter(_) | SearchQuery::TagFilter(_) => None,
|
||||||
|
SearchQuery::RangeQuery { start, end, .. } => {
|
||||||
|
// Range queries use 0-2 params depending on bounds
|
||||||
|
if start.is_some() {
|
||||||
|
*offset += 1;
|
||||||
|
}
|
||||||
|
if end.is_some() {
|
||||||
|
*offset += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
SearchQuery::CompareQuery { .. } => {
|
||||||
|
*offset += 1;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
SearchQuery::DateQuery { .. } => None, // No params, uses inline SQL
|
||||||
SearchQuery::And(children) | SearchQuery::Or(children) => {
|
SearchQuery::And(children) | SearchQuery::Or(children) => {
|
||||||
for child in children {
|
for child in children {
|
||||||
if let Some(idx) = find_inner(child, offset) {
|
if let Some(idx) = find_inner(child, offset) {
|
||||||
|
|
@ -3255,10 +3502,15 @@ mod tests {
|
||||||
let mut offset = 1;
|
let mut offset = 1;
|
||||||
let mut params: Vec<Box<dyn ToSql + Sync + Send>> = Vec::new();
|
let mut params: Vec<Box<dyn ToSql + Sync + Send>> = Vec::new();
|
||||||
let (clause, types, tags) = build_search_clause(&query, &mut offset, &mut params).unwrap();
|
let (clause, types, tags) = build_search_clause(&query, &mut offset, &mut params).unwrap();
|
||||||
assert_eq!(clause, "search_vector @@ plainto_tsquery('english', $1)");
|
// Fuzzy search combines FTS, prefix, ILIKE, and trigram similarity
|
||||||
|
assert!(clause.contains("plainto_tsquery"));
|
||||||
|
assert!(clause.contains("to_tsquery"));
|
||||||
|
assert!(clause.contains("LIKE"));
|
||||||
|
assert!(clause.contains("similarity"));
|
||||||
assert!(types.is_empty());
|
assert!(types.is_empty());
|
||||||
assert!(tags.is_empty());
|
assert!(tags.is_empty());
|
||||||
assert_eq!(offset, 2);
|
// FullText now uses 7 parameters
|
||||||
|
assert_eq!(offset, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@ fn row_to_media_item(row: &Row) -> rusqlite::Result<MediaItem> {
|
||||||
.get::<_, Option<String>>("thumbnail_path")?
|
.get::<_, Option<String>>("thumbnail_path")?
|
||||||
.map(PathBuf::from),
|
.map(PathBuf::from),
|
||||||
custom_fields: HashMap::new(), // loaded separately
|
custom_fields: HashMap::new(), // loaded separately
|
||||||
|
// file_mtime may not be present in all queries, so handle gracefully
|
||||||
|
file_mtime: row.get::<_, Option<i64>>("file_mtime").unwrap_or(None),
|
||||||
created_at: parse_datetime(&created_str),
|
created_at: parse_datetime(&created_str),
|
||||||
updated_at: parse_datetime(&updated_str),
|
updated_at: parse_datetime(&updated_str),
|
||||||
})
|
})
|
||||||
|
|
@ -312,18 +314,22 @@ fn load_custom_fields_batch(db: &Connection, items: &mut [MediaItem]) -> rusqlit
|
||||||
|
|
||||||
/// Translate a `SearchQuery` into components that can be assembled into SQL.
|
/// Translate a `SearchQuery` into components that can be assembled into SQL.
|
||||||
///
|
///
|
||||||
/// Returns `(fts_expr, where_clauses, join_clauses)` where:
|
/// Returns `(fts_expr, like_terms, where_clauses, join_clauses, params)` where:
|
||||||
/// - `fts_expr` is an FTS5 MATCH expression (may be empty),
|
/// - `fts_expr` is an FTS5 MATCH expression (may be empty),
|
||||||
|
/// - `like_terms` are search terms for LIKE fallback matching,
|
||||||
/// - `where_clauses` are extra WHERE predicates (e.g. type filters),
|
/// - `where_clauses` are extra WHERE predicates (e.g. type filters),
|
||||||
/// - `join_clauses` are extra JOIN snippets (e.g. tag filters).
|
/// - `join_clauses` are extra JOIN snippets (e.g. tag filters).
|
||||||
/// - `params` are bind parameter values corresponding to `?` placeholders in
|
/// - `params` are bind parameter values corresponding to `?` placeholders in
|
||||||
/// where_clauses and join_clauses.
|
/// where_clauses and join_clauses.
|
||||||
fn search_query_to_fts(query: &SearchQuery) -> (String, Vec<String>, Vec<String>, Vec<String>) {
|
fn search_query_to_fts(
|
||||||
|
query: &SearchQuery,
|
||||||
|
) -> (String, Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
|
||||||
let mut wheres = Vec::new();
|
let mut wheres = Vec::new();
|
||||||
let mut joins = Vec::new();
|
let mut joins = Vec::new();
|
||||||
let mut params = Vec::new();
|
let mut params = Vec::new();
|
||||||
let fts = build_fts_expr(query, &mut wheres, &mut joins, &mut params);
|
let mut like_terms = Vec::new();
|
||||||
(fts, wheres, joins, params)
|
let fts = build_fts_expr(query, &mut wheres, &mut joins, &mut params, &mut like_terms);
|
||||||
|
(fts, like_terms, wheres, joins, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_fts_expr(
|
fn build_fts_expr(
|
||||||
|
|
@ -331,21 +337,35 @@ fn build_fts_expr(
|
||||||
wheres: &mut Vec<String>,
|
wheres: &mut Vec<String>,
|
||||||
joins: &mut Vec<String>,
|
joins: &mut Vec<String>,
|
||||||
params: &mut Vec<String>,
|
params: &mut Vec<String>,
|
||||||
|
like_terms: &mut Vec<String>,
|
||||||
) -> String {
|
) -> String {
|
||||||
match query {
|
match query {
|
||||||
SearchQuery::FullText(text) => {
|
SearchQuery::FullText(text) => {
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
sanitize_fts_token(text)
|
// Collect term for LIKE fallback matching
|
||||||
|
like_terms.push(text.clone());
|
||||||
|
// Add implicit prefix matching for better partial matches
|
||||||
|
// This allows "mus" to match "music", "musician", etc.
|
||||||
|
let sanitized = sanitize_fts_token(text);
|
||||||
|
// If it's a single word, add prefix matching
|
||||||
|
if !sanitized.contains(' ') && !sanitized.contains('"') {
|
||||||
|
format!("{}*", sanitized)
|
||||||
|
} else {
|
||||||
|
// For phrases, use as-is but also add NEAR for proximity
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SearchQuery::Prefix(prefix) => {
|
SearchQuery::Prefix(prefix) => {
|
||||||
|
like_terms.push(prefix.clone());
|
||||||
format!("{}*", sanitize_fts_token(prefix))
|
format!("{}*", sanitize_fts_token(prefix))
|
||||||
}
|
}
|
||||||
SearchQuery::Fuzzy(term) => {
|
SearchQuery::Fuzzy(term) => {
|
||||||
// FTS5 does not natively support fuzzy; fall back to prefix match
|
// FTS5 does not natively support fuzzy; use prefix match
|
||||||
// as a best-effort approximation.
|
// as a best-effort approximation.
|
||||||
|
like_terms.push(term.clone());
|
||||||
format!("{}*", sanitize_fts_token(term))
|
format!("{}*", sanitize_fts_token(term))
|
||||||
}
|
}
|
||||||
SearchQuery::FieldMatch { field, value } => {
|
SearchQuery::FieldMatch { field, value } => {
|
||||||
|
|
@ -355,7 +375,7 @@ fn build_fts_expr(
|
||||||
format!("{safe_field}:{safe_value}")
|
format!("{safe_field}:{safe_value}")
|
||||||
}
|
}
|
||||||
SearchQuery::Not(inner) => {
|
SearchQuery::Not(inner) => {
|
||||||
let inner_expr = build_fts_expr(inner, wheres, joins, params);
|
let inner_expr = build_fts_expr(inner, wheres, joins, params, like_terms);
|
||||||
if inner_expr.is_empty() {
|
if inner_expr.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -365,7 +385,7 @@ fn build_fts_expr(
|
||||||
SearchQuery::And(terms) => {
|
SearchQuery::And(terms) => {
|
||||||
let parts: Vec<String> = terms
|
let parts: Vec<String> = terms
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| build_fts_expr(t, wheres, joins, params))
|
.map(|t| build_fts_expr(t, wheres, joins, params, like_terms))
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
parts.join(" ")
|
parts.join(" ")
|
||||||
|
|
@ -373,7 +393,7 @@ fn build_fts_expr(
|
||||||
SearchQuery::Or(terms) => {
|
SearchQuery::Or(terms) => {
|
||||||
let parts: Vec<String> = terms
|
let parts: Vec<String> = terms
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| build_fts_expr(t, wheres, joins, params))
|
.map(|t| build_fts_expr(t, wheres, joins, params, like_terms))
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
if parts.len() <= 1 {
|
if parts.len() <= 1 {
|
||||||
|
|
@ -399,6 +419,82 @@ fn build_fts_expr(
|
||||||
params.push(tag_name.clone());
|
params.push(tag_name.clone());
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
|
SearchQuery::RangeQuery { field, start, end } => {
|
||||||
|
let col = match field.as_str() {
|
||||||
|
"year" => "m.year",
|
||||||
|
"size" | "file_size" => "m.file_size",
|
||||||
|
"duration" => "m.duration_secs",
|
||||||
|
_ => return String::new(), // Unknown field, ignore
|
||||||
|
};
|
||||||
|
match (start, end) {
|
||||||
|
(Some(s), Some(e)) => {
|
||||||
|
wheres.push(format!("{col} >= ? AND {col} <= ?"));
|
||||||
|
params.push(s.to_string());
|
||||||
|
params.push(e.to_string());
|
||||||
|
}
|
||||||
|
(Some(s), None) => {
|
||||||
|
wheres.push(format!("{col} >= ?"));
|
||||||
|
params.push(s.to_string());
|
||||||
|
}
|
||||||
|
(None, Some(e)) => {
|
||||||
|
wheres.push(format!("{col} <= ?"));
|
||||||
|
params.push(e.to_string());
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
SearchQuery::CompareQuery { field, op, value } => {
|
||||||
|
let col = match field.as_str() {
|
||||||
|
"year" => "m.year",
|
||||||
|
"size" | "file_size" => "m.file_size",
|
||||||
|
"duration" => "m.duration_secs",
|
||||||
|
_ => return String::new(), // Unknown field, ignore
|
||||||
|
};
|
||||||
|
let op_sql = match op {
|
||||||
|
crate::search::CompareOp::GreaterThan => ">",
|
||||||
|
crate::search::CompareOp::GreaterOrEqual => ">=",
|
||||||
|
crate::search::CompareOp::LessThan => "<",
|
||||||
|
crate::search::CompareOp::LessOrEqual => "<=",
|
||||||
|
};
|
||||||
|
wheres.push(format!("{col} {op_sql} ?"));
|
||||||
|
params.push(value.to_string());
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
SearchQuery::DateQuery { field, value } => {
|
||||||
|
let col = match field.as_str() {
|
||||||
|
"created" => "m.created_at",
|
||||||
|
"modified" | "updated" => "m.updated_at",
|
||||||
|
_ => return String::new(),
|
||||||
|
};
|
||||||
|
let sql = date_value_to_sqlite_expr(col, value);
|
||||||
|
if !sql.is_empty() {
|
||||||
|
wheres.push(sql);
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a DateValue to a SQLite datetime comparison expression
|
||||||
|
fn date_value_to_sqlite_expr(col: &str, value: &crate::search::DateValue) -> String {
|
||||||
|
use crate::search::DateValue;
|
||||||
|
match value {
|
||||||
|
DateValue::Today => format!("date({col}) = date('now')"),
|
||||||
|
DateValue::Yesterday => format!("date({col}) = date('now', '-1 day')"),
|
||||||
|
DateValue::ThisWeek => format!("{col} >= datetime('now', 'weekday 0', '-7 days')"),
|
||||||
|
DateValue::LastWeek => format!(
|
||||||
|
"{col} >= datetime('now', 'weekday 0', '-14 days') AND {col} < datetime('now', 'weekday 0', '-7 days')"
|
||||||
|
),
|
||||||
|
DateValue::ThisMonth => format!("{col} >= datetime('now', 'start of month')"),
|
||||||
|
DateValue::LastMonth => format!(
|
||||||
|
"{col} >= datetime('now', 'start of month', '-1 month') AND {col} < datetime('now', 'start of month')"
|
||||||
|
),
|
||||||
|
DateValue::ThisYear => format!("{col} >= datetime('now', 'start of year')"),
|
||||||
|
DateValue::LastYear => format!(
|
||||||
|
"{col} >= datetime('now', 'start of year', '-1 year') AND {col} < datetime('now', 'start of year')"
|
||||||
|
),
|
||||||
|
DateValue::DaysAgo(days) => format!("{col} >= datetime('now', '-{days} days')"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -514,8 +610,8 @@ impl StorageBackend for SqliteBackend {
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO media_items (id, path, file_name, media_type, content_hash, \
|
"INSERT INTO media_items (id, path, file_name, media_type, content_hash, \
|
||||||
file_size, title, artist, album, genre, year, duration_secs, description, \
|
file_size, title, artist, album, genre, year, duration_secs, description, \
|
||||||
thumbnail_path, created_at, updated_at) \
|
thumbnail_path, file_mtime, created_at, updated_at) \
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||||
params![
|
params![
|
||||||
item.id.0.to_string(),
|
item.id.0.to_string(),
|
||||||
item.path.to_string_lossy().as_ref(),
|
item.path.to_string_lossy().as_ref(),
|
||||||
|
|
@ -533,6 +629,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
item.thumbnail_path
|
item.thumbnail_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.to_string_lossy().to_string()),
|
.map(|p| p.to_string_lossy().to_string()),
|
||||||
|
item.file_mtime,
|
||||||
item.created_at.to_rfc3339(),
|
item.created_at.to_rfc3339(),
|
||||||
item.updated_at.to_rfc3339(),
|
item.updated_at.to_rfc3339(),
|
||||||
],
|
],
|
||||||
|
|
@ -566,7 +663,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
let mut stmt = db.prepare(
|
let mut stmt = db.prepare(
|
||||||
"SELECT id, path, file_name, media_type, content_hash, file_size, \
|
"SELECT id, path, file_name, media_type, content_hash, file_size, \
|
||||||
title, artist, album, genre, year, duration_secs, description, \
|
title, artist, album, genre, year, duration_secs, description, \
|
||||||
thumbnail_path, created_at, updated_at FROM media_items WHERE id = ?1",
|
thumbnail_path, file_mtime, created_at, updated_at FROM media_items WHERE id = ?1",
|
||||||
)?;
|
)?;
|
||||||
let mut item = stmt
|
let mut item = stmt
|
||||||
.query_row(params![id.0.to_string()], row_to_media_item)
|
.query_row(params![id.0.to_string()], row_to_media_item)
|
||||||
|
|
@ -593,7 +690,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
let mut stmt = db.prepare(
|
let mut stmt = db.prepare(
|
||||||
"SELECT id, path, file_name, media_type, content_hash, file_size, \
|
"SELECT id, path, file_name, media_type, content_hash, file_size, \
|
||||||
title, artist, album, genre, year, duration_secs, description, \
|
title, artist, album, genre, year, duration_secs, description, \
|
||||||
thumbnail_path, created_at, updated_at FROM media_items WHERE content_hash = ?1",
|
thumbnail_path, file_mtime, created_at, updated_at FROM media_items WHERE content_hash = ?1",
|
||||||
)?;
|
)?;
|
||||||
let result = stmt
|
let result = stmt
|
||||||
.query_row(params![hash.0], row_to_media_item)
|
.query_row(params![hash.0], row_to_media_item)
|
||||||
|
|
@ -609,6 +706,32 @@ impl StorageBackend for SqliteBackend {
|
||||||
.map_err(|e| PinakesError::Database(e.to_string()))?
|
.map_err(|e| PinakesError::Database(e.to_string()))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_media_by_path(&self, path: &std::path::Path) -> Result<Option<MediaItem>> {
|
||||||
|
let path_str = path.to_string_lossy().to_string();
|
||||||
|
let conn = Arc::clone(&self.conn);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let db = conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||||
|
let mut stmt = db.prepare(
|
||||||
|
"SELECT id, path, file_name, media_type, content_hash, file_size, \
|
||||||
|
title, artist, album, genre, year, duration_secs, description, \
|
||||||
|
thumbnail_path, file_mtime, created_at, updated_at FROM media_items WHERE path = ?1",
|
||||||
|
)?;
|
||||||
|
let result = stmt
|
||||||
|
.query_row(params![path_str], row_to_media_item)
|
||||||
|
.optional()?;
|
||||||
|
if let Some(mut item) = result {
|
||||||
|
item.custom_fields = load_custom_fields_sync(&db, item.id)?;
|
||||||
|
Ok(Some(item))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| PinakesError::Database(e.to_string()))?
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_media(&self, pagination: &Pagination) -> Result<Vec<MediaItem>> {
|
async fn list_media(&self, pagination: &Pagination) -> Result<Vec<MediaItem>> {
|
||||||
let pagination = pagination.clone();
|
let pagination = pagination.clone();
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
|
|
@ -630,7 +753,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, path, file_name, media_type, content_hash, file_size, \
|
"SELECT id, path, file_name, media_type, content_hash, file_size, \
|
||||||
title, artist, album, genre, year, duration_secs, description, \
|
title, artist, album, genre, year, duration_secs, description, \
|
||||||
thumbnail_path, created_at, updated_at FROM media_items \
|
thumbnail_path, file_mtime, created_at, updated_at FROM media_items \
|
||||||
ORDER BY {order_by} LIMIT ?1 OFFSET ?2"
|
ORDER BY {order_by} LIMIT ?1 OFFSET ?2"
|
||||||
);
|
);
|
||||||
let mut stmt = db.prepare(&sql)?;
|
let mut stmt = db.prepare(&sql)?;
|
||||||
|
|
@ -658,7 +781,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
"UPDATE media_items SET path = ?2, file_name = ?3, media_type = ?4, \
|
"UPDATE media_items SET path = ?2, file_name = ?3, media_type = ?4, \
|
||||||
content_hash = ?5, file_size = ?6, title = ?7, artist = ?8, album = ?9, \
|
content_hash = ?5, file_size = ?6, title = ?7, artist = ?8, album = ?9, \
|
||||||
genre = ?10, year = ?11, duration_secs = ?12, description = ?13, \
|
genre = ?10, year = ?11, duration_secs = ?12, description = ?13, \
|
||||||
thumbnail_path = ?14, updated_at = ?15 WHERE id = ?1",
|
thumbnail_path = ?14, file_mtime = ?15, updated_at = ?16 WHERE id = ?1",
|
||||||
params![
|
params![
|
||||||
item.id.0.to_string(),
|
item.id.0.to_string(),
|
||||||
item.path.to_string_lossy().as_ref(),
|
item.path.to_string_lossy().as_ref(),
|
||||||
|
|
@ -676,6 +799,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
item.thumbnail_path
|
item.thumbnail_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.to_string_lossy().to_string()),
|
.map(|p| p.to_string_lossy().to_string()),
|
||||||
|
item.file_mtime,
|
||||||
item.updated_at.to_rfc3339(),
|
item.updated_at.to_rfc3339(),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -1067,7 +1191,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||||
|
|
||||||
let (fts_expr, where_clauses, join_clauses, bind_params) =
|
let (fts_expr, _like_terms, where_clauses, join_clauses, bind_params) =
|
||||||
search_query_to_fts(&request.query);
|
search_query_to_fts(&request.query);
|
||||||
|
|
||||||
let use_fts = !fts_expr.is_empty();
|
let use_fts = !fts_expr.is_empty();
|
||||||
|
|
@ -1309,16 +1433,30 @@ impl StorageBackend for SqliteBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64> {
|
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
let ids: Vec<String> = ids.iter().map(|id| id.0.to_string()).collect();
|
let ids: Vec<String> = ids.iter().map(|id| id.0.to_string()).collect();
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let db = conn
|
let db = conn
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||||
|
// Use IN clause for batch delete - much faster than individual deletes
|
||||||
|
// SQLite has a limit of ~500-1000 items in IN clause, so chunk if needed
|
||||||
|
const CHUNK_SIZE: usize = 500;
|
||||||
db.execute_batch("BEGIN IMMEDIATE")?;
|
db.execute_batch("BEGIN IMMEDIATE")?;
|
||||||
let mut count = 0u64;
|
let mut count = 0u64;
|
||||||
for id in &ids {
|
for chunk in ids.chunks(CHUNK_SIZE) {
|
||||||
let rows = db.execute("DELETE FROM media_items WHERE id = ?1", params![id])?;
|
let placeholders: Vec<String> =
|
||||||
|
(1..=chunk.len()).map(|i| format!("?{}", i)).collect();
|
||||||
|
let sql = format!(
|
||||||
|
"DELETE FROM media_items WHERE id IN ({})",
|
||||||
|
placeholders.join(", ")
|
||||||
|
);
|
||||||
|
let params: Vec<&dyn rusqlite::ToSql> =
|
||||||
|
chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
|
||||||
|
let rows = db.execute(&sql, params.as_slice())?;
|
||||||
count += rows as u64;
|
count += rows as u64;
|
||||||
}
|
}
|
||||||
db.execute_batch("COMMIT")?;
|
db.execute_batch("COMMIT")?;
|
||||||
|
|
@ -1329,6 +1467,9 @@ impl StorageBackend for SqliteBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn batch_tag_media(&self, media_ids: &[MediaId], tag_ids: &[Uuid]) -> Result<u64> {
|
async fn batch_tag_media(&self, media_ids: &[MediaId], tag_ids: &[Uuid]) -> Result<u64> {
|
||||||
|
if media_ids.is_empty() || tag_ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
let media_ids: Vec<String> = media_ids.iter().map(|id| id.0.to_string()).collect();
|
let media_ids: Vec<String> = media_ids.iter().map(|id| id.0.to_string()).collect();
|
||||||
let tag_ids: Vec<String> = tag_ids.iter().map(|id| id.to_string()).collect();
|
let tag_ids: Vec<String> = tag_ids.iter().map(|id| id.to_string()).collect();
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
|
|
@ -1337,13 +1478,14 @@ impl StorageBackend for SqliteBackend {
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||||
db.execute_batch("BEGIN IMMEDIATE")?;
|
db.execute_batch("BEGIN IMMEDIATE")?;
|
||||||
|
// Prepare statement once for reuse
|
||||||
|
let mut stmt = db.prepare_cached(
|
||||||
|
"INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)",
|
||||||
|
)?;
|
||||||
let mut count = 0u64;
|
let mut count = 0u64;
|
||||||
for mid in &media_ids {
|
for mid in &media_ids {
|
||||||
for tid in &tag_ids {
|
for tid in &tag_ids {
|
||||||
db.execute(
|
stmt.execute(params![mid, tid])?;
|
||||||
"INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)",
|
|
||||||
params![mid, tid],
|
|
||||||
)?;
|
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ async fn test_media_crud() {
|
||||||
description: Some("A test file".to_string()),
|
description: Some("A test file".to_string()),
|
||||||
thumbnail_path: None,
|
thumbnail_path: None,
|
||||||
custom_fields: HashMap::new(),
|
custom_fields: HashMap::new(),
|
||||||
|
file_mtime: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
@ -113,6 +114,7 @@ async fn test_tags() {
|
||||||
description: None,
|
description: None,
|
||||||
thumbnail_path: None,
|
thumbnail_path: None,
|
||||||
custom_fields: HashMap::new(),
|
custom_fields: HashMap::new(),
|
||||||
|
file_mtime: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
@ -165,6 +167,7 @@ async fn test_collections() {
|
||||||
description: None,
|
description: None,
|
||||||
thumbnail_path: None,
|
thumbnail_path: None,
|
||||||
custom_fields: HashMap::new(),
|
custom_fields: HashMap::new(),
|
||||||
|
file_mtime: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
@ -212,6 +215,7 @@ async fn test_custom_fields() {
|
||||||
description: None,
|
description: None,
|
||||||
thumbnail_path: None,
|
thumbnail_path: None,
|
||||||
custom_fields: HashMap::new(),
|
custom_fields: HashMap::new(),
|
||||||
|
file_mtime: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
@ -278,6 +282,7 @@ async fn test_search() {
|
||||||
description: None,
|
description: None,
|
||||||
thumbnail_path: None,
|
thumbnail_path: None,
|
||||||
custom_fields: HashMap::new(),
|
custom_fields: HashMap::new(),
|
||||||
|
file_mtime: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
@ -409,6 +414,7 @@ async fn test_library_statistics_with_data() {
|
||||||
description: None,
|
description: None,
|
||||||
thumbnail_path: None,
|
thumbnail_path: None,
|
||||||
custom_fields: HashMap::new(),
|
custom_fields: HashMap::new(),
|
||||||
|
file_mtime: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
@ -445,6 +451,7 @@ fn make_test_media(hash: &str) -> MediaItem {
|
||||||
description: None,
|
description: None,
|
||||||
thumbnail_path: None,
|
thumbnail_path: None,
|
||||||
custom_fields: HashMap::new(),
|
custom_fields: HashMap::new(),
|
||||||
|
file_mtime: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ clap = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||||
tower = { workspace = true }
|
tower = { workspace = true }
|
||||||
tower-http = { workspace = true }
|
tower-http = { workspace = true }
|
||||||
governor = { workspace = true }
|
governor = { workspace = true }
|
||||||
|
|
@ -27,6 +28,7 @@ tokio-util = { version = "0.7", features = ["io"] }
|
||||||
argon2 = { workspace = true }
|
argon2 = { workspace = true }
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
|
http = "1.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,27 @@ use axum::extract::DefaultBodyLimit;
|
||||||
use axum::http::{HeaderValue, Method, header};
|
use axum::http::{HeaderValue, Method, header};
|
||||||
use axum::middleware;
|
use axum::middleware;
|
||||||
use axum::routing::{delete, get, patch, post, put};
|
use axum::routing::{delete, get, patch, post, put};
|
||||||
|
use tower::ServiceBuilder;
|
||||||
use tower_governor::GovernorLayer;
|
use tower_governor::GovernorLayer;
|
||||||
use tower_governor::governor::GovernorConfigBuilder;
|
use tower_governor::governor::GovernorConfigBuilder;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::routes;
|
use crate::routes;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Create the router with optional TLS configuration for HSTS headers
|
||||||
pub fn create_router(state: AppState) -> Router {
|
pub fn create_router(state: AppState) -> Router {
|
||||||
|
create_router_with_tls(state, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the router with TLS configuration for security headers
|
||||||
|
pub fn create_router_with_tls(
|
||||||
|
state: AppState,
|
||||||
|
tls_config: Option<&pinakes_core::config::TlsConfig>,
|
||||||
|
) -> Router {
|
||||||
// Global rate limit: 100 requests/sec per IP
|
// Global rate limit: 100 requests/sec per IP
|
||||||
let global_governor = Arc::new(
|
let global_governor = Arc::new(
|
||||||
GovernorConfigBuilder::default()
|
GovernorConfigBuilder::default()
|
||||||
|
|
@ -41,11 +52,16 @@ pub fn create_router(state: AppState) -> Router {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public routes (no auth required)
|
// Public routes (no auth required)
|
||||||
let public_routes = Router::new().route("/s/{token}", get(routes::social::access_shared_media));
|
let public_routes = Router::new()
|
||||||
|
.route("/s/{token}", get(routes::social::access_shared_media))
|
||||||
|
// Kubernetes-style health probes (no auth required for orchestration)
|
||||||
|
.route("/health/live", get(routes::health::liveness))
|
||||||
|
.route("/health/ready", get(routes::health::readiness));
|
||||||
|
|
||||||
// Read-only routes: any authenticated user (Viewer+)
|
// Read-only routes: any authenticated user (Viewer+)
|
||||||
let viewer_routes = Router::new()
|
let viewer_routes = Router::new()
|
||||||
.route("/health", get(routes::health::health))
|
.route("/health", get(routes::health::health))
|
||||||
|
.route("/health/detailed", get(routes::health::health_detailed))
|
||||||
.route("/media/count", get(routes::media::get_media_count))
|
.route("/media/count", get(routes::media::get_media_count))
|
||||||
.route("/media", get(routes::media::list_media))
|
.route("/media", get(routes::media::list_media))
|
||||||
.route("/media/{id}", get(routes::media::get_media))
|
.route("/media/{id}", get(routes::media::get_media))
|
||||||
|
|
@ -393,7 +409,40 @@ pub fn create_router(state: AppState) -> Router {
|
||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
.merge(protected_api);
|
.merge(protected_api);
|
||||||
|
|
||||||
Router::new()
|
// Build security headers layer
|
||||||
|
let security_headers = ServiceBuilder::new()
|
||||||
|
// Prevent MIME type sniffing
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
))
|
||||||
|
// Prevent clickjacking
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::X_FRAME_OPTIONS,
|
||||||
|
HeaderValue::from_static("DENY"),
|
||||||
|
))
|
||||||
|
// XSS protection (legacy but still useful for older browsers)
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::HeaderName::from_static("x-xss-protection"),
|
||||||
|
HeaderValue::from_static("1; mode=block"),
|
||||||
|
))
|
||||||
|
// Referrer policy
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||||
|
))
|
||||||
|
// Permissions policy (disable unnecessary features)
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::HeaderName::from_static("permissions-policy"),
|
||||||
|
HeaderValue::from_static("geolocation=(), microphone=(), camera=()"),
|
||||||
|
))
|
||||||
|
// Content Security Policy for API responses
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
|
||||||
|
));
|
||||||
|
|
||||||
|
let router = Router::new()
|
||||||
.nest("/api/v1", full_api)
|
.nest("/api/v1", full_api)
|
||||||
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
|
||||||
.layer(GovernorLayer {
|
.layer(GovernorLayer {
|
||||||
|
|
@ -401,5 +450,26 @@ pub fn create_router(state: AppState) -> Router {
|
||||||
})
|
})
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state)
|
.layer(security_headers);
|
||||||
|
|
||||||
|
// Add HSTS header when TLS is enabled
|
||||||
|
if let Some(tls) = tls_config {
|
||||||
|
if tls.enabled && tls.hsts_enabled {
|
||||||
|
let hsts_value = format!("max-age={}; includeSubDomains", tls.hsts_max_age);
|
||||||
|
let hsts_header = HeaderValue::from_str(&hsts_value).unwrap_or_else(|_| {
|
||||||
|
HeaderValue::from_static("max-age=31536000; includeSubDomains")
|
||||||
|
});
|
||||||
|
|
||||||
|
router
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::STRICT_TRANSPORT_SECURITY,
|
||||||
|
hsts_header,
|
||||||
|
))
|
||||||
|
.with_state(state)
|
||||||
|
} else {
|
||||||
|
router.with_state(state)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
router.with_state(state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::response::Redirect;
|
||||||
|
use axum::routing::any;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
@ -202,6 +205,7 @@ async fn main() -> Result<()> {
|
||||||
scanning: false,
|
scanning: false,
|
||||||
files_found: total_found,
|
files_found: total_found,
|
||||||
files_processed: total_processed,
|
files_processed: total_processed,
|
||||||
|
files_skipped: 0,
|
||||||
errors: all_errors,
|
errors: all_errors,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -459,7 +463,7 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
storage: storage.clone(),
|
storage: storage.clone(),
|
||||||
config: config_arc,
|
config: config_arc.clone(),
|
||||||
config_path: Some(config_path),
|
config_path: Some(config_path),
|
||||||
scan_progress: pinakes_core::scan::ScanProgress::new(),
|
scan_progress: pinakes_core::scan::ScanProgress::new(),
|
||||||
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||||
|
|
@ -489,23 +493,124 @@ async fn main() -> Result<()> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let router = app::create_router(state);
|
let config_read = config_arc.read().await;
|
||||||
|
let tls_config = config_read.server.tls.clone();
|
||||||
|
drop(config_read);
|
||||||
|
|
||||||
info!(addr = %addr, "server listening");
|
// Create router with TLS config for HSTS headers
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
let router = if tls_config.enabled {
|
||||||
|
app::create_router_with_tls(state, Some(&tls_config))
|
||||||
|
} else {
|
||||||
|
app::create_router(state)
|
||||||
|
};
|
||||||
|
|
||||||
axum::serve(
|
if tls_config.enabled {
|
||||||
listener,
|
// TLS/HTTPS mode
|
||||||
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
let cert_path = tls_config
|
||||||
)
|
.cert_path
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.as_ref()
|
||||||
.await?;
|
.ok_or_else(|| anyhow::anyhow!("TLS enabled but cert_path not specified"))?;
|
||||||
|
let key_path = tls_config
|
||||||
|
.key_path
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TLS enabled but key_path not specified"))?;
|
||||||
|
|
||||||
|
info!(addr = %addr, cert = %cert_path.display(), "server listening with TLS");
|
||||||
|
|
||||||
|
// Configure TLS
|
||||||
|
let tls_config_builder =
|
||||||
|
axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?;
|
||||||
|
|
||||||
|
// Start HTTP redirect server if configured
|
||||||
|
if tls_config.redirect_http {
|
||||||
|
let http_addr = format!(
|
||||||
|
"{}:{}",
|
||||||
|
config_arc.read().await.server.host,
|
||||||
|
tls_config.http_port
|
||||||
|
);
|
||||||
|
let https_port = config_arc.read().await.server.port;
|
||||||
|
let https_host = config_arc.read().await.server.host.clone();
|
||||||
|
|
||||||
|
let redirect_router = create_https_redirect_router(https_host, https_port);
|
||||||
|
let shutdown = shutdown_token.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let listener = match tokio::net::TcpListener::bind(&http_addr).await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, addr = %http_addr, "failed to bind HTTP redirect listener");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!(addr = %http_addr, "HTTP redirect server listening");
|
||||||
|
let server = axum::serve(
|
||||||
|
listener,
|
||||||
|
redirect_router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
);
|
||||||
|
tokio::select! {
|
||||||
|
result = server => {
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::warn!(error = %e, "HTTP redirect server error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = shutdown.cancelled() => {
|
||||||
|
info!("HTTP redirect server shutting down");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start HTTPS server with graceful shutdown via Handle
|
||||||
|
let addr_parsed: std::net::SocketAddr = addr.parse()?;
|
||||||
|
let handle = axum_server::Handle::new();
|
||||||
|
let shutdown_handle = handle.clone();
|
||||||
|
|
||||||
|
// Spawn a task to trigger graceful shutdown
|
||||||
|
tokio::spawn(async move {
|
||||||
|
shutdown_signal().await;
|
||||||
|
shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(30)));
|
||||||
|
});
|
||||||
|
|
||||||
|
axum_server::bind_rustls(addr_parsed, tls_config_builder)
|
||||||
|
.handle(handle)
|
||||||
|
.serve(router.into_make_service_with_connect_info::<std::net::SocketAddr>())
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
// Plain HTTP mode
|
||||||
|
info!(addr = %addr, "server listening");
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
shutdown_token.cancel();
|
shutdown_token.cancel();
|
||||||
info!("server shut down");
|
info!("server shut down");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a router that redirects all HTTP requests to HTTPS
|
||||||
|
fn create_https_redirect_router(https_host: String, https_port: u16) -> Router {
|
||||||
|
Router::new().fallback(any(move |uri: axum::http::Uri| {
|
||||||
|
let https_host = https_host.clone();
|
||||||
|
async move {
|
||||||
|
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||||
|
|
||||||
|
let https_url = if https_port == 443 {
|
||||||
|
format!("https://{}{}", https_host, path_and_query)
|
||||||
|
} else {
|
||||||
|
format!("https://{}:{}{}", https_host, https_port, path_and_query)
|
||||||
|
};
|
||||||
|
|
||||||
|
Redirect::permanent(&https_url)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn shutdown_signal() {
|
async fn shutdown_signal() {
|
||||||
let ctrl_c = async {
|
let ctrl_c = async {
|
||||||
match tokio::signal::ctrl_c().await {
|
match tokio::signal::ctrl_c().await {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ use axum::http::{HeaderMap, StatusCode};
|
||||||
use crate::dto::{LoginRequest, LoginResponse, UserInfoResponse};
|
use crate::dto::{LoginRequest, LoginResponse, UserInfoResponse};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Dummy password hash to use for timing-safe comparison when user doesn't exist.
|
||||||
|
/// This is a valid argon2 hash that will always fail verification but takes
|
||||||
|
/// similar time to verify as a real hash, preventing timing attacks that could
|
||||||
|
/// reveal whether a username exists.
|
||||||
|
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk";
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<LoginRequest>,
|
Json(req): Json<LoginRequest>,
|
||||||
|
|
@ -25,27 +31,47 @@ pub async fn login(
|
||||||
.iter()
|
.iter()
|
||||||
.find(|u| u.username == req.username);
|
.find(|u| u.username == req.username);
|
||||||
|
|
||||||
let user = match user {
|
// Always perform password verification to prevent timing attacks.
|
||||||
Some(u) => u,
|
// If the user doesn't exist, we verify against a dummy hash to ensure
|
||||||
None => {
|
// consistent response times regardless of whether the username exists.
|
||||||
tracing::warn!(username = %req.username, "login failed: unknown user");
|
use argon2::password_hash::PasswordVerifier;
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
let (hash_to_verify, user_found) = match user {
|
||||||
|
Some(u) => (&u.password_hash as &str, true),
|
||||||
|
None => (DUMMY_HASH, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify password using argon2
|
let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify)
|
||||||
use argon2::password_hash::PasswordVerifier;
|
|
||||||
let hash = &user.password_hash;
|
|
||||||
let parsed_hash = argon2::password_hash::PasswordHash::new(hash)
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
let valid = argon2::Argon2::default()
|
|
||||||
|
let password_valid = argon2::Argon2::default()
|
||||||
.verify_password(req.password.as_bytes(), &parsed_hash)
|
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||||||
.is_ok();
|
.is_ok();
|
||||||
if !valid {
|
|
||||||
tracing::warn!(username = %req.username, "login failed: invalid password");
|
// Authentication fails if user wasn't found OR password was invalid
|
||||||
|
if !user_found || !password_valid {
|
||||||
|
// Log different messages for debugging but return same error
|
||||||
|
if !user_found {
|
||||||
|
tracing::warn!(username = %req.username, "login failed: unknown user");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(username = %req.username, "login failed: invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record failed login attempt in audit log
|
||||||
|
let _ = pinakes_core::audit::record_action(
|
||||||
|
&state.storage,
|
||||||
|
None,
|
||||||
|
pinakes_core::model::AuditAction::LoginFailed,
|
||||||
|
Some(format!("username: {}", req.username)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// At this point we know the user exists and password is valid
|
||||||
|
let user = user.expect("user should exist at this point");
|
||||||
|
|
||||||
// Generate session token
|
// Generate session token
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
let token: String = rand::rng()
|
let token: String = rand::rng()
|
||||||
|
|
@ -72,6 +98,15 @@ pub async fn login(
|
||||||
|
|
||||||
tracing::info!(username = %username, role = %role, "login successful");
|
tracing::info!(username = %username, role = %role, "login successful");
|
||||||
|
|
||||||
|
// Record successful login in audit log
|
||||||
|
let _ = pinakes_core::audit::record_action(
|
||||||
|
&state.storage,
|
||||||
|
None,
|
||||||
|
pinakes_core::model::AuditAction::LoginSuccess,
|
||||||
|
Some(format!("username: {}, role: {}", username, role)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
token,
|
token,
|
||||||
username,
|
username,
|
||||||
|
|
@ -81,8 +116,24 @@ pub async fn login(
|
||||||
|
|
||||||
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> StatusCode {
|
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> StatusCode {
|
||||||
if let Some(token) = extract_bearer_token(&headers) {
|
if let Some(token) = extract_bearer_token(&headers) {
|
||||||
|
let sessions = state.sessions.read().await;
|
||||||
|
let username = sessions.get(token).map(|s| s.username.clone());
|
||||||
|
drop(sessions);
|
||||||
|
|
||||||
let mut sessions = state.sessions.write().await;
|
let mut sessions = state.sessions.write().await;
|
||||||
sessions.remove(token);
|
sessions.remove(token);
|
||||||
|
drop(sessions);
|
||||||
|
|
||||||
|
// Record logout in audit log
|
||||||
|
if let Some(user) = username {
|
||||||
|
let _ = pinakes_core::audit::record_action(
|
||||||
|
&state.storage,
|
||||||
|
None,
|
||||||
|
pinakes_core::model::AuditAction::Logout,
|
||||||
|
Some(format!("username: {}", user)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,221 @@
|
||||||
use axum::Json;
|
use std::time::Instant;
|
||||||
|
|
||||||
pub async fn health() -> Json<serde_json::Value> {
|
use axum::Json;
|
||||||
Json(serde_json::json!({
|
use axum::extract::State;
|
||||||
"status": "ok",
|
use axum::http::StatusCode;
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
use axum::response::IntoResponse;
|
||||||
}))
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Basic health check response
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub version: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub database: Option<DatabaseHealth>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub filesystem: Option<FilesystemHealth>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cache: Option<CacheHealth>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DatabaseHealth {
|
||||||
|
pub status: String,
|
||||||
|
pub latency_ms: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub media_count: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FilesystemHealth {
|
||||||
|
pub status: String,
|
||||||
|
pub roots_configured: usize,
|
||||||
|
pub roots_accessible: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CacheHealth {
|
||||||
|
pub hit_rate: f64,
|
||||||
|
pub total_entries: u64,
|
||||||
|
pub responses_size: u64,
|
||||||
|
pub queries_size: u64,
|
||||||
|
pub media_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comprehensive health check - includes database, filesystem, and cache status
|
||||||
|
pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||||
|
let mut response = HealthResponse {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
database: None,
|
||||||
|
filesystem: None,
|
||||||
|
cache: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check database health
|
||||||
|
let db_start = Instant::now();
|
||||||
|
let db_health = match state.storage.count_media().await {
|
||||||
|
Ok(count) => DatabaseHealth {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
latency_ms: db_start.elapsed().as_millis() as u64,
|
||||||
|
media_count: Some(count),
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
response.status = "degraded".to_string();
|
||||||
|
DatabaseHealth {
|
||||||
|
status: format!("error: {}", e),
|
||||||
|
latency_ms: db_start.elapsed().as_millis() as u64,
|
||||||
|
media_count: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
response.database = Some(db_health);
|
||||||
|
|
||||||
|
// Check filesystem health (root directories)
|
||||||
|
let roots = match state.storage.list_root_dirs().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
let roots_accessible = roots.iter().filter(|r| r.exists()).count();
|
||||||
|
if roots_accessible < roots.len() {
|
||||||
|
response.status = "degraded".to_string();
|
||||||
|
}
|
||||||
|
response.filesystem = Some(FilesystemHealth {
|
||||||
|
status: if roots_accessible == roots.len() {
|
||||||
|
"ok"
|
||||||
|
} else {
|
||||||
|
"degraded"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
roots_configured: roots.len(),
|
||||||
|
roots_accessible,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get cache statistics
|
||||||
|
let cache_stats = state.cache.stats();
|
||||||
|
response.cache = Some(CacheHealth {
|
||||||
|
hit_rate: cache_stats.overall_hit_rate(),
|
||||||
|
total_entries: cache_stats.total_entries(),
|
||||||
|
responses_size: cache_stats.responses.size,
|
||||||
|
queries_size: cache_stats.queries.size,
|
||||||
|
media_size: cache_stats.media.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
Json(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liveness probe - just checks if the server is running
|
||||||
|
/// Returns 200 OK if the server process is alive
|
||||||
|
pub async fn liveness() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "alive"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Readiness probe - checks if the server can serve requests
|
||||||
|
/// Returns 200 OK if database is accessible
|
||||||
|
pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
// Check database connectivity
|
||||||
|
let db_start = Instant::now();
|
||||||
|
match state.storage.count_media().await {
|
||||||
|
Ok(_) => {
|
||||||
|
let latency = db_start.elapsed().as_millis() as u64;
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "ready",
|
||||||
|
"database_latency_ms": latency
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "not_ready",
|
||||||
|
"reason": e.to_string()
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detailed health check for monitoring dashboards
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DetailedHealthResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub version: String,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
pub database: DatabaseHealth,
|
||||||
|
pub filesystem: FilesystemHealth,
|
||||||
|
pub cache: CacheHealth,
|
||||||
|
pub jobs: JobsHealth,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct JobsHealth {
|
||||||
|
pub pending: usize,
|
||||||
|
pub running: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn health_detailed(State(state): State<AppState>) -> Json<DetailedHealthResponse> {
|
||||||
|
// Check database
|
||||||
|
let db_start = Instant::now();
|
||||||
|
let (db_status, media_count) = match state.storage.count_media().await {
|
||||||
|
Ok(count) => ("ok".to_string(), Some(count)),
|
||||||
|
Err(e) => (format!("error: {}", e), None),
|
||||||
|
};
|
||||||
|
let db_latency = db_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
// Check filesystem
|
||||||
|
let roots = state.storage.list_root_dirs().await.unwrap_or_default();
|
||||||
|
let roots_accessible = roots.iter().filter(|r| r.exists()).count();
|
||||||
|
|
||||||
|
// Get cache stats
|
||||||
|
let cache_stats = state.cache.stats();
|
||||||
|
|
||||||
|
// Get job queue stats
|
||||||
|
let job_stats = state.job_queue.stats().await;
|
||||||
|
|
||||||
|
let overall_status = if db_status == "ok" && roots_accessible == roots.len() {
|
||||||
|
"ok"
|
||||||
|
} else {
|
||||||
|
"degraded"
|
||||||
|
};
|
||||||
|
|
||||||
|
Json(DetailedHealthResponse {
|
||||||
|
status: overall_status.to_string(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
uptime_seconds: 0, // Could track server start time
|
||||||
|
database: DatabaseHealth {
|
||||||
|
status: db_status,
|
||||||
|
latency_ms: db_latency,
|
||||||
|
media_count,
|
||||||
|
},
|
||||||
|
filesystem: FilesystemHealth {
|
||||||
|
status: if roots_accessible == roots.len() {
|
||||||
|
"ok"
|
||||||
|
} else {
|
||||||
|
"degraded"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
roots_configured: roots.len(),
|
||||||
|
roots_accessible,
|
||||||
|
},
|
||||||
|
cache: CacheHealth {
|
||||||
|
hit_rate: cache_stats.overall_hit_rate(),
|
||||||
|
total_entries: cache_stats.total_entries(),
|
||||||
|
responses_size: cache_stats.responses.size,
|
||||||
|
queries_size: cache_stats.queries.size,
|
||||||
|
media_size: cache_stats.media.size,
|
||||||
|
},
|
||||||
|
jobs: JobsHealth {
|
||||||
|
pending: job_stats.pending,
|
||||||
|
running: job_stats.running,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use pinakes_core::cache::CacheLayer;
|
||||||
use pinakes_core::config::{
|
use pinakes_core::config::{
|
||||||
AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig,
|
AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig,
|
||||||
JobsConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType,
|
JobsConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType,
|
||||||
StorageConfig, ThumbnailConfig, TranscodingConfig, UiConfig, UserAccount, UserRole,
|
StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig, UserAccount, UserRole,
|
||||||
WebhookConfig,
|
WebhookConfig,
|
||||||
};
|
};
|
||||||
use pinakes_core::jobs::JobQueue;
|
use pinakes_core::jobs::JobQueue;
|
||||||
|
|
@ -112,6 +112,7 @@ fn default_config() -> Config {
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: 3000,
|
port: 3000,
|
||||||
api_key: None,
|
api_key: None,
|
||||||
|
tls: TlsConfig::default(),
|
||||||
},
|
},
|
||||||
ui: UiConfig::default(),
|
ui: UiConfig::default(),
|
||||||
accounts: AccountsConfig::default(),
|
accounts: AccountsConfig::default(),
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use pinakes_core::cache::CacheLayer;
|
||||||
use pinakes_core::config::{
|
use pinakes_core::config::{
|
||||||
AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig,
|
AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig,
|
||||||
JobsConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType,
|
JobsConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType,
|
||||||
StorageConfig, ThumbnailConfig, TranscodingConfig, UiConfig, WebhookConfig,
|
StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig, WebhookConfig,
|
||||||
};
|
};
|
||||||
use pinakes_core::jobs::JobQueue;
|
use pinakes_core::jobs::JobQueue;
|
||||||
use pinakes_core::plugin::PluginManager;
|
use pinakes_core::plugin::PluginManager;
|
||||||
|
|
@ -77,6 +77,7 @@ async fn setup_app_with_plugins() -> (axum::Router, Arc<PluginManager>, tempfile
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: 3000,
|
port: 3000,
|
||||||
api_key: None,
|
api_key: None,
|
||||||
|
tls: TlsConfig::default(),
|
||||||
},
|
},
|
||||||
ui: UiConfig::default(),
|
ui: UiConfig::default(),
|
||||||
accounts: AccountsConfig::default(),
|
accounts: AccountsConfig::default(),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
@ -53,6 +54,9 @@ pub struct AppState {
|
||||||
pub page_size: u64,
|
pub page_size: u64,
|
||||||
pub total_media_count: u64,
|
pub total_media_count: u64,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
// Multi-select support
|
||||||
|
pub selected_items: HashSet<String>,
|
||||||
|
pub selection_mode: bool,
|
||||||
// Duplicates view
|
// Duplicates view
|
||||||
pub duplicate_groups: Vec<crate::client::DuplicateGroupResponse>,
|
pub duplicate_groups: Vec<crate::client::DuplicateGroupResponse>,
|
||||||
pub duplicates_selected: Option<usize>,
|
pub duplicates_selected: Option<usize>,
|
||||||
|
|
@ -131,6 +135,9 @@ impl AppState {
|
||||||
page_size: 50,
|
page_size: 50,
|
||||||
total_media_count: 0,
|
total_media_count: 0,
|
||||||
server_url: server_url.to_string(),
|
server_url: server_url.to_string(),
|
||||||
|
// Multi-select
|
||||||
|
selected_items: HashSet::new(),
|
||||||
|
selection_mode: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1156,6 +1163,154 @@ async fn handle_action(
|
||||||
state.current_view = View::Detail;
|
state.current_view = View::Detail;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::ToggleSelection => {
|
||||||
|
// Toggle selection of current item
|
||||||
|
let item_id = match state.current_view {
|
||||||
|
View::Search => state
|
||||||
|
.search_selected
|
||||||
|
.and_then(|i| state.search_results.get(i))
|
||||||
|
.map(|m| m.id.clone()),
|
||||||
|
View::Library => state
|
||||||
|
.selected_index
|
||||||
|
.and_then(|i| state.media_list.get(i))
|
||||||
|
.map(|m| m.id.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(id) = item_id {
|
||||||
|
if state.selected_items.contains(&id) {
|
||||||
|
state.selected_items.remove(&id);
|
||||||
|
} else {
|
||||||
|
state.selected_items.insert(id);
|
||||||
|
}
|
||||||
|
let count = state.selected_items.len();
|
||||||
|
state.status_message = Some(format!("{} item(s) selected", count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::SelectAll => {
|
||||||
|
// Select all items in current view
|
||||||
|
let items: Vec<String> = match state.current_view {
|
||||||
|
View::Search => state.search_results.iter().map(|m| m.id.clone()).collect(),
|
||||||
|
View::Library => state.media_list.iter().map(|m| m.id.clone()).collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
for id in items {
|
||||||
|
state.selected_items.insert(id);
|
||||||
|
}
|
||||||
|
let count = state.selected_items.len();
|
||||||
|
state.status_message = Some(format!("{} item(s) selected", count));
|
||||||
|
}
|
||||||
|
Action::ClearSelection => {
|
||||||
|
state.selected_items.clear();
|
||||||
|
state.selection_mode = false;
|
||||||
|
state.status_message = Some("Selection cleared".into());
|
||||||
|
}
|
||||||
|
Action::ToggleSelectionMode => {
|
||||||
|
state.selection_mode = !state.selection_mode;
|
||||||
|
if state.selection_mode {
|
||||||
|
state.status_message =
|
||||||
|
Some("Selection mode: ON (Space to toggle, u to clear)".into());
|
||||||
|
} else {
|
||||||
|
state.status_message = Some("Selection mode: OFF".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::BatchDelete => {
|
||||||
|
if state.selected_items.is_empty() {
|
||||||
|
state.status_message = Some("No items selected".into());
|
||||||
|
} else {
|
||||||
|
let count = state.selected_items.len();
|
||||||
|
let ids: Vec<String> = state.selected_items.iter().cloned().collect();
|
||||||
|
state.status_message = Some(format!("Deleting {} item(s)...", count));
|
||||||
|
let client = client.clone();
|
||||||
|
let tx = event_sender.clone();
|
||||||
|
let page_offset = state.page_offset;
|
||||||
|
let page_size = state.page_size;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut deleted = 0;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for id in &ids {
|
||||||
|
match client.delete_media(id).await {
|
||||||
|
Ok(_) => deleted += 1,
|
||||||
|
Err(e) => errors.push(format!("{}: {}", id, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Refresh the media list
|
||||||
|
if let Ok(items) = client.list_media(page_offset, page_size).await {
|
||||||
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaList(items)));
|
||||||
|
}
|
||||||
|
if errors.is_empty() {
|
||||||
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||||
|
"Deleted {} item(s)",
|
||||||
|
deleted
|
||||||
|
))));
|
||||||
|
} else {
|
||||||
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||||
|
"Deleted {} item(s), {} error(s)",
|
||||||
|
deleted,
|
||||||
|
errors.len()
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.selected_items.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::BatchTag => {
|
||||||
|
if state.selected_items.is_empty() {
|
||||||
|
state.status_message = Some("No items selected".into());
|
||||||
|
} else if state.all_tags.is_empty() {
|
||||||
|
// Load tags first
|
||||||
|
match client.list_tags().await {
|
||||||
|
Ok(tags) => {
|
||||||
|
state.all_tags = tags;
|
||||||
|
if state.all_tags.is_empty() {
|
||||||
|
state.status_message =
|
||||||
|
Some("No tags available. Create a tag first.".into());
|
||||||
|
} else {
|
||||||
|
state.tag_selected = Some(0);
|
||||||
|
state.status_message = Some(format!(
|
||||||
|
"{} item(s) selected. Press +/- to tag/untag with selected tag.",
|
||||||
|
state.selected_items.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => state.status_message = Some(format!("Failed to load tags: {e}")),
|
||||||
|
}
|
||||||
|
} else if let Some(tag_idx) = state.tag_selected
|
||||||
|
&& let Some(tag) = state.all_tags.get(tag_idx)
|
||||||
|
{
|
||||||
|
let count = state.selected_items.len();
|
||||||
|
let ids: Vec<String> = state.selected_items.iter().cloned().collect();
|
||||||
|
let tag_id = tag.id.clone();
|
||||||
|
let tag_name = tag.name.clone();
|
||||||
|
state.status_message =
|
||||||
|
Some(format!("Tagging {} item(s) with '{}'...", count, tag_name));
|
||||||
|
let client = client.clone();
|
||||||
|
let tx = event_sender.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tagged = 0;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for id in &ids {
|
||||||
|
match client.tag_media(id, &tag_id).await {
|
||||||
|
Ok(_) => tagged += 1,
|
||||||
|
Err(e) => errors.push(format!("{}: {}", id, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors.is_empty() {
|
||||||
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||||
|
"Tagged {} item(s) with '{}'",
|
||||||
|
tagged, tag_name
|
||||||
|
))));
|
||||||
|
} else {
|
||||||
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||||
|
"Tagged {} item(s), {} error(s)",
|
||||||
|
tagged,
|
||||||
|
errors.len()
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
state.status_message = Some("Select a tag first (use t to view tags)".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::NavigateLeft | Action::NavigateRight | Action::None => {}
|
Action::NavigateLeft | Action::NavigateRight | Action::None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,13 @@ pub enum Action {
|
||||||
Save,
|
Save,
|
||||||
Char(char),
|
Char(char),
|
||||||
Backspace,
|
Backspace,
|
||||||
|
// Multi-select actions
|
||||||
|
ToggleSelection,
|
||||||
|
SelectAll,
|
||||||
|
ClearSelection,
|
||||||
|
ToggleSelectionMode,
|
||||||
|
BatchDelete,
|
||||||
|
BatchTag,
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,13 +94,25 @@ pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Ac
|
||||||
_ => Action::TagView,
|
_ => Action::TagView,
|
||||||
},
|
},
|
||||||
(KeyCode::Char('c'), _) => Action::CollectionView,
|
(KeyCode::Char('c'), _) => Action::CollectionView,
|
||||||
|
// Multi-select: Ctrl+A for SelectAll (must come before plain 'a')
|
||||||
|
(KeyCode::Char('a'), KeyModifiers::CONTROL) => match current_view {
|
||||||
|
View::Library | View::Search => Action::SelectAll,
|
||||||
|
_ => Action::None,
|
||||||
|
},
|
||||||
(KeyCode::Char('a'), _) => Action::AuditView,
|
(KeyCode::Char('a'), _) => Action::AuditView,
|
||||||
(KeyCode::Char('S'), _) => Action::SettingsView,
|
(KeyCode::Char('S'), _) => Action::SettingsView,
|
||||||
(KeyCode::Char('D'), _) => Action::DuplicatesView,
|
|
||||||
(KeyCode::Char('B'), _) => Action::DatabaseView,
|
(KeyCode::Char('B'), _) => Action::DatabaseView,
|
||||||
(KeyCode::Char('Q'), _) => Action::QueueView,
|
(KeyCode::Char('Q'), _) => Action::QueueView,
|
||||||
(KeyCode::Char('X'), _) => Action::StatisticsView,
|
(KeyCode::Char('X'), _) => Action::StatisticsView,
|
||||||
(KeyCode::Char('T'), _) => Action::TasksView,
|
// Use plain D/T for views in non-library contexts, keep for batch ops in library/search
|
||||||
|
(KeyCode::Char('D'), _) => match current_view {
|
||||||
|
View::Library | View::Search => Action::BatchDelete,
|
||||||
|
_ => Action::DuplicatesView,
|
||||||
|
},
|
||||||
|
(KeyCode::Char('T'), _) => match current_view {
|
||||||
|
View::Library | View::Search => Action::BatchTag,
|
||||||
|
_ => Action::TasksView,
|
||||||
|
},
|
||||||
// Ctrl+S must come before plain 's' to ensure proper precedence
|
// Ctrl+S must come before plain 's' to ensure proper precedence
|
||||||
(KeyCode::Char('s'), KeyModifiers::CONTROL) => match current_view {
|
(KeyCode::Char('s'), KeyModifiers::CONTROL) => match current_view {
|
||||||
View::MetadataEdit => Action::Save,
|
View::MetadataEdit => Action::Save,
|
||||||
|
|
@ -106,7 +125,7 @@ pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Ac
|
||||||
(KeyCode::Char('-'), _) => Action::UntagMedia,
|
(KeyCode::Char('-'), _) => Action::UntagMedia,
|
||||||
(KeyCode::Char('v'), _) => match current_view {
|
(KeyCode::Char('v'), _) => match current_view {
|
||||||
View::Database => Action::Vacuum,
|
View::Database => Action::Vacuum,
|
||||||
_ => Action::None,
|
_ => Action::ToggleSelectionMode,
|
||||||
},
|
},
|
||||||
(KeyCode::Char('x'), _) => match current_view {
|
(KeyCode::Char('x'), _) => match current_view {
|
||||||
View::Tasks => Action::RunNow,
|
View::Tasks => Action::RunNow,
|
||||||
|
|
@ -116,6 +135,15 @@ pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Ac
|
||||||
(KeyCode::BackTab, _) => Action::PrevTab,
|
(KeyCode::BackTab, _) => Action::PrevTab,
|
||||||
(KeyCode::PageUp, _) => Action::PageUp,
|
(KeyCode::PageUp, _) => Action::PageUp,
|
||||||
(KeyCode::PageDown, _) => Action::PageDown,
|
(KeyCode::PageDown, _) => Action::PageDown,
|
||||||
|
// Multi-select keys
|
||||||
|
(KeyCode::Char(' '), _) => match current_view {
|
||||||
|
View::Library | View::Search => Action::ToggleSelection,
|
||||||
|
_ => Action::None,
|
||||||
|
},
|
||||||
|
(KeyCode::Char('u'), _) => match current_view {
|
||||||
|
View::Library | View::Search => Action::ClearSelection,
|
||||||
|
_ => Action::None,
|
||||||
|
},
|
||||||
_ => Action::None,
|
_ => Action::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use super::{format_duration, format_size, media_type_color};
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
let header = Row::new(vec!["Title / Name", "Type", "Duration", "Year", "Size"]).style(
|
let header = Row::new(vec!["", "Title / Name", "Type", "Duration", "Year", "Size"]).style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
|
|
@ -19,12 +19,27 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, item)| {
|
.map(|(i, item)| {
|
||||||
let style = if Some(i) == state.selected_index {
|
let is_cursor = Some(i) == state.selected_index;
|
||||||
|
let is_selected = state.selected_items.contains(&item.id);
|
||||||
|
|
||||||
|
let style = if is_cursor {
|
||||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
|
} else if is_selected {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Green)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Selection marker
|
||||||
|
let marker = if is_selected { "[*]" } else { "[ ]" };
|
||||||
|
let marker_style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string();
|
let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string();
|
||||||
|
|
||||||
let type_color = media_type_color(&item.media_type);
|
let type_color = media_type_color(&item.media_type);
|
||||||
|
|
@ -44,6 +59,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
.unwrap_or_else(|| "-".to_string());
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
|
Cell::from(Span::styled(marker, marker_style)),
|
||||||
Cell::from(display_name),
|
Cell::from(display_name),
|
||||||
type_cell,
|
type_cell,
|
||||||
Cell::from(duration),
|
Cell::from(duration),
|
||||||
|
|
@ -56,16 +72,22 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
|
||||||
let page = (state.page_offset / state.page_size) + 1;
|
let page = (state.page_offset / state.page_size) + 1;
|
||||||
let item_count = state.media_list.len();
|
let item_count = state.media_list.len();
|
||||||
let title = format!(" Library (page {page}, {item_count} items) ");
|
let selected_count = state.selected_items.len();
|
||||||
|
let title = if selected_count > 0 {
|
||||||
|
format!(" Library (page {page}, {item_count} items, {selected_count} selected) ")
|
||||||
|
} else {
|
||||||
|
format!(" Library (page {page}, {item_count} items) ")
|
||||||
|
};
|
||||||
|
|
||||||
let table = Table::new(
|
let table = Table::new(
|
||||||
rows,
|
rows,
|
||||||
[
|
[
|
||||||
Constraint::Percentage(35),
|
Constraint::Length(3), // Selection marker
|
||||||
Constraint::Percentage(20),
|
Constraint::Percentage(33), // Title
|
||||||
Constraint::Percentage(15),
|
Constraint::Percentage(18), // Type
|
||||||
Constraint::Percentage(10),
|
Constraint::Percentage(13), // Duration
|
||||||
Constraint::Percentage(20),
|
Constraint::Percentage(8), // Year
|
||||||
|
Constraint::Percentage(18), // Size
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.header(header)
|
.header(header)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
f.render_widget(input, chunks[0]);
|
f.render_widget(input, chunks[0]);
|
||||||
|
|
||||||
// Results
|
// Results
|
||||||
let header = Row::new(vec!["Name", "Type", "Artist", "Size"]).style(
|
let header = Row::new(vec!["", "Name", "Type", "Artist", "Size"]).style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
|
|
@ -39,12 +39,27 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, item)| {
|
.map(|(i, item)| {
|
||||||
let style = if Some(i) == state.search_selected {
|
let is_cursor = Some(i) == state.search_selected;
|
||||||
|
let is_selected = state.selected_items.contains(&item.id);
|
||||||
|
|
||||||
|
let style = if is_cursor {
|
||||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
|
} else if is_selected {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Green)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Selection marker
|
||||||
|
let marker = if is_selected { "[*]" } else { "[ ]" };
|
||||||
|
let marker_style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
let type_color = media_type_color(&item.media_type);
|
let type_color = media_type_color(&item.media_type);
|
||||||
let type_cell = Cell::from(Span::styled(
|
let type_cell = Cell::from(Span::styled(
|
||||||
item.media_type.clone(),
|
item.media_type.clone(),
|
||||||
|
|
@ -52,6 +67,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
));
|
));
|
||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
|
Cell::from(Span::styled(marker, marker_style)),
|
||||||
Cell::from(item.file_name.clone()),
|
Cell::from(item.file_name.clone()),
|
||||||
type_cell,
|
type_cell,
|
||||||
Cell::from(item.artist.clone().unwrap_or_default()),
|
Cell::from(item.artist.clone().unwrap_or_default()),
|
||||||
|
|
@ -63,15 +79,21 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
|
|
||||||
let shown = state.search_results.len();
|
let shown = state.search_results.len();
|
||||||
let total = state.search_total_count;
|
let total = state.search_total_count;
|
||||||
let results_title = format!(" Results: {shown} shown, {total} total ");
|
let selected_count = state.selected_items.len();
|
||||||
|
let results_title = if selected_count > 0 {
|
||||||
|
format!(" Results: {shown} shown, {total} total, {selected_count} selected ")
|
||||||
|
} else {
|
||||||
|
format!(" Results: {shown} shown, {total} total ")
|
||||||
|
};
|
||||||
|
|
||||||
let table = Table::new(
|
let table = Table::new(
|
||||||
rows,
|
rows,
|
||||||
[
|
[
|
||||||
Constraint::Percentage(35),
|
Constraint::Length(3), // Selection marker
|
||||||
Constraint::Percentage(20),
|
Constraint::Percentage(33), // Name
|
||||||
Constraint::Percentage(25),
|
Constraint::Percentage(18), // Type
|
||||||
Constraint::Percentage(20),
|
Constraint::Percentage(23), // Artist
|
||||||
|
Constraint::Percentage(18), // Size
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.header(header)
|
.header(header)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ tracing-subscriber = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
dioxus = { workspace = true }
|
dioxus = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
rfd = "0.17"
|
rfd = "0.17"
|
||||||
pulldown-cmark = { workspace = true }
|
pulldown-cmark = { workspace = true }
|
||||||
gray_matter = { workspace = true }
|
gray_matter = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use futures::future::join_all;
|
||||||
|
|
||||||
use crate::client::*;
|
use crate::client::*;
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
|
|
@ -85,6 +87,9 @@ pub fn App() -> Element {
|
||||||
let mut last_search_query = use_signal(String::new);
|
let mut last_search_query = use_signal(String::new);
|
||||||
let mut last_search_sort = use_signal(|| Option::<String>::None);
|
let mut last_search_sort = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
// Phase 3.6: Saved searches
|
||||||
|
let mut saved_searches = use_signal(Vec::<SavedSearchResponse>::new);
|
||||||
|
|
||||||
// Phase 6.1: Audit pagination & filter
|
// Phase 6.1: Audit pagination & filter
|
||||||
let mut audit_page = use_signal(|| 0u64);
|
let mut audit_page = use_signal(|| 0u64);
|
||||||
let audit_page_size = use_signal(|| 200u64);
|
let audit_page_size = use_signal(|| 200u64);
|
||||||
|
|
@ -107,8 +112,44 @@ pub fn App() -> Element {
|
||||||
let mut login_loading = use_signal(|| false);
|
let mut login_loading = use_signal(|| false);
|
||||||
let mut auto_play_media = use_signal(|| false);
|
let mut auto_play_media = use_signal(|| false);
|
||||||
|
|
||||||
|
// Theme state (Phase 3.3)
|
||||||
|
let mut current_theme = use_signal(|| "dark".to_string());
|
||||||
|
let mut system_prefers_dark = use_signal(|| true);
|
||||||
|
|
||||||
|
// Detect system color scheme preference
|
||||||
|
use_effect(move || {
|
||||||
|
spawn(async move {
|
||||||
|
// Check system preference using JavaScript
|
||||||
|
let result =
|
||||||
|
document::eval(r#"window.matchMedia('(prefers-color-scheme: dark)').matches"#);
|
||||||
|
if let Ok(val) = result.await {
|
||||||
|
if let Some(prefers_dark) = val.as_bool() {
|
||||||
|
system_prefers_dark.set(prefers_dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute effective theme based on preference
|
||||||
|
let effective_theme = use_memo(move || {
|
||||||
|
let theme = current_theme.read().clone();
|
||||||
|
if theme == "system" {
|
||||||
|
if *system_prefers_dark.read() {
|
||||||
|
"dark".to_string()
|
||||||
|
} else {
|
||||||
|
"light".to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
theme
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Import state for UI feedback
|
// Import state for UI feedback
|
||||||
let mut import_in_progress = use_signal(|| false);
|
let mut import_in_progress = use_signal(|| false);
|
||||||
|
// Extended import state: current file name, queue of pending imports, progress (completed, total)
|
||||||
|
let mut import_current_file = use_signal(|| Option::<String>::None);
|
||||||
|
let mut import_queue = use_signal(Vec::<String>::new);
|
||||||
|
let mut import_progress = use_signal(|| (0usize, 0usize)); // (completed, total)
|
||||||
|
|
||||||
// Check auth on startup
|
// Check auth on startup
|
||||||
let client_auth = client.read().clone();
|
let client_auth = client.read().clone();
|
||||||
|
|
@ -136,6 +177,7 @@ pub fn App() -> Element {
|
||||||
if let Ok(cfg) = client.get_config().await {
|
if let Ok(cfg) = client.get_config().await {
|
||||||
auto_play_media.set(cfg.ui.auto_play_media);
|
auto_play_media.set(cfg.ui.auto_play_media);
|
||||||
sidebar_collapsed.set(cfg.ui.sidebar_collapsed);
|
sidebar_collapsed.set(cfg.ui.sidebar_collapsed);
|
||||||
|
current_theme.set(cfg.ui.theme.clone());
|
||||||
if cfg.ui.default_page_size > 0 {
|
if cfg.ui.default_page_size > 0 {
|
||||||
media_page_size.set(cfg.ui.default_page_size as u64);
|
media_page_size.set(cfg.ui.default_page_size as u64);
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +225,10 @@ pub fn App() -> Element {
|
||||||
if let Ok(c) = client.list_collections().await {
|
if let Ok(c) = client.list_collections().await {
|
||||||
collections_list.set(c);
|
collections_list.set(c);
|
||||||
}
|
}
|
||||||
|
// Phase 3.6: Load saved searches
|
||||||
|
if let Ok(ss) = client.list_saved_searches().await {
|
||||||
|
saved_searches.set(ss);
|
||||||
|
}
|
||||||
loading.set(false);
|
loading.set(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -310,14 +356,17 @@ pub fn App() -> Element {
|
||||||
} else {
|
} else {
|
||||||
// Phase 7.1: Keyboard shortcuts
|
// Phase 7.1: Keyboard shortcuts
|
||||||
div {
|
div {
|
||||||
class: "app",
|
class: if *effective_theme.read() == "light" { "app theme-light" } else { "app" },
|
||||||
tabindex: "0",
|
tabindex: "0",
|
||||||
onkeydown: {
|
onkeydown: {
|
||||||
move |evt: KeyboardEvent| {
|
move |evt: KeyboardEvent| {
|
||||||
let key = evt.key();
|
let key = evt.key();
|
||||||
let ctrl = evt.modifiers().contains(Modifiers::CONTROL);
|
let ctrl = evt.modifiers().contains(Modifiers::CONTROL);
|
||||||
let meta = evt.modifiers().contains(Modifiers::META);
|
let meta = evt.modifiers().contains(Modifiers::META);
|
||||||
|
let shift = evt.modifiers().contains(Modifiers::SHIFT);
|
||||||
|
|
||||||
match key {
|
match key {
|
||||||
|
// Escape - close modal/go back
|
||||||
Key::Escape => {
|
Key::Escape => {
|
||||||
if *show_help.read() {
|
if *show_help.read() {
|
||||||
show_help.set(false);
|
show_help.set(false);
|
||||||
|
|
@ -325,6 +374,7 @@ pub fn App() -> Element {
|
||||||
current_view.set(View::Library);
|
current_view.set(View::Library);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// / or Ctrl+K - focus search
|
||||||
Key::Character(ref c) if c == "/" && !ctrl && !meta => {
|
Key::Character(ref c) if c == "/" && !ctrl && !meta => {
|
||||||
evt.prevent_default();
|
evt.prevent_default();
|
||||||
current_view.set(View::Search);
|
current_view.set(View::Search);
|
||||||
|
|
@ -333,9 +383,43 @@ pub fn App() -> Element {
|
||||||
evt.prevent_default();
|
evt.prevent_default();
|
||||||
current_view.set(View::Search);
|
current_view.set(View::Search);
|
||||||
}
|
}
|
||||||
|
// ? - toggle help overlay
|
||||||
Key::Character(ref c) if c == "?" && !ctrl && !meta => {
|
Key::Character(ref c) if c == "?" && !ctrl && !meta => {
|
||||||
show_help.toggle();
|
show_help.toggle();
|
||||||
}
|
}
|
||||||
|
// Ctrl+, - open settings
|
||||||
|
Key::Character(ref c) if c == "," && (ctrl || meta) => {
|
||||||
|
evt.prevent_default();
|
||||||
|
current_view.set(View::Settings);
|
||||||
|
}
|
||||||
|
// Number keys 1-6 for quick view switching (without modifiers)
|
||||||
|
Key::Character(ref c) if c == "1" && !ctrl && !meta && !shift => {
|
||||||
|
evt.prevent_default();
|
||||||
|
current_view.set(View::Library);
|
||||||
|
}
|
||||||
|
Key::Character(ref c) if c == "2" && !ctrl && !meta && !shift => {
|
||||||
|
evt.prevent_default();
|
||||||
|
current_view.set(View::Search);
|
||||||
|
}
|
||||||
|
Key::Character(ref c) if c == "3" && !ctrl && !meta && !shift => {
|
||||||
|
evt.prevent_default();
|
||||||
|
current_view.set(View::Import);
|
||||||
|
}
|
||||||
|
Key::Character(ref c) if c == "4" && !ctrl && !meta && !shift => {
|
||||||
|
evt.prevent_default();
|
||||||
|
current_view.set(View::Tags);
|
||||||
|
}
|
||||||
|
Key::Character(ref c) if c == "5" && !ctrl && !meta && !shift => {
|
||||||
|
evt.prevent_default();
|
||||||
|
current_view.set(View::Collections);
|
||||||
|
}
|
||||||
|
Key::Character(ref c) if c == "6" && !ctrl && !meta && !shift => {
|
||||||
|
evt.prevent_default();
|
||||||
|
current_view.set(View::Audit);
|
||||||
|
}
|
||||||
|
// g then l - go to library (vim-style)
|
||||||
|
// Could implement g-prefix commands in the future
|
||||||
|
Key::Character(ref c) if c == "g" && !ctrl && !meta => {}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -492,6 +576,44 @@ pub fn App() -> Element {
|
||||||
|
|
||||||
div { class: "sidebar-spacer" }
|
div { class: "sidebar-spacer" }
|
||||||
|
|
||||||
|
// Show import progress in sidebar when not on import page
|
||||||
|
if *import_in_progress.read() && *current_view.read() != View::Import {
|
||||||
|
{
|
||||||
|
let (completed, total) = *import_progress.read();
|
||||||
|
let has_progress = total > 0;
|
||||||
|
let pct = if total > 0 { (completed * 100) / total } else { 0 };
|
||||||
|
let current = import_current_file.read().clone();
|
||||||
|
let queue_len = import_queue.read().len();
|
||||||
|
rsx! {
|
||||||
|
div { class: "sidebar-import-progress",
|
||||||
|
div { class: "sidebar-import-header",
|
||||||
|
div { class: "status-dot checking" }
|
||||||
|
span {
|
||||||
|
if has_progress {
|
||||||
|
"Importing {completed}/{total}"
|
||||||
|
} else {
|
||||||
|
"Importing..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if queue_len > 0 {
|
||||||
|
span { class: "import-queue-badge", "+{queue_len}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref file_name) = current {
|
||||||
|
div { class: "sidebar-import-file", "{file_name}" }
|
||||||
|
}
|
||||||
|
div { class: "progress-bar",
|
||||||
|
if has_progress {
|
||||||
|
div { class: "progress-fill", style: "width: {pct}%;" }
|
||||||
|
} else {
|
||||||
|
div { class: "progress-fill indeterminate" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sidebar collapse toggle
|
// Sidebar collapse toggle
|
||||||
button {
|
button {
|
||||||
class: "sidebar-toggle",
|
class: "sidebar-toggle",
|
||||||
|
|
@ -867,6 +989,62 @@ pub fn App() -> Element {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Phase 3.6: Saved searches
|
||||||
|
saved_searches: saved_searches.read().clone(),
|
||||||
|
on_save_search: {
|
||||||
|
let client = client.read().clone();
|
||||||
|
move |(name, query, sort): (String, String, Option<String>)| {
|
||||||
|
let client = client.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match client.create_saved_search(&name, &query, sort.as_deref()).await {
|
||||||
|
Ok(ss) => {
|
||||||
|
saved_searches.write().push(ss);
|
||||||
|
show_toast(format!("Search '{}' saved", name), false);
|
||||||
|
}
|
||||||
|
Err(e) => show_toast(format!("Failed to save search: {e}"), true),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
on_delete_saved_search: {
|
||||||
|
let client = client.read().clone();
|
||||||
|
move |id: String| {
|
||||||
|
let client = client.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match client.delete_saved_search(&id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
saved_searches.write().retain(|s| s.id != id);
|
||||||
|
show_toast("Search deleted".into(), false);
|
||||||
|
}
|
||||||
|
Err(e) => show_toast(format!("Failed to delete: {e}"), true),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
on_load_saved_search: {
|
||||||
|
let client = client.read().clone();
|
||||||
|
move |ss: SavedSearchResponse| {
|
||||||
|
let client = client.clone();
|
||||||
|
let query = ss.query.clone();
|
||||||
|
let sort = ss.sort_order.clone();
|
||||||
|
search_page.set(0);
|
||||||
|
last_search_query.set(query.clone());
|
||||||
|
last_search_sort.set(sort.clone());
|
||||||
|
spawn(async move {
|
||||||
|
loading.set(true);
|
||||||
|
let offset = 0;
|
||||||
|
let limit = *search_page_size.read();
|
||||||
|
match client.search(&query, sort.as_deref(), offset, limit).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
search_total.set(resp.total_count);
|
||||||
|
search_results.set(resp.items);
|
||||||
|
}
|
||||||
|
Err(e) => show_toast(format!("Search failed: {e}"), true),
|
||||||
|
}
|
||||||
|
loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
View::Detail => {
|
View::Detail => {
|
||||||
|
|
@ -1225,10 +1403,54 @@ pub fn App() -> Element {
|
||||||
let refresh_media = refresh_media.clone();
|
let refresh_media = refresh_media.clone();
|
||||||
let refresh_tags = refresh_tags.clone();
|
let refresh_tags = refresh_tags.clone();
|
||||||
move |(path, tag_ids, new_tags, col_id): ImportEvent| {
|
move |(path, tag_ids, new_tags, col_id): ImportEvent| {
|
||||||
|
// Extract file name from path
|
||||||
|
let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||||
|
|
||||||
|
// Check if already importing - if so, add to queue
|
||||||
|
|
||||||
|
|
||||||
|
// Extract directory name from path
|
||||||
|
|
||||||
|
// Check if already importing - if so, add to queue
|
||||||
|
if *import_in_progress.read() {
|
||||||
|
|
||||||
|
// Get preview files if available for per-file progress
|
||||||
|
|
||||||
|
// Use parallel import with per-batch progress
|
||||||
|
|
||||||
|
// Show first file in batch as current
|
||||||
|
|
||||||
|
// Process batch in parallel
|
||||||
|
|
||||||
|
// Update progress after batch
|
||||||
|
|
||||||
|
// Fallback: use server-side directory import (no per-file progress)
|
||||||
|
// Check if already importing - if so, add to queue
|
||||||
|
|
||||||
|
// Update progress from scan status
|
||||||
|
|
||||||
|
// Check if already importing - if so, add to queue
|
||||||
|
|
||||||
|
// Process files in parallel batches for better performance
|
||||||
|
|
||||||
|
// Show first file in batch as current
|
||||||
|
|
||||||
|
// Process batch in parallel
|
||||||
|
|
||||||
|
// Update progress after batch
|
||||||
|
|
||||||
|
// Extended import state
|
||||||
|
import_queue.write().push(file_name);
|
||||||
|
show_toast("Added to import queue".into(), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let refresh_media = refresh_media.clone();
|
let refresh_media = refresh_media.clone();
|
||||||
let refresh_tags = refresh_tags.clone();
|
let refresh_tags = refresh_tags.clone();
|
||||||
import_in_progress.set(true);
|
import_in_progress.set(true);
|
||||||
|
import_current_file.set(Some(file_name));
|
||||||
|
import_progress.set((0, 1));
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
if tag_ids.is_empty() && new_tags.is_empty() && col_id.is_none() {
|
if tag_ids.is_empty() && new_tags.is_empty() && col_id.is_none() {
|
||||||
match client.import_file(&path).await {
|
match client.import_file(&path).await {
|
||||||
|
|
@ -1275,6 +1497,8 @@ pub fn App() -> Element {
|
||||||
Err(e) => show_toast(format!("Import failed: {e}"), true),
|
Err(e) => show_toast(format!("Import failed: {e}"), true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
import_progress.set((1, 1));
|
||||||
|
import_current_file.set(None);
|
||||||
import_in_progress.set(false);
|
import_in_progress.set(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1284,45 +1508,169 @@ pub fn App() -> Element {
|
||||||
let refresh_media = refresh_media.clone();
|
let refresh_media = refresh_media.clone();
|
||||||
let refresh_tags = refresh_tags.clone();
|
let refresh_tags = refresh_tags.clone();
|
||||||
move |(path, tag_ids, new_tags, col_id): ImportEvent| {
|
move |(path, tag_ids, new_tags, col_id): ImportEvent| {
|
||||||
|
let dir_name = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||||
|
|
||||||
|
if *import_in_progress.read() {
|
||||||
|
import_queue.write().push(format!("{dir_name}/ (directory)"));
|
||||||
|
show_toast("Added directory to import queue".into(), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let files_to_import: Vec<String> = preview_files
|
||||||
|
.read()
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.path.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let refresh_media = refresh_media.clone();
|
let refresh_media = refresh_media.clone();
|
||||||
let refresh_tags = refresh_tags.clone();
|
let refresh_tags = refresh_tags.clone();
|
||||||
import_in_progress.set(true);
|
import_in_progress.set(true);
|
||||||
spawn(async move {
|
|
||||||
match client
|
if !files_to_import.is_empty() {
|
||||||
.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref())
|
let file_count = files_to_import.len();
|
||||||
.await
|
import_progress.set((0, file_count));
|
||||||
{
|
|
||||||
Ok(resp) => {
|
let client = Arc::new(client);
|
||||||
show_toast(
|
let tag_ids = Arc::new(tag_ids);
|
||||||
format!(
|
let new_tags = Arc::new(new_tags);
|
||||||
"Done: {} imported, {} duplicates, {} errors",
|
let col_id = Arc::new(col_id);
|
||||||
resp.imported,
|
|
||||||
resp.duplicates,
|
const BATCH_SIZE: usize = 6;
|
||||||
resp.errors,
|
spawn(async move {
|
||||||
),
|
let imported = Arc::new(AtomicUsize::new(0));
|
||||||
resp.errors > 0,
|
let duplicates = Arc::new(AtomicUsize::new(0));
|
||||||
);
|
let errors = Arc::new(AtomicUsize::new(0));
|
||||||
refresh_media();
|
let completed = Arc::new(AtomicUsize::new(0));
|
||||||
if !new_tags.is_empty() {
|
|
||||||
refresh_tags();
|
for chunk in files_to_import.chunks(BATCH_SIZE) {
|
||||||
|
if let Some(first_path) = chunk.first() {
|
||||||
|
let file_name = first_path
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(first_path);
|
||||||
|
import_current_file.set(Some(file_name.to_string()));
|
||||||
}
|
}
|
||||||
preview_files.set(Vec::new());
|
let futures: Vec<_> = chunk
|
||||||
preview_total_size.set(0);
|
.iter()
|
||||||
|
.map(|file_path| {
|
||||||
|
let client = Arc::clone(&client);
|
||||||
|
let tag_ids = Arc::clone(&tag_ids);
|
||||||
|
let new_tags = Arc::clone(&new_tags);
|
||||||
|
let col_id = Arc::clone(&col_id);
|
||||||
|
let imported = Arc::clone(&imported);
|
||||||
|
let duplicates = Arc::clone(&duplicates);
|
||||||
|
let errors = Arc::clone(&errors);
|
||||||
|
let completed = Arc::clone(&completed);
|
||||||
|
let file_path = file_path.clone();
|
||||||
|
async move {
|
||||||
|
let result = if tag_ids.is_empty() && new_tags.is_empty()
|
||||||
|
&& col_id.is_none()
|
||||||
|
{
|
||||||
|
client.import_file(&file_path).await
|
||||||
|
} else {
|
||||||
|
client
|
||||||
|
.import_with_options(
|
||||||
|
&file_path,
|
||||||
|
&tag_ids,
|
||||||
|
&new_tags,
|
||||||
|
col_id.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
if resp.was_duplicate {
|
||||||
|
duplicates.fetch_add(1, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
imported.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
join_all(futures).await;
|
||||||
|
let done = completed.load(Ordering::Relaxed);
|
||||||
|
import_progress.set((done, file_count));
|
||||||
}
|
}
|
||||||
Err(e) => show_toast(format!("Directory import failed: {e}"), true),
|
let imported = imported.load(Ordering::Relaxed);
|
||||||
}
|
let duplicates = duplicates.load(Ordering::Relaxed);
|
||||||
import_in_progress.set(false);
|
let errors = errors.load(Ordering::Relaxed);
|
||||||
});
|
show_toast(
|
||||||
|
format!(
|
||||||
|
"Done: {imported} imported, {duplicates} duplicates, {errors} errors",
|
||||||
|
),
|
||||||
|
errors > 0,
|
||||||
|
);
|
||||||
|
refresh_media();
|
||||||
|
if !new_tags.is_empty() {
|
||||||
|
refresh_tags();
|
||||||
|
}
|
||||||
|
preview_files.set(Vec::new());
|
||||||
|
preview_total_size.set(0);
|
||||||
|
import_progress.set((file_count, file_count));
|
||||||
|
import_current_file.set(None);
|
||||||
|
import_in_progress.set(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
import_current_file.set(Some(format!("{dir_name}/")));
|
||||||
|
import_progress.set((0, 0));
|
||||||
|
spawn(async move {
|
||||||
|
match client
|
||||||
|
.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
show_toast(
|
||||||
|
format!(
|
||||||
|
"Done: {} imported, {} duplicates, {} errors",
|
||||||
|
resp.imported,
|
||||||
|
resp.duplicates,
|
||||||
|
resp.errors,
|
||||||
|
),
|
||||||
|
resp.errors > 0,
|
||||||
|
);
|
||||||
|
refresh_media();
|
||||||
|
if !new_tags.is_empty() {
|
||||||
|
refresh_tags();
|
||||||
|
}
|
||||||
|
preview_files.set(Vec::new());
|
||||||
|
preview_total_size.set(0);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
show_toast(format!("Directory import failed: {e}"), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
import_current_file.set(None);
|
||||||
|
import_progress.set((0, 0));
|
||||||
|
import_in_progress.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
on_scan: {
|
on_scan: {
|
||||||
let client = client.read().clone();
|
let client = client.read().clone();
|
||||||
let refresh_media = refresh_media.clone();
|
let refresh_media = refresh_media.clone();
|
||||||
move |_| {
|
move |_| {
|
||||||
|
if *import_in_progress.read() {
|
||||||
|
import_queue.write().push("Scan roots".to_string());
|
||||||
|
show_toast("Added scan to import queue".into(), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let refresh_media = refresh_media.clone();
|
let refresh_media = refresh_media.clone();
|
||||||
import_in_progress.set(true);
|
import_in_progress.set(true);
|
||||||
|
import_current_file.set(Some("Scanning roots...".to_string()));
|
||||||
|
import_progress.set((0, 0)); // Will be updated from scan_progress
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match client.trigger_scan().await {
|
match client.trigger_scan().await {
|
||||||
Ok(_results) => {
|
Ok(_results) => {
|
||||||
|
|
@ -1330,6 +1678,23 @@ pub fn App() -> Element {
|
||||||
match client.scan_status().await {
|
match client.scan_status().await {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
let done = !status.scanning;
|
let done = !status.scanning;
|
||||||
|
import_progress
|
||||||
|
.set((
|
||||||
|
status.files_processed as usize,
|
||||||
|
status.files_found as usize,
|
||||||
|
));
|
||||||
|
if status.files_found > 0 {
|
||||||
|
import_current_file
|
||||||
|
.set(
|
||||||
|
Some(
|
||||||
|
format!(
|
||||||
|
"Scanning ({}/{})",
|
||||||
|
status.files_processed,
|
||||||
|
status.files_found,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
scan_progress.set(Some(status.clone()));
|
scan_progress.set(Some(status.clone()));
|
||||||
if done {
|
if done {
|
||||||
let total = status.files_processed;
|
let total = status.files_processed;
|
||||||
|
|
@ -1348,6 +1713,8 @@ pub fn App() -> Element {
|
||||||
}
|
}
|
||||||
Err(e) => show_toast(format!("Scan failed: {e}"), true),
|
Err(e) => show_toast(format!("Scan failed: {e}"), true),
|
||||||
}
|
}
|
||||||
|
import_current_file.set(None);
|
||||||
|
import_progress.set((0, 0));
|
||||||
import_in_progress.set(false);
|
import_in_progress.set(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1357,40 +1724,105 @@ pub fn App() -> Element {
|
||||||
let refresh_media = refresh_media.clone();
|
let refresh_media = refresh_media.clone();
|
||||||
let refresh_tags = refresh_tags.clone();
|
let refresh_tags = refresh_tags.clone();
|
||||||
move |(paths, tag_ids, new_tags, col_id): import::BatchImportEvent| {
|
move |(paths, tag_ids, new_tags, col_id): import::BatchImportEvent| {
|
||||||
let client = client.clone();
|
let file_count = paths.len();
|
||||||
|
|
||||||
|
if *import_in_progress.read() {
|
||||||
|
import_queue.write().push(format!("{file_count} files (batch)"));
|
||||||
|
show_toast("Added batch to import queue".into(), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = Arc::new(client.clone());
|
||||||
let refresh_media = refresh_media.clone();
|
let refresh_media = refresh_media.clone();
|
||||||
let refresh_tags = refresh_tags.clone();
|
let refresh_tags = refresh_tags.clone();
|
||||||
let file_count = paths.len();
|
let tag_ids = Arc::new(tag_ids);
|
||||||
|
let new_tags = Arc::new(new_tags);
|
||||||
|
let col_id = Arc::new(col_id);
|
||||||
import_in_progress.set(true);
|
import_in_progress.set(true);
|
||||||
|
import_progress.set((0, file_count));
|
||||||
|
|
||||||
|
const BATCH_SIZE: usize = 6;
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match client
|
let imported = Arc::new(AtomicUsize::new(0));
|
||||||
.batch_import(&paths, &tag_ids, &new_tags, col_id.as_deref())
|
let duplicates = Arc::new(AtomicUsize::new(0));
|
||||||
.await
|
let errors = Arc::new(AtomicUsize::new(0));
|
||||||
{
|
let completed = Arc::new(AtomicUsize::new(0));
|
||||||
Ok(resp) => {
|
|
||||||
show_toast(
|
for chunk in paths.chunks(BATCH_SIZE) {
|
||||||
format!(
|
if let Some(first_path) = chunk.first() {
|
||||||
"Done: {} imported, {} duplicates, {} errors",
|
let file_name = first_path
|
||||||
resp.imported,
|
|
||||||
resp.duplicates,
|
|
||||||
resp.errors,
|
|
||||||
),
|
.rsplit('/')
|
||||||
resp.errors > 0,
|
.next()
|
||||||
);
|
.unwrap_or(first_path);
|
||||||
refresh_media();
|
import_current_file.set(Some(file_name.to_string()));
|
||||||
if !new_tags.is_empty() {
|
|
||||||
refresh_tags();
|
|
||||||
}
|
|
||||||
preview_files.set(Vec::new());
|
|
||||||
preview_total_size.set(0);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
show_toast(
|
|
||||||
format!("Batch import failed ({file_count} files): {e}"),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
let futures: Vec<_> = chunk
|
||||||
|
.iter()
|
||||||
|
.map(|path| {
|
||||||
|
let client = Arc::clone(&client);
|
||||||
|
let tag_ids = Arc::clone(&tag_ids);
|
||||||
|
let new_tags = Arc::clone(&new_tags);
|
||||||
|
let col_id = Arc::clone(&col_id);
|
||||||
|
let imported = Arc::clone(&imported);
|
||||||
|
let duplicates = Arc::clone(&duplicates);
|
||||||
|
let errors = Arc::clone(&errors);
|
||||||
|
let completed = Arc::clone(&completed);
|
||||||
|
let path = path.clone();
|
||||||
|
async move {
|
||||||
|
let result = if tag_ids.is_empty() && new_tags.is_empty()
|
||||||
|
&& col_id.is_none()
|
||||||
|
{
|
||||||
|
client.import_file(&path).await
|
||||||
|
} else {
|
||||||
|
client
|
||||||
|
.import_with_options(
|
||||||
|
&path,
|
||||||
|
&tag_ids,
|
||||||
|
&new_tags,
|
||||||
|
col_id.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
if resp.was_duplicate {
|
||||||
|
duplicates.fetch_add(1, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
imported.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
join_all(futures).await;
|
||||||
|
let done = completed.load(Ordering::Relaxed);
|
||||||
|
import_progress.set((done, file_count));
|
||||||
}
|
}
|
||||||
|
let imported = imported.load(Ordering::Relaxed);
|
||||||
|
let duplicates = duplicates.load(Ordering::Relaxed);
|
||||||
|
let errors = errors.load(Ordering::Relaxed);
|
||||||
|
show_toast(
|
||||||
|
format!(
|
||||||
|
"Done: {imported} imported, {duplicates} duplicates, {errors} errors",
|
||||||
|
),
|
||||||
|
errors > 0,
|
||||||
|
);
|
||||||
|
refresh_media();
|
||||||
|
if !new_tags.is_empty() {
|
||||||
|
refresh_tags();
|
||||||
|
}
|
||||||
|
preview_files.set(Vec::new());
|
||||||
|
preview_total_size.set(0);
|
||||||
|
import_progress.set((file_count, file_count));
|
||||||
|
import_current_file.set(None);
|
||||||
import_in_progress.set(false);
|
import_in_progress.set(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1416,6 +1848,9 @@ pub fn App() -> Element {
|
||||||
},
|
},
|
||||||
preview_files: preview_files.read().clone(),
|
preview_files: preview_files.read().clone(),
|
||||||
preview_total_size: *preview_total_size.read(),
|
preview_total_size: *preview_total_size.read(),
|
||||||
|
current_file: import_current_file.read().clone(),
|
||||||
|
import_queue: import_queue.read().clone(),
|
||||||
|
import_progress: *import_progress.read(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
View::Database => {
|
View::Database => {
|
||||||
|
|
@ -1620,6 +2055,7 @@ pub fn App() -> Element {
|
||||||
Ok(ui_cfg) => {
|
Ok(ui_cfg) => {
|
||||||
auto_play_media.set(ui_cfg.auto_play_media);
|
auto_play_media.set(ui_cfg.auto_play_media);
|
||||||
sidebar_collapsed.set(ui_cfg.sidebar_collapsed);
|
sidebar_collapsed.set(ui_cfg.sidebar_collapsed);
|
||||||
|
current_theme.set(ui_cfg.theme.clone());
|
||||||
if let Ok(cfg) = client.get_config().await {
|
if let Ok(cfg) = client.get_config().await {
|
||||||
config_data.set(Some(cfg));
|
config_data.set(Some(cfg));
|
||||||
}
|
}
|
||||||
|
|
@ -1654,6 +2090,7 @@ pub fn App() -> Element {
|
||||||
onclick: move |evt: MouseEvent| evt.stop_propagation(),
|
onclick: move |evt: MouseEvent| evt.stop_propagation(),
|
||||||
h3 { "Keyboard Shortcuts" }
|
h3 { "Keyboard Shortcuts" }
|
||||||
div { class: "help-shortcuts",
|
div { class: "help-shortcuts",
|
||||||
|
h4 { "Navigation" }
|
||||||
div { class: "shortcut-row",
|
div { class: "shortcut-row",
|
||||||
kbd { "Esc" }
|
kbd { "Esc" }
|
||||||
span { "Go back / close overlay" }
|
span { "Go back / close overlay" }
|
||||||
|
|
@ -1664,12 +2101,42 @@ pub fn App() -> Element {
|
||||||
}
|
}
|
||||||
div { class: "shortcut-row",
|
div { class: "shortcut-row",
|
||||||
kbd { "Ctrl+K" }
|
kbd { "Ctrl+K" }
|
||||||
span { "Focus search" }
|
span { "Focus search (alternative)" }
|
||||||
|
}
|
||||||
|
div { class: "shortcut-row",
|
||||||
|
kbd { "Ctrl+," }
|
||||||
|
span { "Open settings" }
|
||||||
}
|
}
|
||||||
div { class: "shortcut-row",
|
div { class: "shortcut-row",
|
||||||
kbd { "?" }
|
kbd { "?" }
|
||||||
span { "Toggle this help" }
|
span { "Toggle this help" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h4 { "Quick Views" }
|
||||||
|
div { class: "shortcut-row",
|
||||||
|
kbd { "1" }
|
||||||
|
span { "Library" }
|
||||||
|
}
|
||||||
|
div { class: "shortcut-row",
|
||||||
|
kbd { "2" }
|
||||||
|
span { "Search" }
|
||||||
|
}
|
||||||
|
div { class: "shortcut-row",
|
||||||
|
kbd { "3" }
|
||||||
|
span { "Import" }
|
||||||
|
}
|
||||||
|
div { class: "shortcut-row",
|
||||||
|
kbd { "4" }
|
||||||
|
span { "Tags" }
|
||||||
|
}
|
||||||
|
div { class: "shortcut-row",
|
||||||
|
kbd { "5" }
|
||||||
|
span { "Collections" }
|
||||||
|
}
|
||||||
|
div { class: "shortcut-row",
|
||||||
|
kbd { "6" }
|
||||||
|
span { "Audit Log" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
class: "help-close",
|
class: "help-close",
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,22 @@ pub struct DatabaseStatsResponse {
|
||||||
pub backend_name: String,
|
pub backend_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct SavedSearchResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub query: String,
|
||||||
|
pub sort_order: Option<String>,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CreateSavedSearchRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub query: String,
|
||||||
|
pub sort_order: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
impl ApiClient {
|
impl ApiClient {
|
||||||
pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
|
pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
|
||||||
|
|
@ -1053,6 +1069,50 @@ impl ApiClient {
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Saved Searches ──
|
||||||
|
|
||||||
|
pub async fn list_saved_searches(&self) -> Result<Vec<SavedSearchResponse>> {
|
||||||
|
Ok(self
|
||||||
|
.client
|
||||||
|
.get(self.url("/saved-searches"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_saved_search(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
query: &str,
|
||||||
|
sort_order: Option<&str>,
|
||||||
|
) -> Result<SavedSearchResponse> {
|
||||||
|
let req = CreateSavedSearchRequest {
|
||||||
|
name: name.to_string(),
|
||||||
|
query: query.to_string(),
|
||||||
|
sort_order: sort_order.map(|s| s.to_string()),
|
||||||
|
};
|
||||||
|
Ok(self
|
||||||
|
.client
|
||||||
|
.post(self.url("/saved-searches"))
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_saved_search(&self, id: &str) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.delete(self.url(&format!("/saved-searches/{id}")))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_token(&mut self, token: &str) {
|
pub fn set_token(&mut self, token: &str) {
|
||||||
let mut headers = header::HeaderMap::new();
|
let mut headers = header::HeaderMap::new();
|
||||||
if let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {token}")) {
|
if let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {token}")) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use dioxus::prelude::*;
|
||||||
use super::image_viewer::ImageViewer;
|
use super::image_viewer::ImageViewer;
|
||||||
use super::markdown_viewer::MarkdownViewer;
|
use super::markdown_viewer::MarkdownViewer;
|
||||||
use super::media_player::MediaPlayer;
|
use super::media_player::MediaPlayer;
|
||||||
|
use super::pdf_viewer::PdfViewer;
|
||||||
use super::utils::{format_duration, format_size, media_category, type_badge_class};
|
use super::utils::{format_duration, format_size, media_category, type_badge_class};
|
||||||
use crate::client::{MediaResponse, MediaUpdateEvent, TagResponse};
|
use crate::client::{MediaResponse, MediaUpdateEvent, TagResponse};
|
||||||
|
|
||||||
|
|
@ -262,15 +263,20 @@ pub fn Detail(
|
||||||
media_type: media.media_type.clone(),
|
media_type: media.media_type.clone(),
|
||||||
}
|
}
|
||||||
} else if category == "document" {
|
} else if category == "document" {
|
||||||
div { class: "detail-no-preview",
|
if media.media_type == "pdf" {
|
||||||
p { class: "text-muted", "Preview not available for this document type." }
|
PdfViewer { src: stream_url.clone() }
|
||||||
button {
|
} else {
|
||||||
class: "btn btn-primary",
|
// EPUB and other document types
|
||||||
onclick: {
|
div { class: "detail-no-preview",
|
||||||
let id_open = id.clone();
|
p { class: "text-muted", "Preview not available for this document type." }
|
||||||
move |_| on_open.call(id_open.clone())
|
button {
|
||||||
},
|
class: "btn btn-primary",
|
||||||
"Open Externally"
|
onclick: {
|
||||||
|
let id_open = id.clone();
|
||||||
|
move |_| on_open.call(id_open.clone())
|
||||||
|
},
|
||||||
|
"Open Externally"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if has_thumbnail {
|
} else if has_thumbnail {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ pub fn Duplicates(
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "duplicate-group", key: "{hash}",
|
div { class: "duplicate-group", key: "{hash}",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
class: "duplicate-group-header",
|
class: "duplicate-group-header",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
|
|
@ -109,8 +111,6 @@ pub fn Duplicates(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
div { class: "dup-thumb",
|
div { class: "dup-thumb",
|
||||||
if has_thumb {
|
if has_thumb {
|
||||||
img {
|
img {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ pub fn Import(
|
||||||
preview_total_size: u64,
|
preview_total_size: u64,
|
||||||
scan_progress: Option<ScanStatusResponse>,
|
scan_progress: Option<ScanStatusResponse>,
|
||||||
#[props(default = false)] is_importing: bool,
|
#[props(default = false)] is_importing: bool,
|
||||||
|
// Extended import state
|
||||||
|
#[props(default)] current_file: Option<String>,
|
||||||
|
#[props(default)] import_queue: Vec<String>,
|
||||||
|
#[props(default = (0, 0))] import_progress: (usize, usize),
|
||||||
) -> Element {
|
) -> Element {
|
||||||
let mut import_mode = use_signal(|| 0usize);
|
let mut import_mode = use_signal(|| 0usize);
|
||||||
let mut file_path = use_signal(String::new);
|
let mut file_path = use_signal(String::new);
|
||||||
|
|
@ -47,13 +51,45 @@ pub fn Import(
|
||||||
rsx! {
|
rsx! {
|
||||||
// Import status panel (shown when import is in progress)
|
// Import status panel (shown when import is in progress)
|
||||||
if is_importing {
|
if is_importing {
|
||||||
div { class: "import-status-panel",
|
{
|
||||||
div { class: "import-status-header",
|
let (completed, total) = import_progress;
|
||||||
div { class: "status-dot checking" }
|
let has_progress = total > 0;
|
||||||
span { "Import in progress..." }
|
let pct = if total > 0 { (completed * 100) / total } else { 0 };
|
||||||
}
|
let queue_count = import_queue.len();
|
||||||
div { class: "progress-bar",
|
rsx! {
|
||||||
div { class: "progress-fill indeterminate" }
|
div { class: "import-status-panel",
|
||||||
|
div { class: "import-status-header",
|
||||||
|
div { class: "status-dot checking" }
|
||||||
|
span {
|
||||||
|
if has_progress {
|
||||||
|
"Importing {completed}/{total}..."
|
||||||
|
} else {
|
||||||
|
"Import in progress..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Show current file being imported
|
||||||
|
if let Some(ref file_name) = current_file {
|
||||||
|
div { class: "import-current-file",
|
||||||
|
span { class: "import-file-label", "Current: " }
|
||||||
|
span { class: "import-file-name", "{file_name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Show queue indicator
|
||||||
|
if queue_count > 0 {
|
||||||
|
div { class: "import-queue-indicator",
|
||||||
|
span { class: "import-queue-badge", "{queue_count}" }
|
||||||
|
span { class: "import-queue-text", " item(s) queued" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "progress-bar",
|
||||||
|
if has_progress {
|
||||||
|
div { class: "progress-fill", style: "width: {pct}%;" }
|
||||||
|
} else {
|
||||||
|
div { class: "progress-fill indeterminate" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -229,13 +265,13 @@ pub fn Import(
|
||||||
|
|
||||||
// Recursive toggle
|
// Recursive toggle
|
||||||
div { class: "form-group",
|
div { class: "form-group",
|
||||||
label { class: "form-row",
|
label { class: "checkbox-label",
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
checked: *recursive.read(),
|
checked: *recursive.read(),
|
||||||
onchange: move |_| recursive.toggle(),
|
onchange: move |_| recursive.toggle(),
|
||||||
}
|
}
|
||||||
span { style: "margin-left: 6px;", "Recursive (include subdirectories)" }
|
span { "Recursive (include subdirectories)" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -299,9 +335,12 @@ pub fn Import(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
div { class: "filter-bar",
|
div { class: "filter-bar",
|
||||||
div { class: "flex-row mb-8",
|
div { class: "filter-row",
|
||||||
label {
|
span { class: "filter-label", "Types" }
|
||||||
|
label { class: if types_snapshot[0] { "filter-chip active" } else { "filter-chip" },
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
checked: types_snapshot[0],
|
checked: types_snapshot[0],
|
||||||
|
|
@ -311,9 +350,9 @@ pub fn Import(
|
||||||
filter_types.set(types);
|
filter_types.set(types);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
" Audio"
|
"Audio"
|
||||||
}
|
}
|
||||||
label {
|
label { class: if types_snapshot[1] { "filter-chip active" } else { "filter-chip" },
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
checked: types_snapshot[1],
|
checked: types_snapshot[1],
|
||||||
|
|
@ -323,9 +362,9 @@ pub fn Import(
|
||||||
filter_types.set(types);
|
filter_types.set(types);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
" Video"
|
"Video"
|
||||||
}
|
}
|
||||||
label {
|
label { class: if types_snapshot[2] { "filter-chip active" } else { "filter-chip" },
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
checked: types_snapshot[2],
|
checked: types_snapshot[2],
|
||||||
|
|
@ -335,9 +374,9 @@ pub fn Import(
|
||||||
filter_types.set(types);
|
filter_types.set(types);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
" Image"
|
"Image"
|
||||||
}
|
}
|
||||||
label {
|
label { class: if types_snapshot[3] { "filter-chip active" } else { "filter-chip" },
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
checked: types_snapshot[3],
|
checked: types_snapshot[3],
|
||||||
|
|
@ -347,9 +386,9 @@ pub fn Import(
|
||||||
filter_types.set(types);
|
filter_types.set(types);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
" Document"
|
"Document"
|
||||||
}
|
}
|
||||||
label {
|
label { class: if types_snapshot[4] { "filter-chip active" } else { "filter-chip" },
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
checked: types_snapshot[4],
|
checked: types_snapshot[4],
|
||||||
|
|
@ -359,9 +398,9 @@ pub fn Import(
|
||||||
filter_types.set(types);
|
filter_types.set(types);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
" Text"
|
"Text"
|
||||||
}
|
}
|
||||||
label {
|
label { class: if types_snapshot[5] { "filter-chip active" } else { "filter-chip" },
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
checked: types_snapshot[5],
|
checked: types_snapshot[5],
|
||||||
|
|
@ -371,33 +410,41 @@ pub fn Import(
|
||||||
filter_types.set(types);
|
filter_types.set(types);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
" Other"
|
"Other"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div { class: "flex-row",
|
div { class: "size-filters",
|
||||||
label { class: "form-label", "Min size (MB): " }
|
div { class: "size-filter-group",
|
||||||
input {
|
label { "Min size" }
|
||||||
r#type: "number",
|
input {
|
||||||
value: "{min / (1024 * 1024)}",
|
r#type: "number",
|
||||||
oninput: move |e| {
|
placeholder: "MB",
|
||||||
if let Ok(mb) = e.value().parse::<u64>() {
|
value: if min > 0 { format!("{}", min / (1024 * 1024)) } else { String::new() },
|
||||||
filter_min_size.set(mb * 1024 * 1024);
|
oninput: move |e| {
|
||||||
} else {
|
if let Ok(mb) = e.value().parse::<u64>() {
|
||||||
filter_min_size.set(0);
|
filter_min_size.set(mb * 1024 * 1024);
|
||||||
}
|
} else {
|
||||||
},
|
filter_min_size.set(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
span { class: "text-muted text-sm", "MB" }
|
||||||
}
|
}
|
||||||
label { class: "form-label", "Max size (MB): " }
|
div { class: "size-filter-group",
|
||||||
input {
|
label { "Max size" }
|
||||||
r#type: "number",
|
input {
|
||||||
value: "{max / (1024 * 1024)}",
|
r#type: "number",
|
||||||
oninput: move |e| {
|
placeholder: "MB",
|
||||||
if let Ok(mb) = e.value().parse::<u64>() {
|
value: if max > 0 { format!("{}", max / (1024 * 1024)) } else { String::new() },
|
||||||
filter_max_size.set(mb * 1024 * 1024);
|
oninput: move |e| {
|
||||||
} else {
|
if let Ok(mb) = e.value().parse::<u64>() {
|
||||||
filter_max_size.set(0);
|
filter_max_size.set(mb * 1024 * 1024);
|
||||||
}
|
} else {
|
||||||
},
|
filter_max_size.set(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
span { class: "text-muted text-sm", "MB" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -565,34 +612,46 @@ pub fn Import(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import entire directory
|
// Import entire directory
|
||||||
button {
|
{
|
||||||
class: "btn btn-secondary",
|
let has_dir = !dir_path.read().is_empty();
|
||||||
disabled: is_importing,
|
let has_preview = !preview_files.is_empty();
|
||||||
onclick: {
|
let file_count = preview_files.len();
|
||||||
let mut dir_path = dir_path;
|
rsx! {
|
||||||
let mut selected_tags = selected_tags;
|
button {
|
||||||
let mut new_tags_input = new_tags_input;
|
class: if has_dir { "btn btn-secondary" } else { "btn btn-secondary btn-disabled-hint" },
|
||||||
let mut selected_collection = selected_collection;
|
disabled: is_importing || !has_dir,
|
||||||
let mut selected_file_paths = selected_file_paths;
|
title: if !has_dir { "Select a directory first" } else { "" },
|
||||||
move |_| {
|
onclick: {
|
||||||
let path = dir_path.read().clone();
|
let mut dir_path = dir_path;
|
||||||
if !path.is_empty() {
|
let mut selected_tags = selected_tags;
|
||||||
let tag_ids = selected_tags.read().clone();
|
let mut new_tags_input = new_tags_input;
|
||||||
let new_tags = parse_new_tags(&new_tags_input.read());
|
let mut selected_collection = selected_collection;
|
||||||
let col_id = selected_collection.read().clone();
|
let mut selected_file_paths = selected_file_paths;
|
||||||
on_import_directory.call((path, tag_ids, new_tags, col_id));
|
move |_| {
|
||||||
dir_path.set(String::new());
|
let path = dir_path.read().clone();
|
||||||
selected_tags.set(Vec::new());
|
if !path.is_empty() {
|
||||||
new_tags_input.set(String::new());
|
let tag_ids = selected_tags.read().clone();
|
||||||
selected_collection.set(None);
|
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||||
selected_file_paths.set(HashSet::new());
|
let col_id = selected_collection.read().clone();
|
||||||
|
on_import_directory.call((path, tag_ids, new_tags, col_id));
|
||||||
|
dir_path.set(String::new());
|
||||||
|
selected_tags.set(Vec::new());
|
||||||
|
new_tags_input.set(String::new());
|
||||||
|
selected_collection.set(None);
|
||||||
|
selected_file_paths.set(HashSet::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
if is_importing {
|
||||||
|
"Importing..."
|
||||||
|
} else if has_preview {
|
||||||
|
"Import All ({file_count} files)"
|
||||||
|
} else if has_dir {
|
||||||
|
"Import Entire Directory"
|
||||||
|
} else {
|
||||||
|
"Select Directory First"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
if is_importing {
|
|
||||||
"Importing..."
|
|
||||||
} else {
|
|
||||||
"Import Entire Directory"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -595,20 +595,23 @@ pub fn Library(
|
||||||
let badge_class = type_badge_class(&item.media_type);
|
let badge_class = type_badge_class(&item.media_type);
|
||||||
let is_checked = current_selection.contains(&id);
|
let is_checked = current_selection.contains(&id);
|
||||||
|
|
||||||
|
// Build a list of all visible IDs for shift+click range selection.
|
||||||
|
|
||||||
|
// Shift+click: select range from last_click_index to current idx.
|
||||||
|
// No previous click, just toggle this one.
|
||||||
|
|
||||||
|
// Thumbnail with CSS fallback: both the icon and img
|
||||||
|
// are rendered. The img is absolutely positioned on
|
||||||
|
// top. If the image fails to load, the icon beneath
|
||||||
|
// shows through.
|
||||||
|
|
||||||
|
// Thumbnail with CSS fallback: icon always
|
||||||
|
// rendered, img overlays when available.
|
||||||
|
|
||||||
|
|
||||||
// Build a list of all visible IDs for shift+click range selection.
|
|
||||||
|
|
||||||
// Shift+click: select range from last_click_index to current idx.
|
|
||||||
// No previous click, just toggle this one.
|
|
||||||
|
|
||||||
// Thumbnail with CSS fallback: both the icon and img
|
|
||||||
// are rendered. The img is absolutely positioned on
|
|
||||||
// top. If the image fails to load, the icon beneath
|
|
||||||
// shows through.
|
|
||||||
|
|
||||||
// Thumbnail with CSS fallback: icon always
|
|
||||||
// rendered, img overlays when available.
|
|
||||||
let card_click = {
|
let card_click = {
|
||||||
let id = item.id.clone();
|
let id = item.id.clone();
|
||||||
move |_| on_select.call(id.clone())
|
move |_| on_select.call(id.clone())
|
||||||
|
|
@ -616,8 +619,6 @@ pub fn Library(
|
||||||
|
|
||||||
let visible_ids: Vec<String> = filtered_media
|
let visible_ids: Vec<String> = filtered_media
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| m.id.clone())
|
.map(|m| m.id.clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -665,6 +666,8 @@ pub fn Library(
|
||||||
rsx! {
|
rsx! {
|
||||||
div { key: "{item.id}", class: "{card_class}", onclick: card_click,
|
div { key: "{item.id}", class: "{card_class}", onclick: card_click,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
div { class: "card-checkbox",
|
div { class: "card-checkbox",
|
||||||
input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
|
input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ pub mod login;
|
||||||
pub mod markdown_viewer;
|
pub mod markdown_viewer;
|
||||||
pub mod media_player;
|
pub mod media_player;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
|
pub mod pdf_viewer;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
|
|
|
||||||
112
crates/pinakes-ui/src/components/pdf_viewer.rs
Normal file
112
crates/pinakes-ui/src/components/pdf_viewer.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn PdfViewer(
|
||||||
|
src: String,
|
||||||
|
#[props(default = 1)] initial_page: usize,
|
||||||
|
#[props(default = 100)] initial_zoom: usize,
|
||||||
|
) -> Element {
|
||||||
|
let current_page = use_signal(|| initial_page);
|
||||||
|
let mut zoom_level = use_signal(|| initial_zoom);
|
||||||
|
let mut loading = use_signal(|| true);
|
||||||
|
let mut error = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
// For navigation controls
|
||||||
|
let zoom = *zoom_level.read();
|
||||||
|
let page = *current_page.read();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "pdf-viewer",
|
||||||
|
// Toolbar
|
||||||
|
div { class: "pdf-toolbar",
|
||||||
|
div { class: "pdf-toolbar-group",
|
||||||
|
button {
|
||||||
|
class: "pdf-toolbar-btn",
|
||||||
|
title: "Zoom out",
|
||||||
|
disabled: zoom <= 50,
|
||||||
|
onclick: move |_| {
|
||||||
|
let new_zoom = (*zoom_level.read()).saturating_sub(25).max(50);
|
||||||
|
zoom_level.set(new_zoom);
|
||||||
|
},
|
||||||
|
"\u{2212}" // minus
|
||||||
|
}
|
||||||
|
span { class: "pdf-zoom-label", "{zoom}%" }
|
||||||
|
button {
|
||||||
|
class: "pdf-toolbar-btn",
|
||||||
|
title: "Zoom in",
|
||||||
|
disabled: zoom >= 200,
|
||||||
|
onclick: move |_| {
|
||||||
|
let new_zoom = (*zoom_level.read() + 25).min(200);
|
||||||
|
zoom_level.set(new_zoom);
|
||||||
|
},
|
||||||
|
"+" // plus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "pdf-toolbar-group",
|
||||||
|
button {
|
||||||
|
class: "pdf-toolbar-btn",
|
||||||
|
title: "Fit to width",
|
||||||
|
onclick: move |_| zoom_level.set(100),
|
||||||
|
"\u{2194}" // left-right arrow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF embed container
|
||||||
|
div { class: "pdf-container",
|
||||||
|
if *loading.read() {
|
||||||
|
div { class: "pdf-loading",
|
||||||
|
div { class: "spinner" }
|
||||||
|
span { "Loading PDF..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref err) = *error.read() {
|
||||||
|
div { class: "pdf-error",
|
||||||
|
p { "{err}" }
|
||||||
|
a {
|
||||||
|
href: "{src}",
|
||||||
|
target: "_blank",
|
||||||
|
class: "btn btn-primary",
|
||||||
|
"Download PDF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use object/embed for PDF rendering
|
||||||
|
// The webview should handle PDF rendering natively
|
||||||
|
object {
|
||||||
|
class: "pdf-object",
|
||||||
|
r#type: "application/pdf",
|
||||||
|
data: "{src}#zoom={zoom}&page={page}",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
onload: move |_| {
|
||||||
|
loading.set(false);
|
||||||
|
error.set(None);
|
||||||
|
},
|
||||||
|
onerror: move |_| {
|
||||||
|
loading.set(false);
|
||||||
|
error
|
||||||
|
.set(
|
||||||
|
Some(
|
||||||
|
"Unable to display PDF. Your browser may not support embedded PDF viewing."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Fallback content
|
||||||
|
div { class: "pdf-fallback",
|
||||||
|
p { "PDF preview is not available in this browser." }
|
||||||
|
a {
|
||||||
|
href: "{src}",
|
||||||
|
target: "_blank",
|
||||||
|
class: "btn btn-primary",
|
||||||
|
"Download PDF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ use dioxus::prelude::*;
|
||||||
|
|
||||||
use super::pagination::Pagination as PaginationControls;
|
use super::pagination::Pagination as PaginationControls;
|
||||||
use super::utils::{format_size, type_badge_class, type_icon};
|
use super::utils::{format_size, type_badge_class, type_icon};
|
||||||
use crate::client::MediaResponse;
|
use crate::client::{MediaResponse, SavedSearchResponse};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Search(
|
pub fn Search(
|
||||||
|
|
@ -14,10 +14,17 @@ pub fn Search(
|
||||||
on_select: EventHandler<String>,
|
on_select: EventHandler<String>,
|
||||||
on_page_change: EventHandler<u64>,
|
on_page_change: EventHandler<u64>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
|
#[props(default)] saved_searches: Vec<SavedSearchResponse>,
|
||||||
|
#[props(default)] on_save_search: Option<EventHandler<(String, String, Option<String>)>>,
|
||||||
|
#[props(default)] on_delete_saved_search: Option<EventHandler<String>>,
|
||||||
|
#[props(default)] on_load_saved_search: Option<EventHandler<SavedSearchResponse>>,
|
||||||
) -> Element {
|
) -> Element {
|
||||||
let mut query = use_signal(String::new);
|
let mut query = use_signal(String::new);
|
||||||
let mut sort_by = use_signal(|| String::from("relevance"));
|
let mut sort_by = use_signal(|| String::from("relevance"));
|
||||||
let mut show_help = use_signal(|| false);
|
let mut show_help = use_signal(|| false);
|
||||||
|
let mut show_save_dialog = use_signal(|| false);
|
||||||
|
let mut save_name = use_signal(String::new);
|
||||||
|
let mut show_saved_list = use_signal(|| false);
|
||||||
// 0 = table, 1 = grid
|
// 0 = table, 1 = grid
|
||||||
let mut view_mode = use_signal(|| 0u8);
|
let mut view_mode = use_signal(|| 0u8);
|
||||||
|
|
||||||
|
|
@ -87,6 +94,23 @@ pub fn Search(
|
||||||
button { class: "btn btn-primary", onclick: do_search, "Search" }
|
button { class: "btn btn-primary", onclick: do_search, "Search" }
|
||||||
button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" }
|
button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" }
|
||||||
|
|
||||||
|
// Save/Load search buttons
|
||||||
|
if on_save_search.is_some() {
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
disabled: query.read().is_empty(),
|
||||||
|
onclick: move |_| show_save_dialog.set(true),
|
||||||
|
"Save"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !saved_searches.is_empty() {
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: move |_| show_saved_list.toggle(),
|
||||||
|
"Saved ({saved_searches.len()})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// View mode toggle
|
// View mode toggle
|
||||||
div { class: "view-toggle",
|
div { class: "view-toggle",
|
||||||
button {
|
button {
|
||||||
|
|
@ -148,6 +172,147 @@ pub fn Search(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save search dialog
|
||||||
|
if *show_save_dialog.read() {
|
||||||
|
div {
|
||||||
|
class: "modal-overlay",
|
||||||
|
onclick: move |_| show_save_dialog.set(false),
|
||||||
|
div {
|
||||||
|
class: "modal-content",
|
||||||
|
onclick: move |evt: MouseEvent| evt.stop_propagation(),
|
||||||
|
h3 { "Save Search" }
|
||||||
|
div { class: "form-field",
|
||||||
|
label { "Name" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Enter a name for this search...",
|
||||||
|
value: "{save_name}",
|
||||||
|
oninput: move |e| save_name.set(e.value()),
|
||||||
|
onkeypress: {
|
||||||
|
let query = query.read().clone();
|
||||||
|
let sort = sort_by.read().clone();
|
||||||
|
let handler = on_save_search;
|
||||||
|
move |e: KeyboardEvent| {
|
||||||
|
if e.key() == Key::Enter {
|
||||||
|
let name = save_name.read().clone();
|
||||||
|
if !name.is_empty() {
|
||||||
|
let sort_opt = if sort == "relevance" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(sort.clone())
|
||||||
|
};
|
||||||
|
if let Some(ref h) = handler {
|
||||||
|
h.call((name, query.clone(), sort_opt));
|
||||||
|
}
|
||||||
|
show_save_dialog.set(false);
|
||||||
|
save_name.set(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p { class: "text-muted text-sm", "Query: {query}" }
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: move |_| {
|
||||||
|
show_save_dialog.set(false);
|
||||||
|
save_name.set(String::new());
|
||||||
|
},
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
disabled: save_name.read().is_empty(),
|
||||||
|
onclick: {
|
||||||
|
let query_val = query.read().clone();
|
||||||
|
let sort_val = sort_by.read().clone();
|
||||||
|
let handler = on_save_search;
|
||||||
|
move |_| {
|
||||||
|
let name = save_name.read().clone();
|
||||||
|
if !name.is_empty() {
|
||||||
|
let sort_opt = if sort_val == "relevance" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(sort_val.clone())
|
||||||
|
};
|
||||||
|
if let Some(ref h) = handler {
|
||||||
|
h.call((name, query_val.clone(), sort_opt));
|
||||||
|
}
|
||||||
|
show_save_dialog.set(false);
|
||||||
|
save_name.set(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Save"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saved searches list
|
||||||
|
if *show_saved_list.read() && !saved_searches.is_empty() {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h4 { "Saved Searches" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
onclick: move |_| show_saved_list.set(false),
|
||||||
|
"Close"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "saved-searches-list",
|
||||||
|
for search in saved_searches.iter() {
|
||||||
|
{
|
||||||
|
let search_clone = search.clone();
|
||||||
|
let id_for_delete = search.id.clone();
|
||||||
|
let load_handler = on_load_saved_search;
|
||||||
|
let delete_handler = on_delete_saved_search;
|
||||||
|
rsx! {
|
||||||
|
div { class: "saved-search-item", key: "{search.id}",
|
||||||
|
div {
|
||||||
|
class: "saved-search-info",
|
||||||
|
onclick: {
|
||||||
|
let sc = search_clone.clone();
|
||||||
|
move |_| {
|
||||||
|
if let Some(ref h) = load_handler {
|
||||||
|
h.call(sc.clone());
|
||||||
|
}
|
||||||
|
query.set(sc.query.clone());
|
||||||
|
if let Some(ref s) = sc.sort_order {
|
||||||
|
sort_by.set(s.clone());
|
||||||
|
} else {
|
||||||
|
sort_by.set("relevance".to_string());
|
||||||
|
}
|
||||||
|
show_saved_list.set(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
span { class: "saved-search-name", "{search.name}" }
|
||||||
|
span { class: "saved-search-query text-muted", "{search.query}" }
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let id = id_for_delete.clone();
|
||||||
|
move |evt: MouseEvent| {
|
||||||
|
evt.stop_propagation();
|
||||||
|
if let Some(ref h) = delete_handler {
|
||||||
|
h.call(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p { class: "text-muted text-sm mb-8", "Results: {total_count}" }
|
p { class: "text-muted text-sm mb-8", "Results: {total_count}" }
|
||||||
|
|
||||||
if results.is_empty() && query.read().is_empty() {
|
if results.is_empty() && query.read().is_empty() {
|
||||||
|
|
@ -190,6 +355,8 @@ pub fn Search(
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
div { key: "{item.id}", class: "media-card", onclick: card_click,
|
div { key: "{item.id}", class: "media-card", onclick: card_click,
|
||||||
|
|
||||||
div { class: "card-thumbnail",
|
div { class: "card-thumbnail",
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,7 @@ pub fn Settings(
|
||||||
},
|
},
|
||||||
option { value: "dark", "Dark" }
|
option { value: "dark", "Dark" }
|
||||||
option { value: "light", "Light" }
|
option { value: "light", "Light" }
|
||||||
|
option { value: "system", "System" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,19 @@ pub fn Statistics(
|
||||||
|
|
||||||
// Media by Type
|
// Media by Type
|
||||||
|
|
||||||
|
// Storage by Type
|
||||||
|
|
||||||
|
// Top Tags
|
||||||
|
|
||||||
|
// Top Collections
|
||||||
|
|
||||||
|
// Date Range
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Storage by Type
|
|
||||||
|
|
||||||
// Top Tags
|
|
||||||
|
|
||||||
// Top Collections
|
|
||||||
|
|
||||||
// Date Range
|
|
||||||
if !s.media_by_type.is_empty() {
|
if !s.media_by_type.is_empty() {
|
||||||
div { class: "card mt-16",
|
div { class: "card mt-16",
|
||||||
h4 { class: "card-title", "Media by Type" }
|
h4 { class: "card-title", "Media by Type" }
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,8 @@ pub fn Tags(
|
||||||
if !children.is_empty() {
|
if !children.is_empty() {
|
||||||
div {
|
div {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class: "tag-children",
|
class: "tag-children",
|
||||||
style: "margin-left: 16px; margin-top: 4px;",
|
style: "margin-left: 16px; margin-top: 4px;",
|
||||||
for child in children.iter() {
|
for child in children.iter() {
|
||||||
|
|
|
||||||
|
|
@ -81,13 +81,25 @@ body {
|
||||||
.sidebar.collapsed .sidebar-header .logo,
|
.sidebar.collapsed .sidebar-header .logo,
|
||||||
.sidebar.collapsed .sidebar-header .version,
|
.sidebar.collapsed .sidebar-header .version,
|
||||||
.sidebar.collapsed .nav-badge { display: none; }
|
.sidebar.collapsed .nav-badge { display: none; }
|
||||||
.sidebar.collapsed .nav-item { justify-content: center; padding: 8px; border-left: none; }
|
.sidebar.collapsed .nav-item { justify-content: center; padding: 8px; border-left: none; border-radius: var(--radius-sm); }
|
||||||
|
.sidebar.collapsed .nav-item.active { border-left: none; }
|
||||||
.sidebar.collapsed .nav-icon { width: auto; margin: 0; }
|
.sidebar.collapsed .nav-icon { width: auto; margin: 0; }
|
||||||
|
.sidebar.collapsed .sidebar-header { padding: 12px 8px; justify-content: center; }
|
||||||
|
.sidebar.collapsed .nav-section { padding: 0 4px; }
|
||||||
|
.sidebar.collapsed .sidebar-footer { padding: 8px 4px; }
|
||||||
|
|
||||||
/* Nav item text - hide when collapsed */
|
/* Nav item text - hide when collapsed, properly handle overflow when expanded */
|
||||||
.nav-item-text {
|
.nav-item-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When sidebar is expanded, allow text to show fully */
|
||||||
|
.sidebar:not(.collapsed) .nav-item-text {
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed .nav-item-text { display: none; }
|
.sidebar.collapsed .nav-item-text { display: none; }
|
||||||
|
|
@ -179,8 +191,14 @@ body {
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
overflow: visible;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide footer content in collapsed sidebar */
|
||||||
|
.sidebar.collapsed .sidebar-footer .status-text { display: none; }
|
||||||
|
.sidebar.collapsed .sidebar-footer .user-info { justify-content: center; }
|
||||||
|
|
||||||
/* ── Main ── */
|
/* ── Main ── */
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -747,10 +765,86 @@ input[type="text"]:focus, textarea:focus, select:focus {
|
||||||
|
|
||||||
/* ── Checkbox ── */
|
/* ── Checkbox ── */
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
accent-color: var(--accent);
|
appearance: none;
|
||||||
width: 14px;
|
-webkit-appearance: none;
|
||||||
height: 14px;
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
top: 2px;
|
||||||
|
width: 4px;
|
||||||
|
height: 8px;
|
||||||
|
border: solid var(--bg-0);
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox with label */
|
||||||
|
.checkbox-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-1);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover {
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Number input */
|
||||||
|
input[type="number"] {
|
||||||
|
width: 80px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-0);
|
||||||
|
font-size: 12px;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-outer-spin-button,
|
||||||
|
input[type="number"]::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Select ── */
|
/* ── Select ── */
|
||||||
|
|
@ -784,6 +878,8 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
|
|
@ -802,7 +898,18 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
|
||||||
50% { opacity: 0.3; }
|
50% { opacity: 0.3; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text { color: var(--text-2); }
|
.status-text {
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure status text is visible in expanded sidebar */
|
||||||
|
.sidebar:not(.collapsed) .status-text {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Modal ── */
|
/* ── Modal ── */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
|
|
@ -850,6 +957,61 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Saved Searches ── */
|
||||||
|
.saved-searches-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-search-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-search-item:hover {
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-search-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-search-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-search-query {
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Offline banner ── */
|
/* ── Offline banner ── */
|
||||||
.offline-banner {
|
.offline-banner {
|
||||||
background: rgba(228, 88, 88, 0.06);
|
background: rgba(228, 88, 88, 0.06);
|
||||||
|
|
@ -881,15 +1043,94 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
|
||||||
/* ── Filter bar ── */
|
/* ── Filter bar ── */
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 8px 12px;
|
padding: 12px;
|
||||||
background: var(--bg-0);
|
background: var(--bg-0);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 12px;
|
||||||
font-size: 12px;
|
}
|
||||||
|
|
||||||
|
.filter-bar .filter-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .filter-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter chip/toggle style */
|
||||||
|
.filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-1);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip:hover {
|
||||||
|
background: var(--bg-3);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip input[type="checkbox"] {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip input[type="checkbox"]:checked::after {
|
||||||
|
left: 3px;
|
||||||
|
top: 1px;
|
||||||
|
width: 3px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Size filter inputs */
|
||||||
|
.filter-bar .size-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .size-filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .size-filter-group label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar input[type="number"] {
|
||||||
|
width: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group {
|
.filter-group {
|
||||||
|
|
@ -1071,6 +1312,14 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled with hint - shows what action is needed */
|
||||||
|
.btn.btn-disabled-hint:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
border-style: dashed;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Library Toolbar ── */
|
/* ── Library Toolbar ── */
|
||||||
.library-toolbar {
|
.library-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1589,6 +1838,93 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
|
||||||
color: var(--text-0);
|
color: var(--text-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-current-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-label {
|
||||||
|
color: var(--text-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-name {
|
||||||
|
color: var(--text-0);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-queue-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-queue-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-queue-text {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar import progress ── */
|
||||||
|
.sidebar-import-progress {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-import-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-import-file {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-import-progress .progress-bar {
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-import-progress {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-import-header span,
|
||||||
|
.sidebar.collapsed .sidebar-import-file {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Tag confirmation ── */
|
/* ── Tag confirmation ── */
|
||||||
.tag-confirm-delete {
|
.tag-confirm-delete {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -2391,9 +2727,13 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
|
||||||
|
|
||||||
/* Hide user details in collapsed sidebar, show only logout icon */
|
/* Hide user details in collapsed sidebar, show only logout icon */
|
||||||
.sidebar.collapsed .user-info .user-name,
|
.sidebar.collapsed .user-info .user-name,
|
||||||
.sidebar.collapsed .user-info .role-badge { display: none; }
|
.sidebar.collapsed .user-info .role-badge,
|
||||||
|
.sidebar.collapsed .user-info .btn { display: none; }
|
||||||
|
|
||||||
.sidebar.collapsed .user-info .btn { padding: 6px; }
|
.sidebar.collapsed .user-info {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.role-badge {
|
.role-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
@ -2676,4 +3016,117 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── PDF Viewer ── */
|
||||||
|
.pdf-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 500px;
|
||||||
|
background: var(--bg-0);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-1);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-3);
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-zoom-label {
|
||||||
|
min-width: 45px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-object {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-error {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
color: var(--text-1);
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-fallback {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme adjustments */
|
||||||
|
.theme-light .pdf-container {
|
||||||
|
background: #e8e8e8;
|
||||||
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue