chore: bump deps; fix clippy lints & cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4c4815ad145650a07f108614034d2e996a6a6964
This commit is contained in:
parent
c535650f45
commit
cd1161ee5d
41 changed files with 1528 additions and 953 deletions
|
|
@ -6,6 +6,22 @@ use crate::{
|
|||
storage::DynStorageBackend,
|
||||
};
|
||||
|
||||
/// Records an audit action for a media item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend for persistence
|
||||
/// * `media_id` - Optional media item that was affected
|
||||
/// * `action` - The action being performed
|
||||
/// * `details` - Optional additional details
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns errors from the storage backend
|
||||
pub async fn record_action(
|
||||
storage: &DynStorageBackend,
|
||||
media_id: Option<MediaId>,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,19 @@ use uuid::Uuid;
|
|||
|
||||
use crate::{error::Result, model::*, storage::DynStorageBackend};
|
||||
|
||||
/// Creates a new collection.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `name` - Collection name
|
||||
/// * `kind` - Manual or virtual collection
|
||||
/// * `description` - Optional description
|
||||
/// * `filter_query` - For virtual collections, the search query
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The created collection
|
||||
pub async fn create_collection(
|
||||
storage: &DynStorageBackend,
|
||||
name: &str,
|
||||
|
|
@ -14,6 +27,18 @@ pub async fn create_collection(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Adds a media item to a collection.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `collection_id` - Target collection
|
||||
/// * `media_id` - Media item to add
|
||||
/// * `position` - Position in the collection order
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
pub async fn add_member(
|
||||
storage: &DynStorageBackend,
|
||||
collection_id: Uuid,
|
||||
|
|
@ -32,6 +57,17 @@ pub async fn add_member(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Removes a media item from a collection.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `collection_id` - Target collection
|
||||
/// * `media_id` - Media item to remove
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
pub async fn remove_member(
|
||||
storage: &DynStorageBackend,
|
||||
collection_id: Uuid,
|
||||
|
|
@ -49,6 +85,19 @@ pub async fn remove_member(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Returns all media items in a collection.
|
||||
///
|
||||
/// Virtual collections are evaluated dynamically using their filter query.
|
||||
/// Manual collections return stored members.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `collection_id` - Collection to query
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// List of media items in the collection
|
||||
pub async fn get_members(
|
||||
storage: &DynStorageBackend,
|
||||
collection_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub struct Config {
|
|||
pub sync: SyncConfig,
|
||||
#[serde(default)]
|
||||
pub sharing: SharingConfig,
|
||||
#[serde(default)]
|
||||
pub trash: TrashConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -284,8 +286,6 @@ impl std::fmt::Display for UserRole {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Plugin Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginsConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -337,8 +337,6 @@ impl Default for PluginsConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Transcoding Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TranscodingConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -400,8 +398,6 @@ impl Default for TranscodingConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Enrichment Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct EnrichmentConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -432,8 +428,6 @@ pub struct EnrichmentSource {
|
|||
pub api_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
// ===== Cloud Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CloudConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -483,8 +477,6 @@ impl Default for CloudConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Analytics Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalyticsConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -509,8 +501,6 @@ impl Default for AnalyticsConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Photo Management Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PhotoConfig {
|
||||
/// Generate perceptual hashes for image duplicate detection (CPU-intensive)
|
||||
|
|
@ -568,8 +558,6 @@ impl Default for PhotoConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Managed Storage Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManagedStorageConfig {
|
||||
/// Enable managed storage for file uploads
|
||||
|
|
@ -613,23 +601,18 @@ impl Default for ManagedStorageConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Sync Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConflictResolution {
|
||||
ServerWins,
|
||||
ClientWins,
|
||||
#[default]
|
||||
KeepBoth,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Default for ConflictResolution {
|
||||
fn default() -> Self {
|
||||
Self::KeepBoth
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncConfig {
|
||||
/// Enable cross-device sync functionality
|
||||
|
|
@ -697,8 +680,6 @@ impl Default for SyncConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Sharing Configuration =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharingConfig {
|
||||
/// Enable sharing functionality
|
||||
|
|
@ -750,7 +731,29 @@ impl Default for SharingConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Storage Configuration =====
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrashConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_trash_retention_days")]
|
||||
pub retention_days: u64,
|
||||
#[serde(default)]
|
||||
pub auto_empty: bool,
|
||||
}
|
||||
|
||||
fn default_trash_retention_days() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
impl Default for TrashConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
retention_days: default_trash_retention_days(),
|
||||
auto_empty: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StorageConfig {
|
||||
|
|
@ -982,19 +985,19 @@ impl Config {
|
|||
|
||||
/// Ensure all directories needed by this config exist and are writable.
|
||||
pub fn ensure_dirs(&self) -> crate::error::Result<()> {
|
||||
if let Some(ref sqlite) = self.storage.sqlite {
|
||||
if let Some(parent) = sqlite.path.parent() {
|
||||
// Skip if parent is empty string (happens with bare filenames like
|
||||
// "pinakes.db")
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let metadata = std::fs::metadata(parent)?;
|
||||
if metadata.permissions().readonly() {
|
||||
return Err(crate::error::PinakesError::Config(format!(
|
||||
"directory is not writable: {}",
|
||||
parent.display()
|
||||
)));
|
||||
}
|
||||
if let Some(ref sqlite) = self.storage.sqlite
|
||||
&& let Some(parent) = sqlite.path.parent()
|
||||
{
|
||||
// Skip if parent is empty string (happens with bare filenames like
|
||||
// "pinakes.db")
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let metadata = std::fs::metadata(parent)?;
|
||||
if metadata.permissions().readonly() {
|
||||
return Err(crate::error::PinakesError::Config(format!(
|
||||
"directory is not writable: {}",
|
||||
parent.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1139,6 +1142,7 @@ impl Default for Config {
|
|||
managed_storage: ManagedStorageConfig::default(),
|
||||
sync: SyncConfig::default(),
|
||||
sharing: SharingConfig::default(),
|
||||
trash: TrashConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ pub fn detect_events(
|
|||
}
|
||||
|
||||
// Sort by date_taken
|
||||
items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap()));
|
||||
items.sort_by_key(|a| a.date_taken.unwrap());
|
||||
|
||||
let mut events: Vec<DetectedEvent> = Vec::new();
|
||||
let mut current_event_items: Vec<MediaId> = vec![items[0].id];
|
||||
|
|
@ -181,7 +181,7 @@ pub fn detect_bursts(
|
|||
}
|
||||
|
||||
// Sort by date_taken
|
||||
items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap()));
|
||||
items.sort_by_key(|a| a.date_taken.unwrap());
|
||||
|
||||
let mut bursts: Vec<Vec<MediaId>> = Vec::new();
|
||||
let mut current_burst: Vec<MediaId> = vec![items[0].id];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ use crate::{error::Result, model::ContentHash};
|
|||
|
||||
const BUFFER_SIZE: usize = 65536;
|
||||
|
||||
/// Computes the BLAKE3 hash of a file asynchronously.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Path to the file to hash
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The content hash
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns I/O errors or task execution errors
|
||||
pub async fn compute_file_hash(path: &Path) -> Result<ContentHash> {
|
||||
let path = path.to_path_buf();
|
||||
let hash = tokio::task::spawn_blocking(move || -> Result<ContentHash> {
|
||||
|
|
@ -24,6 +37,7 @@ pub async fn compute_file_hash(path: &Path) -> Result<ContentHash> {
|
|||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Computes the BLAKE3 hash of a byte slice synchronously.
|
||||
pub fn compute_hash_sync(data: &[u8]) -> ContentHash {
|
||||
let hash = blake3::hash(data);
|
||||
ContentHash::new(hash.to_hex().to_string())
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use crate::{
|
|||
thumbnail,
|
||||
};
|
||||
|
||||
/// Result of importing a single file.
|
||||
pub struct ImportResult {
|
||||
pub media_id: MediaId,
|
||||
pub was_duplicate: bool,
|
||||
|
|
@ -26,7 +27,7 @@ pub struct ImportResult {
|
|||
}
|
||||
|
||||
/// Options for import operations
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ImportOptions {
|
||||
/// Skip files that haven't changed since last scan (based on mtime)
|
||||
pub incremental: bool,
|
||||
|
|
@ -36,16 +37,6 @@ pub struct ImportOptions {
|
|||
pub photo_config: crate::config::PhotoConfig,
|
||||
}
|
||||
|
||||
impl Default for ImportOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
incremental: false,
|
||||
force: false,
|
||||
photo_config: crate::config::PhotoConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the modification time of a file as a Unix timestamp
|
||||
fn get_file_mtime(path: &Path) -> Option<i64> {
|
||||
std::fs::metadata(path)
|
||||
|
|
@ -55,9 +46,20 @@ fn get_file_mtime(path: &Path) -> Option<i64> {
|
|||
.map(|d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Validates that a path is within configured root directories.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend to query root directories
|
||||
/// * `path` - Path to validate
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` if path is within roots or no roots configured
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `InvalidOperation` if path is outside all root directories
|
||||
pub async fn validate_path_in_roots(
|
||||
storage: &DynStorageBackend,
|
||||
path: &Path,
|
||||
|
|
@ -79,6 +81,20 @@ pub async fn validate_path_in_roots(
|
|||
)))
|
||||
}
|
||||
|
||||
/// Imports a file using default options.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `path` - Path to the file to import
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Import result with media ID and status
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `FileNotFound` if path doesn't exist
|
||||
pub async fn import_file(
|
||||
storage: &DynStorageBackend,
|
||||
path: &Path,
|
||||
|
|
@ -236,15 +252,15 @@ pub async fn import_file_with_options(
|
|||
storage.insert_media(&item).await?;
|
||||
|
||||
// Extract and store markdown links for markdown files
|
||||
if is_markdown {
|
||||
if let Err(e) = extract_and_store_links(storage, media_id, &path).await {
|
||||
tracing::warn!(
|
||||
media_id = %media_id,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"failed to extract markdown links"
|
||||
);
|
||||
}
|
||||
if is_markdown
|
||||
&& let Err(e) = extract_and_store_links(storage, media_id, &path).await
|
||||
{
|
||||
tracing::warn!(
|
||||
media_id = %media_id,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"failed to extract markdown links"
|
||||
);
|
||||
}
|
||||
|
||||
// Store extracted extra metadata as custom fields
|
||||
|
|
@ -419,12 +435,10 @@ async fn extract_and_store_links(
|
|||
media_id: MediaId,
|
||||
path: &Path,
|
||||
) -> Result<()> {
|
||||
// Read file content
|
||||
let content = tokio::fs::read_to_string(path).await.map_err(|e| {
|
||||
PinakesError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("failed to read markdown file for link extraction: {e}"),
|
||||
))
|
||||
PinakesError::Io(std::io::Error::other(format!(
|
||||
"failed to read markdown file for link extraction: {e}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
// Extract links
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use crate::{
|
|||
storage::DynStorageBackend,
|
||||
};
|
||||
|
||||
/// Report of orphaned, untracked, and moved files.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrphanReport {
|
||||
/// Media items whose files no longer exist on disk.
|
||||
|
|
@ -24,6 +25,7 @@ pub struct OrphanReport {
|
|||
pub moved_files: Vec<(MediaId, PathBuf, PathBuf)>,
|
||||
}
|
||||
|
||||
/// Action to take when resolving orphans.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OrphanAction {
|
||||
|
|
@ -31,6 +33,7 @@ pub enum OrphanAction {
|
|||
Ignore,
|
||||
}
|
||||
|
||||
/// Report of file integrity verification results.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerificationReport {
|
||||
pub verified: usize,
|
||||
|
|
@ -39,6 +42,7 @@ pub struct VerificationReport {
|
|||
pub errors: Vec<(MediaId, String)>,
|
||||
}
|
||||
|
||||
/// Status of a media item's file integrity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IntegrityStatus {
|
||||
|
|
@ -72,9 +76,15 @@ impl std::str::FromStr for IntegrityStatus {
|
|||
}
|
||||
}
|
||||
|
||||
/// Detect orphaned media items (files that no longer exist on disk),
|
||||
/// untracked files (files on disk not in database), and moved files (same hash,
|
||||
/// different path).
|
||||
/// Detect orphaned, untracked, and moved files.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend to query
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Report containing orphaned items, untracked files, and moved files
|
||||
pub async fn detect_orphans(
|
||||
storage: &DynStorageBackend,
|
||||
) -> Result<OrphanReport> {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ pub enum JobKind {
|
|||
media_ids: Vec<MediaId>,
|
||||
},
|
||||
CleanupAnalytics,
|
||||
TrashPurge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -167,7 +168,7 @@ impl JobQueue {
|
|||
cancel,
|
||||
};
|
||||
|
||||
// If the channel is full we still record the job — it'll stay Pending
|
||||
// If the channel is full we still record the job; it will stay Pending
|
||||
let _ = self.tx.send(item).await;
|
||||
id
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ impl ManagedStorageService {
|
|||
self.verify(hash).await?;
|
||||
}
|
||||
|
||||
fs::File::open(&path).await.map_err(|e| PinakesError::Io(e))
|
||||
fs::File::open(&path).await.map_err(PinakesError::Io)
|
||||
}
|
||||
|
||||
/// Read a blob entirely into memory.
|
||||
|
|
@ -271,11 +271,11 @@ impl ManagedStorageService {
|
|||
let mut file_entries = fs::read_dir(&sub_path).await?;
|
||||
while let Some(file_entry) = file_entries.next_entry().await? {
|
||||
let file_path = file_entry.path();
|
||||
if file_path.is_file() {
|
||||
if let Some(name) = file_path.file_name() {
|
||||
hashes
|
||||
.push(ContentHash::new(name.to_string_lossy().to_string()));
|
||||
}
|
||||
if file_path.is_file()
|
||||
&& let Some(name) = file_path.file_name()
|
||||
{
|
||||
hashes
|
||||
.push(ContentHash::new(name.to_string_lossy().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -311,15 +311,15 @@ impl ManagedStorageService {
|
|||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
// Check if temp file is old (> 1 hour)
|
||||
if let Ok(meta) = fs::metadata(&path).await {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
let age = std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
if age.as_secs() > 3600 {
|
||||
let _ = fs::remove_file(&path).await;
|
||||
count += 1;
|
||||
}
|
||||
if let Ok(meta) = fs::metadata(&path).await
|
||||
&& let Ok(modified) = meta.modified()
|
||||
{
|
||||
let age = std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
if age.as_secs() > 3600 {
|
||||
let _ = fs::remove_file(&path).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ use uuid::Uuid;
|
|||
|
||||
use crate::media_type::MediaType;
|
||||
|
||||
/// Unique identifier for a media item.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MediaId(pub Uuid);
|
||||
|
||||
impl MediaId {
|
||||
/// Creates a new media ID using UUIDv7.
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
|
@ -27,10 +29,12 @@ impl Default for MediaId {
|
|||
}
|
||||
}
|
||||
|
||||
/// BLAKE3 content hash for deduplication.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ContentHash(pub String);
|
||||
|
||||
impl ContentHash {
|
||||
/// Creates a new content hash from a hex string.
|
||||
pub fn new(hex: String) -> Self {
|
||||
Self(hex)
|
||||
}
|
||||
|
|
@ -42,8 +46,6 @@ impl fmt::Display for ContentHash {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Managed Storage Types =====
|
||||
|
||||
/// Storage mode for media items
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
|
||||
|
|
@ -162,12 +164,14 @@ pub struct MediaItem {
|
|||
pub links_extracted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// A custom field attached to a media item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomField {
|
||||
pub field_type: CustomFieldType,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Type of custom field value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CustomFieldType {
|
||||
|
|
@ -177,6 +181,7 @@ pub enum CustomFieldType {
|
|||
Boolean,
|
||||
}
|
||||
|
||||
/// A tag that can be applied to media items.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: Uuid,
|
||||
|
|
@ -185,6 +190,7 @@ pub struct Tag {
|
|||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A collection of media items.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Collection {
|
||||
pub id: Uuid,
|
||||
|
|
@ -196,6 +202,7 @@ pub struct Collection {
|
|||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Kind of collection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CollectionKind {
|
||||
|
|
@ -203,6 +210,7 @@ pub enum CollectionKind {
|
|||
Virtual,
|
||||
}
|
||||
|
||||
/// A member of a collection with position tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CollectionMember {
|
||||
pub collection_id: Uuid,
|
||||
|
|
@ -211,6 +219,7 @@ pub struct CollectionMember {
|
|||
pub added_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// An audit trail entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditEntry {
|
||||
pub id: Uuid,
|
||||
|
|
@ -329,6 +338,7 @@ impl fmt::Display for AuditAction {
|
|||
}
|
||||
}
|
||||
|
||||
/// Pagination parameters for list queries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub offset: u64,
|
||||
|
|
@ -337,6 +347,7 @@ pub struct Pagination {
|
|||
}
|
||||
|
||||
impl Pagination {
|
||||
/// Creates a new pagination instance.
|
||||
pub fn new(offset: u64, limit: u64, sort: Option<String>) -> Self {
|
||||
Self {
|
||||
offset,
|
||||
|
|
@ -356,6 +367,7 @@ impl Default for Pagination {
|
|||
}
|
||||
}
|
||||
|
||||
/// A saved search query.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedSearch {
|
||||
pub id: Uuid,
|
||||
|
|
@ -367,6 +379,7 @@ pub struct SavedSearch {
|
|||
|
||||
// Book Management Types
|
||||
|
||||
/// Metadata for book-type media.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BookMetadata {
|
||||
pub media_id: MediaId,
|
||||
|
|
@ -385,6 +398,7 @@ pub struct BookMetadata {
|
|||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Information about a book author.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AuthorInfo {
|
||||
pub name: String,
|
||||
|
|
@ -394,6 +408,7 @@ pub struct AuthorInfo {
|
|||
}
|
||||
|
||||
impl AuthorInfo {
|
||||
/// Creates a new author with the given name.
|
||||
pub fn new(name: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
|
|
@ -403,6 +418,7 @@ impl AuthorInfo {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sets the author's role.
|
||||
pub fn with_role(mut self, role: String) -> Self {
|
||||
self.role = role;
|
||||
self
|
||||
|
|
@ -435,6 +451,7 @@ pub struct ExtractedBookMetadata {
|
|||
pub identifiers: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Reading progress for a book.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadingProgress {
|
||||
pub media_id: MediaId,
|
||||
|
|
@ -446,6 +463,7 @@ pub struct ReadingProgress {
|
|||
}
|
||||
|
||||
impl ReadingProgress {
|
||||
/// Creates a new reading progress entry.
|
||||
pub fn new(
|
||||
media_id: MediaId,
|
||||
user_id: Uuid,
|
||||
|
|
@ -473,6 +491,7 @@ impl ReadingProgress {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reading status for a book.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReadingStatus {
|
||||
|
|
@ -493,8 +512,6 @@ impl fmt::Display for ReadingStatus {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Markdown Links (Obsidian-style) =====
|
||||
|
||||
/// Type of markdown link
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
|
@ -530,7 +547,7 @@ impl std::str::FromStr for LinkType {
|
|||
}
|
||||
}
|
||||
|
||||
/// A markdown link extracted from a file
|
||||
/// A markdown link extracted from a file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarkdownLink {
|
||||
pub id: Uuid,
|
||||
|
|
@ -549,7 +566,7 @@ pub struct MarkdownLink {
|
|||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Information about a backlink (incoming link)
|
||||
/// Information about a backlink (incoming link).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacklinkInfo {
|
||||
pub link_id: Uuid,
|
||||
|
|
@ -562,14 +579,14 @@ pub struct BacklinkInfo {
|
|||
pub link_type: LinkType,
|
||||
}
|
||||
|
||||
/// Graph data for visualization
|
||||
/// Graph data for visualization.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct GraphData {
|
||||
pub nodes: Vec<GraphNode>,
|
||||
pub edges: Vec<GraphEdge>,
|
||||
}
|
||||
|
||||
/// A node in the graph visualization
|
||||
/// A node in the graph visualization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphNode {
|
||||
pub id: String,
|
||||
|
|
@ -582,7 +599,7 @@ pub struct GraphNode {
|
|||
pub backlink_count: u32,
|
||||
}
|
||||
|
||||
/// An edge (link) in the graph visualization
|
||||
/// An edge (link) in the graph visualization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphEdge {
|
||||
pub source: String,
|
||||
|
|
|
|||
|
|
@ -15,14 +15,9 @@ impl WasmRuntime {
|
|||
/// Create a new WASM runtime
|
||||
pub fn new() -> Result<Self> {
|
||||
let mut config = Config::new();
|
||||
|
||||
// Enable WASM features
|
||||
config.wasm_component_model(true);
|
||||
config.async_support(true);
|
||||
|
||||
// Set resource limits
|
||||
config.max_wasm_stack(1024 * 1024); // 1MB stack
|
||||
config.consume_fuel(true); // Enable fuel metering for CPU limits
|
||||
config.consume_fuel(true); // enable fuel metering for CPU limits
|
||||
|
||||
let engine = Engine::new(&config)?;
|
||||
|
||||
|
|
@ -39,10 +34,7 @@ impl WasmRuntime {
|
|||
return Err(anyhow!("WASM file not found: {:?}", wasm_path));
|
||||
}
|
||||
|
||||
// Read WASM bytes
|
||||
let wasm_bytes = std::fs::read(wasm_path)?;
|
||||
|
||||
// Compile module
|
||||
let module = Module::new(&self.engine, &wasm_bytes)?;
|
||||
|
||||
Ok(WasmPlugin {
|
||||
|
|
@ -82,7 +74,6 @@ impl WasmPlugin {
|
|||
) -> Result<Vec<u8>> {
|
||||
let engine = self.module.engine();
|
||||
|
||||
// Create store with per-invocation data
|
||||
let store_data = PluginStoreData {
|
||||
context: self.context.clone(),
|
||||
exchange_buffer: Vec::new(),
|
||||
|
|
@ -97,17 +88,14 @@ impl WasmPlugin {
|
|||
store.set_fuel(1_000_000_000)?;
|
||||
}
|
||||
|
||||
// Set up linker with host functions
|
||||
let mut linker = Linker::new(engine);
|
||||
HostFunctions::setup_linker(&mut linker)?;
|
||||
|
||||
// Instantiate the module
|
||||
let instance = linker.instantiate_async(&mut store, &self.module).await?;
|
||||
|
||||
// Get the memory export (if available)
|
||||
let memory = instance.get_memory(&mut store, "memory");
|
||||
|
||||
// If there are params and memory is available, write them
|
||||
// If there are params and memory is available, write them to the module
|
||||
let mut alloc_offset: i32 = 0;
|
||||
if !params.is_empty()
|
||||
&& let Some(mem) = &memory
|
||||
|
|
@ -136,7 +124,6 @@ impl WasmPlugin {
|
|||
}
|
||||
}
|
||||
|
||||
// Look up the exported function and call it
|
||||
let func =
|
||||
instance
|
||||
.get_func(&mut store, function_name)
|
||||
|
|
@ -150,9 +137,9 @@ impl WasmPlugin {
|
|||
|
||||
let mut results = vec![Val::I32(0); result_count];
|
||||
|
||||
// Call with appropriate params based on function signature
|
||||
// Call with appropriate params based on function signature; convention:
|
||||
// (ptr, len)
|
||||
if param_count == 2 && !params.is_empty() {
|
||||
// Convention: (ptr, len)
|
||||
func
|
||||
.call_async(
|
||||
&mut store,
|
||||
|
|
@ -171,13 +158,13 @@ impl WasmPlugin {
|
|||
.await?;
|
||||
}
|
||||
|
||||
// Read result from exchange buffer (host functions may have written data)
|
||||
// Prefer data written into the exchange buffer by host functions
|
||||
let exchange = std::mem::take(&mut store.data_mut().exchange_buffer);
|
||||
if !exchange.is_empty() {
|
||||
return Ok(exchange);
|
||||
}
|
||||
|
||||
// Otherwise serialize the return values
|
||||
// Fall back to serialising the WASM return value
|
||||
if let Some(Val::I32(ret)) = results.first() {
|
||||
Ok(ret.to_le_bytes().to_vec())
|
||||
} else {
|
||||
|
|
@ -208,9 +195,10 @@ impl Default for WasmPlugin {
|
|||
pub struct HostFunctions;
|
||||
|
||||
impl HostFunctions {
|
||||
/// Set up host functions in a linker
|
||||
/// Registers all host ABI functions (`host_log`, `host_read_file`,
|
||||
/// `host_write_file`, `host_http_request`, `host_get_config`,
|
||||
/// `host_get_buffer`) into the given linker.
|
||||
pub fn setup_linker(linker: &mut Linker<PluginStoreData>) -> Result<()> {
|
||||
// host_log: log a message from the plugin
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_log",
|
||||
|
|
@ -240,7 +228,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_read_file: read a file into the exchange buffer
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_read_file",
|
||||
|
|
@ -300,7 +287,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_write_file: write data to a file
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_write_file",
|
||||
|
|
@ -373,7 +359,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_http_request: make an HTTP request (blocking)
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_http_request",
|
||||
|
|
@ -461,7 +446,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_get_config: read a config key into the exchange buffer
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_get_config",
|
||||
|
|
@ -500,7 +484,6 @@ impl HostFunctions {
|
|||
},
|
||||
)?;
|
||||
|
||||
// host_get_buffer: copy the exchange buffer to WASM memory
|
||||
linker.func_wrap(
|
||||
"env",
|
||||
"host_get_buffer",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::{error::Result, import, storage::DynStorageBackend};
|
||||
|
||||
/// Status of a directory scan operation.
|
||||
pub struct ScanStatus {
|
||||
pub scanning: bool,
|
||||
pub files_found: usize,
|
||||
|
|
@ -100,6 +101,17 @@ impl Default for ScanProgress {
|
|||
}
|
||||
}
|
||||
|
||||
/// Scans a directory with default options.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `dir` - Directory to scan
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Scan status with counts and any errors
|
||||
pub async fn scan_directory(
|
||||
storage: &DynStorageBackend,
|
||||
dir: &Path,
|
||||
|
|
@ -115,7 +127,19 @@ pub async fn scan_directory(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Scan a directory with incremental scanning support
|
||||
/// Scans a directory with incremental scanning support.
|
||||
///
|
||||
/// Skips files that haven't changed since last scan based on mtime.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `dir` - Directory to scan
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Scan status with counts and any errors
|
||||
pub async fn scan_directory_incremental(
|
||||
storage: &DynStorageBackend,
|
||||
dir: &Path,
|
||||
|
|
@ -129,6 +153,18 @@ pub async fn scan_directory_incremental(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Scans a directory with progress reporting.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `dir` - Directory to scan
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
/// * `progress` - Optional progress tracker
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Scan status with counts and any errors
|
||||
pub async fn scan_directory_with_progress(
|
||||
storage: &DynStorageBackend,
|
||||
dir: &Path,
|
||||
|
|
@ -230,6 +266,16 @@ pub async fn scan_directory_with_options(
|
|||
Ok(status)
|
||||
}
|
||||
|
||||
/// Scans all configured root directories with default options.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Status for each root directory
|
||||
pub async fn scan_all_roots(
|
||||
storage: &DynStorageBackend,
|
||||
ignore_patterns: &[String],
|
||||
|
|
@ -243,7 +289,16 @@ pub async fn scan_all_roots(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Scan all roots incrementally (skip unchanged files)
|
||||
/// Scans all roots incrementally, skipping unchanged files.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Status for each root directory
|
||||
pub async fn scan_all_roots_incremental(
|
||||
storage: &DynStorageBackend,
|
||||
ignore_patterns: &[String],
|
||||
|
|
@ -255,6 +310,17 @@ pub async fn scan_all_roots_incremental(
|
|||
scan_all_roots_with_options(storage, ignore_patterns, None, &options).await
|
||||
}
|
||||
|
||||
/// Scans all root directories with progress reporting.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
/// * `progress` - Optional progress tracker
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Status for each root directory
|
||||
pub async fn scan_all_roots_with_progress(
|
||||
storage: &DynStorageBackend,
|
||||
ignore_patterns: &[String],
|
||||
|
|
@ -269,7 +335,18 @@ pub async fn scan_all_roots_with_progress(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Scan all roots with full options including progress and incremental mode
|
||||
/// Scans all roots with full options including progress and incremental mode.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `ignore_patterns` - Patterns to exclude
|
||||
/// * `progress` - Optional progress tracker
|
||||
/// * `scan_options` - Scan configuration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Status for each root directory
|
||||
pub async fn scan_all_roots_with_options(
|
||||
storage: &DynStorageBackend,
|
||||
ignore_patterns: &[String],
|
||||
|
|
@ -306,12 +383,14 @@ pub async fn scan_all_roots_with_options(
|
|||
Ok(statuses)
|
||||
}
|
||||
|
||||
/// Watches directories for file changes and imports modified files.
|
||||
pub struct FileWatcher {
|
||||
_watcher: Box<dyn Watcher + Send>,
|
||||
rx: mpsc::Receiver<PathBuf>,
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
/// Creates a new file watcher for the given directories.
|
||||
pub fn new(dirs: &[PathBuf]) -> Result<Self> {
|
||||
let (tx, rx) = mpsc::channel(1024);
|
||||
|
||||
|
|
@ -393,11 +472,13 @@ impl FileWatcher {
|
|||
Ok(Box::new(watcher))
|
||||
}
|
||||
|
||||
/// Receives the next changed file path.
|
||||
pub async fn next_change(&mut self) -> Option<PathBuf> {
|
||||
self.rx.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches directories and imports files on change.
|
||||
pub async fn watch_and_import(
|
||||
storage: DynStorageBackend,
|
||||
dirs: Vec<PathBuf>,
|
||||
|
|
|
|||
|
|
@ -200,6 +200,22 @@ impl TaskScheduler {
|
|||
running: false,
|
||||
last_job_id: None,
|
||||
},
|
||||
ScheduledTask {
|
||||
id: "trash_purge".to_string(),
|
||||
name: "Trash Purge".to_string(),
|
||||
kind: JobKind::TrashPurge,
|
||||
schedule: Schedule::Weekly {
|
||||
day: 0,
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
},
|
||||
enabled: false,
|
||||
last_run: None,
|
||||
next_run: None,
|
||||
last_status: None,
|
||||
running: false,
|
||||
last_job_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
Self {
|
||||
|
|
@ -404,6 +420,7 @@ mod tests {
|
|||
use chrono::TimeZone;
|
||||
|
||||
use super::*;
|
||||
use crate::config::TrashConfig;
|
||||
|
||||
#[test]
|
||||
fn test_interval_next_run() {
|
||||
|
|
@ -453,7 +470,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_weekly_same_day_future() {
|
||||
// 2025-06-15 is Sunday (day 6). Schedule is Sunday 14:00, current is 10:00
|
||||
// => today.
|
||||
|
||||
let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap();
|
||||
let schedule = Schedule::Weekly {
|
||||
day: 6,
|
||||
|
|
@ -467,7 +484,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_weekly_same_day_past() {
|
||||
// 2025-06-15 is Sunday (day 6). Schedule is Sunday 08:00, current is 10:00
|
||||
// => next week.
|
||||
|
||||
let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap();
|
||||
let schedule = Schedule::Weekly {
|
||||
day: 6,
|
||||
|
|
@ -545,4 +562,152 @@ mod tests {
|
|||
"Sun 14:30"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trash_purge_job_kind_serde() {
|
||||
let job = JobKind::TrashPurge;
|
||||
let json = serde_json::to_string(&job).unwrap();
|
||||
assert_eq!(json, r#"{"type":"trash_purge"}"#);
|
||||
|
||||
let deserialized: JobKind = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(deserialized, JobKind::TrashPurge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trash_purge_scheduled_task_defaults() {
|
||||
let task = ScheduledTask {
|
||||
id: "trash_purge".to_string(),
|
||||
name: "Trash Purge".to_string(),
|
||||
kind: JobKind::TrashPurge,
|
||||
schedule: Schedule::Weekly {
|
||||
day: 0,
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
},
|
||||
enabled: false,
|
||||
last_run: None,
|
||||
next_run: None,
|
||||
last_status: None,
|
||||
running: false,
|
||||
last_job_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(task.id, "trash_purge");
|
||||
assert_eq!(task.name, "Trash Purge");
|
||||
assert!(matches!(task.kind, JobKind::TrashPurge));
|
||||
assert!(!task.enabled);
|
||||
assert!(!task.running);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_tasks_contain_trash_purge() {
|
||||
let cancel = CancellationToken::new();
|
||||
let config = Arc::new(RwLock::new(Config::default()));
|
||||
let job_queue = JobQueue::new(1, |_, _, _, _| tokio::spawn(async move {}));
|
||||
|
||||
let scheduler = TaskScheduler::new(job_queue, cancel, config, None);
|
||||
let tasks = scheduler.list_tasks().await;
|
||||
|
||||
let trash_task = tasks.iter().find(|t| t.id == "trash_purge");
|
||||
assert!(
|
||||
trash_task.is_some(),
|
||||
"trash_purge task should be in default tasks"
|
||||
);
|
||||
|
||||
let task = trash_task.unwrap();
|
||||
assert_eq!(task.id, "trash_purge");
|
||||
assert_eq!(task.name, "Trash Purge");
|
||||
assert!(matches!(task.kind, JobKind::TrashPurge));
|
||||
assert!(!task.enabled, "trash_purge should be disabled by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trash_purge_serde_roundtrip() {
|
||||
let task = ScheduledTask {
|
||||
id: "trash_purge".to_string(),
|
||||
name: "Trash Purge".to_string(),
|
||||
kind: JobKind::TrashPurge,
|
||||
schedule: Schedule::Weekly {
|
||||
day: 0,
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
},
|
||||
enabled: true,
|
||||
last_run: Some(Utc.with_ymd_and_hms(2025, 1, 15, 10, 0, 0).unwrap()),
|
||||
next_run: Some(Utc.with_ymd_and_hms(2025, 1, 19, 3, 0, 0).unwrap()),
|
||||
last_status: Some("completed".to_string()),
|
||||
running: false,
|
||||
last_job_id: Some(Uuid::now_v7()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&task).unwrap();
|
||||
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.id, "trash_purge");
|
||||
assert_eq!(deserialized.enabled, true);
|
||||
assert!(!deserialized.running);
|
||||
assert!(deserialized.last_job_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_job_kinds_serde() {
|
||||
let kinds: Vec<JobKind> = vec![
|
||||
JobKind::Scan { path: None },
|
||||
JobKind::Scan {
|
||||
path: Some(PathBuf::from("/test")),
|
||||
},
|
||||
JobKind::GenerateThumbnails { media_ids: vec![] },
|
||||
JobKind::VerifyIntegrity { media_ids: vec![] },
|
||||
JobKind::OrphanDetection,
|
||||
JobKind::CleanupThumbnails,
|
||||
JobKind::TrashPurge,
|
||||
];
|
||||
|
||||
for kind in kinds {
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
let deserialized: JobKind = serde_json::from_str(&json).unwrap();
|
||||
assert!(
|
||||
matches!(deserialized, JobKind::Scan { path: None })
|
||||
|| matches!(deserialized, JobKind::Scan { path: Some(_) })
|
||||
|| matches!(deserialized, JobKind::GenerateThumbnails { .. })
|
||||
|| matches!(deserialized, JobKind::VerifyIntegrity { .. })
|
||||
|| matches!(deserialized, JobKind::OrphanDetection)
|
||||
|| matches!(deserialized, JobKind::CleanupThumbnails)
|
||||
|| matches!(deserialized, JobKind::TrashPurge)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_serde_skips_runtime_fields() {
|
||||
let task = ScheduledTask {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
kind: JobKind::TrashPurge,
|
||||
schedule: Schedule::Daily {
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
},
|
||||
enabled: true,
|
||||
last_run: Some(Utc::now()),
|
||||
next_run: Some(Utc::now()),
|
||||
last_status: Some("running".to_string()),
|
||||
running: true,
|
||||
last_job_id: Some(Uuid::now_v7()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&task).unwrap();
|
||||
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.running, false);
|
||||
assert!(deserialized.last_job_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trash_config_defaults() {
|
||||
let config = TrashConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.retention_days, 30);
|
||||
assert!(!config.auto_empty);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use winnow::{
|
|||
token::{take_till, take_while},
|
||||
};
|
||||
|
||||
/// Represents a parsed search query.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SearchQuery {
|
||||
FullText(String),
|
||||
|
|
@ -39,6 +40,7 @@ pub enum SearchQuery {
|
|||
},
|
||||
}
|
||||
|
||||
/// Comparison operators for range queries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CompareOp {
|
||||
GreaterThan,
|
||||
|
|
@ -47,6 +49,7 @@ pub enum CompareOp {
|
|||
LessOrEqual,
|
||||
}
|
||||
|
||||
/// Date values for date-based queries.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DateValue {
|
||||
Today,
|
||||
|
|
@ -61,6 +64,7 @@ pub enum DateValue {
|
|||
DaysAgo(u32),
|
||||
}
|
||||
|
||||
/// Request for executing a search.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchRequest {
|
||||
pub query: SearchQuery,
|
||||
|
|
@ -68,12 +72,14 @@ pub struct SearchRequest {
|
|||
pub pagination: crate::model::Pagination,
|
||||
}
|
||||
|
||||
/// Results of a search operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResults {
|
||||
pub items: Vec<crate::model::MediaItem>,
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
/// Sorting options for search results.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
|
|
@ -139,19 +145,25 @@ fn parse_date_value(s: &str) -> Option<DateValue> {
|
|||
}
|
||||
|
||||
/// Parse size strings like "10MB", "1GB", "500KB" to bytes
|
||||
///
|
||||
/// Returns `None` if the input is invalid or if the value would overflow.
|
||||
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()
|
||||
let (num_str, multiplier): (&str, i64) = if let Some(n) = s.strip_suffix("GB")
|
||||
{
|
||||
(n, 1024 * 1024 * 1024)
|
||||
} else if let Some(n) = s.strip_suffix("MB") {
|
||||
(n, 1024 * 1024)
|
||||
} else if let Some(n) = s.strip_suffix("KB") {
|
||||
(n, 1024)
|
||||
} else if let Some(n) = s.strip_suffix('B') {
|
||||
(n, 1)
|
||||
} else {
|
||||
s.parse::<i64>().ok()
|
||||
}
|
||||
(s.as_str(), 1)
|
||||
};
|
||||
|
||||
let num: i64 = num_str.parse().ok()?;
|
||||
num.checked_mul(multiplier)
|
||||
}
|
||||
|
||||
fn field_match(input: &mut &str) -> ModalResult<SearchQuery> {
|
||||
|
|
@ -332,6 +344,22 @@ fn or_expr(input: &mut &str) -> ModalResult<SearchQuery> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parses a search query string into a structured query.
|
||||
///
|
||||
/// Supports full-text search, field matches, operators (AND/OR/NOT),
|
||||
/// prefixes, fuzzy matching, and type/tag filters.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `input` - Raw query string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Parsed query tree
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `SearchParse` error for invalid syntax
|
||||
pub fn parse_search_query(input: &str) -> crate::error::Result<SearchQuery> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{model::MediaId, users::UserId};
|
||||
use crate::{error::PinakesError, model::MediaId, users::UserId};
|
||||
|
||||
/// Unique identifier for a share.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ShareId(pub Uuid);
|
||||
|
||||
impl ShareId {
|
||||
/// Creates a new share ID.
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
|
@ -47,6 +48,7 @@ pub enum ShareTarget {
|
|||
}
|
||||
|
||||
impl ShareTarget {
|
||||
/// Returns the type of target being shared.
|
||||
pub fn target_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Media { .. } => "media",
|
||||
|
|
@ -56,6 +58,7 @@ impl ShareTarget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns the ID of the target being shared.
|
||||
pub fn target_id(&self) -> Uuid {
|
||||
match self {
|
||||
Self::Media { media_id } => media_id.0,
|
||||
|
|
@ -87,6 +90,7 @@ pub enum ShareRecipient {
|
|||
}
|
||||
|
||||
impl ShareRecipient {
|
||||
/// Returns the type of recipient.
|
||||
pub fn recipient_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::PublicLink { .. } => "public_link",
|
||||
|
|
@ -117,7 +121,7 @@ pub struct SharePermissions {
|
|||
}
|
||||
|
||||
impl SharePermissions {
|
||||
/// View-only permissions
|
||||
/// Creates a new share with view-only permissions.
|
||||
pub fn view_only() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
|
|
@ -125,7 +129,7 @@ impl SharePermissions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Download permissions (includes view)
|
||||
/// Creates a new share with download permissions.
|
||||
pub fn download() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
|
|
@ -134,7 +138,7 @@ impl SharePermissions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Edit permissions (includes view and download)
|
||||
/// Creates a new share with edit permissions.
|
||||
pub fn edit() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
|
|
@ -145,7 +149,7 @@ impl SharePermissions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Full permissions
|
||||
/// Creates a new share with full permissions.
|
||||
pub fn full() -> Self {
|
||||
Self {
|
||||
can_view: true,
|
||||
|
|
@ -157,7 +161,7 @@ impl SharePermissions {
|
|||
}
|
||||
}
|
||||
|
||||
/// Merge permissions (takes the most permissive of each)
|
||||
/// Merges two permission sets, taking the most permissive values.
|
||||
pub fn merge(&self, other: &Self) -> Self {
|
||||
Self {
|
||||
can_view: self.can_view || other.can_view,
|
||||
|
|
@ -246,17 +250,17 @@ impl Share {
|
|||
}
|
||||
}
|
||||
|
||||
/// Check if the share has expired.
|
||||
/// Checks if the share has expired.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at.map(|exp| exp < Utc::now()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if this is a public link share.
|
||||
/// Checks if this is a public link share.
|
||||
pub fn is_public(&self) -> bool {
|
||||
matches!(self.recipient, ShareRecipient::PublicLink { .. })
|
||||
}
|
||||
|
||||
/// Get the public token if this is a public link share.
|
||||
/// Returns the public token if this is a public link share.
|
||||
pub fn public_token(&self) -> Option<&str> {
|
||||
match &self.recipient {
|
||||
ShareRecipient::PublicLink { token, .. } => Some(token),
|
||||
|
|
@ -322,6 +326,7 @@ pub struct ShareActivity {
|
|||
}
|
||||
|
||||
impl ShareActivity {
|
||||
/// Creates a new share activity entry.
|
||||
pub fn new(share_id: ShareId, action: ShareActivityAction) -> Self {
|
||||
Self {
|
||||
id: Uuid::now_v7(),
|
||||
|
|
@ -334,16 +339,19 @@ impl ShareActivity {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sets the actor who performed the activity.
|
||||
pub fn with_actor(mut self, actor_id: UserId) -> Self {
|
||||
self.actor_id = Some(actor_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the IP address of the actor.
|
||||
pub fn with_ip(mut self, ip: &str) -> Self {
|
||||
self.actor_ip = Some(ip.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets additional details about the activity.
|
||||
pub fn with_details(mut self, details: &str) -> Self {
|
||||
self.details = Some(details.to_string());
|
||||
self
|
||||
|
|
@ -400,6 +408,7 @@ pub struct ShareNotification {
|
|||
}
|
||||
|
||||
impl ShareNotification {
|
||||
/// Creates a new share notification.
|
||||
pub fn new(
|
||||
user_id: UserId,
|
||||
share_id: ShareId,
|
||||
|
|
@ -416,20 +425,18 @@ impl ShareNotification {
|
|||
}
|
||||
}
|
||||
|
||||
/// Generate a random share token using UUID.
|
||||
/// Generates a random share token.
|
||||
pub fn generate_share_token() -> String {
|
||||
// Use UUIDv4 for random tokens - simple string representation
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// Hash a share password.
|
||||
pub fn hash_share_password(password: &str) -> String {
|
||||
// Use BLAKE3 for password hashing (in production, use Argon2)
|
||||
blake3::hash(password.as_bytes()).to_hex().to_string()
|
||||
/// Hashes a share password using Argon2id.
|
||||
pub fn hash_share_password(password: &str) -> Result<String, PinakesError> {
|
||||
crate::users::auth::hash_password(password)
|
||||
}
|
||||
|
||||
/// Verify a share password.
|
||||
/// Verifies a share password against an Argon2id hash.
|
||||
pub fn verify_share_password(password: &str, hash: &str) -> bool {
|
||||
let computed = hash_share_password(password);
|
||||
computed == hash
|
||||
crate::users::auth::verify_password(password, hash).unwrap_or(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,29 +142,13 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<()>;
|
||||
|
||||
// Batch operations (transactional where supported)
|
||||
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64> {
|
||||
let mut count = 0u64;
|
||||
for id in ids {
|
||||
self.delete_media(*id).await?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64>;
|
||||
|
||||
async fn batch_tag_media(
|
||||
&self,
|
||||
media_ids: &[MediaId],
|
||||
tag_ids: &[Uuid],
|
||||
) -> Result<u64> {
|
||||
let mut count = 0u64;
|
||||
for media_id in media_ids {
|
||||
for tag_id in tag_ids {
|
||||
self.tag_media(*media_id, *tag_id).await?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
) -> Result<u64>;
|
||||
|
||||
// Integrity
|
||||
async fn list_media_paths(
|
||||
|
|
@ -342,7 +326,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Ratings =====
|
||||
async fn rate_media(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
|
|
@ -358,7 +341,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<Option<Rating>>;
|
||||
async fn delete_rating(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
// ===== Comments =====
|
||||
async fn add_comment(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
|
|
@ -370,7 +352,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
-> Result<Vec<Comment>>;
|
||||
async fn delete_comment(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
// ===== Favorites =====
|
||||
async fn add_favorite(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
|
|
@ -392,7 +373,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
media_id: MediaId,
|
||||
) -> Result<bool>;
|
||||
|
||||
// ===== Share Links =====
|
||||
async fn create_share_link(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
@ -405,7 +385,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
async fn increment_share_views(&self, token: &str) -> Result<()>;
|
||||
async fn delete_share_link(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
// ===== Playlists =====
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
owner_id: UserId,
|
||||
|
|
@ -450,7 +429,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
new_position: i32,
|
||||
) -> Result<()>;
|
||||
|
||||
// ===== Analytics =====
|
||||
async fn record_usage_event(&self, event: &UsageEvent) -> Result<()>;
|
||||
async fn get_usage_events(
|
||||
&self,
|
||||
|
|
@ -477,7 +455,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<Option<f64>>;
|
||||
async fn cleanup_old_events(&self, before: DateTime<Utc>) -> Result<u64>;
|
||||
|
||||
// ===== Subtitles =====
|
||||
async fn add_subtitle(&self, subtitle: &Subtitle) -> Result<()>;
|
||||
async fn get_media_subtitles(
|
||||
&self,
|
||||
|
|
@ -490,7 +467,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
offset_ms: i64,
|
||||
) -> Result<()>;
|
||||
|
||||
// ===== External Metadata (Enrichment) =====
|
||||
async fn store_external_metadata(
|
||||
&self,
|
||||
meta: &ExternalMetadata,
|
||||
|
|
@ -501,7 +477,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
) -> Result<Vec<ExternalMetadata>>;
|
||||
async fn delete_external_metadata(&self, id: Uuid) -> Result<()>;
|
||||
|
||||
// ===== Transcode Sessions =====
|
||||
async fn create_transcode_session(
|
||||
&self,
|
||||
session: &TranscodeSession,
|
||||
|
|
@ -522,7 +497,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
before: DateTime<Utc>,
|
||||
) -> Result<u64>;
|
||||
|
||||
// ===== Session Management =====
|
||||
/// Create a new session in the database
|
||||
async fn create_session(&self, session: &SessionData) -> Result<()>;
|
||||
|
||||
|
|
@ -623,8 +597,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
pagination: &Pagination,
|
||||
) -> Result<Vec<MediaItem>>;
|
||||
|
||||
// ===== Managed Storage =====
|
||||
|
||||
/// Insert a media item that uses managed storage
|
||||
async fn insert_managed_media(&self, item: &MediaItem) -> Result<()>;
|
||||
|
||||
|
|
@ -658,8 +630,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Get managed storage statistics
|
||||
async fn managed_storage_stats(&self) -> Result<ManagedStorageStats>;
|
||||
|
||||
// ===== Sync Devices =====
|
||||
|
||||
/// Register a new sync device
|
||||
async fn register_device(
|
||||
&self,
|
||||
|
|
@ -695,8 +665,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Update the last_seen_at timestamp for a device
|
||||
async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()>;
|
||||
|
||||
// ===== Sync Log =====
|
||||
|
||||
/// Record a change in the sync log
|
||||
async fn record_sync_change(
|
||||
&self,
|
||||
|
|
@ -716,8 +684,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Clean up old sync log entries
|
||||
async fn cleanup_old_sync_log(&self, before: DateTime<Utc>) -> Result<u64>;
|
||||
|
||||
// ===== Device Sync State =====
|
||||
|
||||
/// Get sync state for a device and path
|
||||
async fn get_device_sync_state(
|
||||
&self,
|
||||
|
|
@ -737,8 +703,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
device_id: crate::sync::DeviceId,
|
||||
) -> Result<Vec<crate::sync::DeviceSyncState>>;
|
||||
|
||||
// ===== Upload Sessions (Chunked Uploads) =====
|
||||
|
||||
/// Create a new upload session
|
||||
async fn create_upload_session(
|
||||
&self,
|
||||
|
|
@ -773,8 +737,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Clean up expired upload sessions
|
||||
async fn cleanup_expired_uploads(&self) -> Result<u64>;
|
||||
|
||||
// ===== Sync Conflicts =====
|
||||
|
||||
/// Record a sync conflict
|
||||
async fn record_conflict(
|
||||
&self,
|
||||
|
|
@ -794,8 +756,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
resolution: crate::config::ConflictResolution,
|
||||
) -> Result<()>;
|
||||
|
||||
// ===== Enhanced Sharing =====
|
||||
|
||||
/// Create a new share
|
||||
async fn create_share(
|
||||
&self,
|
||||
|
|
@ -872,8 +832,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Clean up expired shares
|
||||
async fn cleanup_expired_shares(&self) -> Result<u64>;
|
||||
|
||||
// ===== Share Activity =====
|
||||
|
||||
/// Record share activity
|
||||
async fn record_share_activity(
|
||||
&self,
|
||||
|
|
@ -887,8 +845,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
pagination: &Pagination,
|
||||
) -> Result<Vec<crate::sharing::ShareActivity>>;
|
||||
|
||||
// ===== Share Notifications =====
|
||||
|
||||
/// Create a share notification
|
||||
async fn create_share_notification(
|
||||
&self,
|
||||
|
|
@ -907,8 +863,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Mark all notifications as read for a user
|
||||
async fn mark_all_notifications_read(&self, user_id: UserId) -> Result<()>;
|
||||
|
||||
// ===== File Management =====
|
||||
|
||||
/// Rename a media item (changes file_name and updates path accordingly).
|
||||
/// For external storage, this actually renames the file on disk.
|
||||
/// For managed storage, this only updates the metadata.
|
||||
|
|
@ -939,8 +893,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
Ok(results)
|
||||
}
|
||||
|
||||
// ===== Trash / Soft Delete =====
|
||||
|
||||
/// Soft delete a media item (set deleted_at timestamp).
|
||||
async fn soft_delete_media(&self, id: MediaId) -> Result<()>;
|
||||
|
||||
|
|
@ -960,8 +912,6 @@ pub trait StorageBackend: Send + Sync + 'static {
|
|||
/// Count items in trash.
|
||||
async fn count_trash(&self) -> Result<u64>;
|
||||
|
||||
// ===== Markdown Links (Obsidian-style) =====
|
||||
|
||||
/// Save extracted markdown links for a media item.
|
||||
/// This replaces any existing links for the source media.
|
||||
async fn save_markdown_links(
|
||||
|
|
|
|||
|
|
@ -583,8 +583,7 @@ impl StorageBackend for PostgresBackend {
|
|||
crate::storage::migrations::run_postgres_migrations(client).await
|
||||
}
|
||||
|
||||
// ---- Root directories ----
|
||||
|
||||
// Root directories
|
||||
async fn add_root_dir(&self, path: PathBuf) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -638,8 +637,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Media CRUD ----
|
||||
|
||||
// Media CRUD
|
||||
async fn insert_media(&self, item: &MediaItem) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -1032,8 +1030,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(count as u64)
|
||||
}
|
||||
|
||||
// ---- Batch Operations ----
|
||||
|
||||
// Batch Operations
|
||||
async fn batch_delete_media(&self, ids: &[MediaId]) -> Result<u64> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
|
|
@ -1089,8 +1086,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(rows)
|
||||
}
|
||||
|
||||
// ---- Tags ----
|
||||
|
||||
// Tags
|
||||
async fn create_tag(
|
||||
&self,
|
||||
name: &str,
|
||||
|
|
@ -1257,8 +1253,7 @@ impl StorageBackend for PostgresBackend {
|
|||
rows.iter().map(row_to_tag).collect()
|
||||
}
|
||||
|
||||
// ---- Collections ----
|
||||
|
||||
// Collections
|
||||
async fn create_collection(
|
||||
&self,
|
||||
name: &str,
|
||||
|
|
@ -1499,8 +1494,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(items)
|
||||
}
|
||||
|
||||
// ---- Search ----
|
||||
|
||||
// Search
|
||||
async fn search(&self, request: &SearchRequest) -> Result<SearchResults> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -1666,8 +1660,7 @@ impl StorageBackend for PostgresBackend {
|
|||
})
|
||||
}
|
||||
|
||||
// ---- Audit ----
|
||||
|
||||
// Audit
|
||||
async fn record_audit(&self, entry: &AuditEntry) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -1739,8 +1732,7 @@ impl StorageBackend for PostgresBackend {
|
|||
rows.iter().map(row_to_audit_entry).collect()
|
||||
}
|
||||
|
||||
// ---- Custom fields ----
|
||||
|
||||
// Custom fields
|
||||
async fn set_custom_field(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
@ -1821,8 +1813,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Duplicates ----
|
||||
|
||||
// Duplicates
|
||||
async fn find_duplicates(&self) -> Result<Vec<Vec<MediaItem>>> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -2007,8 +1998,7 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(groups)
|
||||
}
|
||||
|
||||
// ---- Database management ----
|
||||
|
||||
// Database management
|
||||
async fn database_stats(&self) -> Result<crate::storage::DatabaseStats> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -2524,7 +2514,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Ratings =====
|
||||
async fn rate_media(
|
||||
&self,
|
||||
user_id: crate::users::UserId,
|
||||
|
|
@ -2635,7 +2624,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
async fn add_comment(
|
||||
&self,
|
||||
user_id: crate::users::UserId,
|
||||
|
|
@ -2712,7 +2700,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Favorites =====
|
||||
async fn add_favorite(
|
||||
&self,
|
||||
user_id: crate::users::UserId,
|
||||
|
|
@ -2838,7 +2825,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(count > 0)
|
||||
}
|
||||
|
||||
// ===== Share Links =====
|
||||
async fn create_share_link(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
@ -2942,7 +2928,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Playlists =====
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
owner_id: crate::users::UserId,
|
||||
|
|
@ -3250,7 +3235,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Analytics =====
|
||||
async fn record_usage_event(
|
||||
&self,
|
||||
event: &crate::analytics::UsageEvent,
|
||||
|
|
@ -3540,7 +3524,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(affected)
|
||||
}
|
||||
|
||||
// ===== Subtitles =====
|
||||
async fn add_subtitle(
|
||||
&self,
|
||||
subtitle: &crate::subtitles::Subtitle,
|
||||
|
|
@ -3652,7 +3635,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== External Metadata (Enrichment) =====
|
||||
async fn store_external_metadata(
|
||||
&self,
|
||||
meta: &crate::enrichment::ExternalMetadata,
|
||||
|
|
@ -3742,7 +3724,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== Transcode Sessions =====
|
||||
async fn create_transcode_session(
|
||||
&self,
|
||||
session: &crate::transcode::TranscodeSession,
|
||||
|
|
@ -3930,8 +3911,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(affected)
|
||||
}
|
||||
|
||||
// ===== Session Management =====
|
||||
|
||||
async fn create_session(
|
||||
&self,
|
||||
session: &crate::storage::SessionData,
|
||||
|
|
@ -4666,10 +4645,6 @@ impl StorageBackend for PostgresBackend {
|
|||
items
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Managed Storage
|
||||
// =========================================================================
|
||||
|
||||
async fn insert_managed_media(&self, item: &MediaItem) -> Result<()> {
|
||||
let client = self.pool.get().await.map_err(|e| {
|
||||
PinakesError::Database(format!("failed to get connection: {e}"))
|
||||
|
|
@ -4967,10 +4942,6 @@ impl StorageBackend for PostgresBackend {
|
|||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sync Devices
|
||||
// =========================================================================
|
||||
|
||||
async fn register_device(
|
||||
&self,
|
||||
device: &crate::sync::SyncDevice,
|
||||
|
|
@ -5188,10 +5159,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sync Log
|
||||
// =========================================================================
|
||||
|
||||
async fn record_sync_change(
|
||||
&self,
|
||||
change: &crate::sync::SyncLogEntry,
|
||||
|
|
@ -5310,10 +5277,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Device Sync State
|
||||
// =========================================================================
|
||||
|
||||
async fn get_device_sync_state(
|
||||
&self,
|
||||
device_id: crate::sync::DeviceId,
|
||||
|
|
@ -5437,10 +5400,6 @@ impl StorageBackend for PostgresBackend {
|
|||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Upload Sessions
|
||||
// =========================================================================
|
||||
|
||||
async fn create_upload_session(
|
||||
&self,
|
||||
session: &crate::sync::UploadSession,
|
||||
|
|
@ -5618,10 +5577,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sync Conflicts
|
||||
// =========================================================================
|
||||
|
||||
async fn record_conflict(
|
||||
&self,
|
||||
conflict: &crate::sync::SyncConflict,
|
||||
|
|
@ -5737,10 +5692,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shares
|
||||
// =========================================================================
|
||||
|
||||
async fn create_share(
|
||||
&self,
|
||||
share: &crate::sharing::Share,
|
||||
|
|
@ -6050,10 +6001,10 @@ impl StorageBackend for PostgresBackend {
|
|||
|
||||
for share in shares {
|
||||
// Skip expired shares
|
||||
if let Some(exp) = share.expires_at {
|
||||
if exp < now {
|
||||
continue;
|
||||
}
|
||||
if let Some(exp) = share.expires_at
|
||||
&& exp < now
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
match (&share.recipient, user_id) {
|
||||
|
|
@ -6167,10 +6118,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Share Activity
|
||||
// =========================================================================
|
||||
|
||||
async fn record_share_activity(
|
||||
&self,
|
||||
activity: &crate::sharing::ShareActivity,
|
||||
|
|
@ -6244,10 +6191,6 @@ impl StorageBackend for PostgresBackend {
|
|||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Share Notifications
|
||||
// =========================================================================
|
||||
|
||||
async fn create_share_notification(
|
||||
&self,
|
||||
notification: &crate::sharing::ShareNotification,
|
||||
|
|
@ -6349,8 +6292,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ===== File Management =====
|
||||
|
||||
async fn rename_media(&self, id: MediaId, new_name: &str) -> Result<String> {
|
||||
// Validate the new name
|
||||
if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\')
|
||||
|
|
@ -6468,8 +6409,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(old_path)
|
||||
}
|
||||
|
||||
// ===== Trash / Soft Delete =====
|
||||
|
||||
async fn soft_delete_media(&self, id: MediaId) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
|
|
@ -6671,8 +6610,6 @@ impl StorageBackend for PostgresBackend {
|
|||
Ok(count as u64)
|
||||
}
|
||||
|
||||
// ===== Markdown Links (Obsidian-style) =====
|
||||
|
||||
async fn save_markdown_links(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -204,17 +204,16 @@ impl ChunkedUploadManager {
|
|||
let mut entries = fs::read_dir(&self.temp_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "upload").unwrap_or(false) {
|
||||
if let Ok(metadata) = fs::metadata(&path).await {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
let age = std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
if age > max_age {
|
||||
let _ = fs::remove_file(&path).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if path.extension().map(|e| e == "upload").unwrap_or(false)
|
||||
&& let Ok(metadata) = fs::metadata(&path).await
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
{
|
||||
let age = std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
if age > max_age {
|
||||
let _ = fs::remove_file(&path).await;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,22 +35,19 @@ impl fmt::Display for DeviceId {
|
|||
}
|
||||
|
||||
/// Type of sync device.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DeviceType {
|
||||
Desktop,
|
||||
Mobile,
|
||||
Tablet,
|
||||
Server,
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Default for DeviceType {
|
||||
fn default() -> Self {
|
||||
Self::Other
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DeviceType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
|
|
@ -353,7 +350,7 @@ impl UploadSession {
|
|||
timeout_hours: u64,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
let chunk_count = (expected_size + chunk_size - 1) / chunk_size;
|
||||
let chunk_count = expected_size.div_ceil(chunk_size);
|
||||
Self {
|
||||
id: Uuid::now_v7(),
|
||||
device_id,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@ use crate::{
|
|||
storage::DynStorageBackend,
|
||||
};
|
||||
|
||||
/// Creates a new tag.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `name` - Tag name
|
||||
/// * `parent_id` - Optional parent tag for hierarchy
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The created tag
|
||||
pub async fn create_tag(
|
||||
storage: &DynStorageBackend,
|
||||
name: &str,
|
||||
|
|
@ -14,6 +25,17 @@ pub async fn create_tag(
|
|||
storage.create_tag(name, parent_id).await
|
||||
}
|
||||
|
||||
/// Applies a tag to a media item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `media_id` - Media item to tag
|
||||
/// * `tag_id` - Tag to apply
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
pub async fn tag_media(
|
||||
storage: &DynStorageBackend,
|
||||
media_id: MediaId,
|
||||
|
|
@ -29,6 +51,17 @@ pub async fn tag_media(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Removes a tag from a media item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `media_id` - Media item to untag
|
||||
/// * `tag_id` - Tag to remove
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` on success
|
||||
pub async fn untag_media(
|
||||
storage: &DynStorageBackend,
|
||||
media_id: MediaId,
|
||||
|
|
@ -44,6 +77,16 @@ pub async fn untag_media(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Returns all descendants of a tag in the hierarchy.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `storage` - Storage backend
|
||||
/// * `tag_id` - Root tag to query
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// List of child tags
|
||||
pub async fn get_tag_tree(
|
||||
storage: &DynStorageBackend,
|
||||
tag_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ fn generate_raw_thumbnail(
|
|||
)));
|
||||
}
|
||||
|
||||
// The extracted preview is typically a JPEG — try loading it
|
||||
// The extracted preview is typically a JPEG; try loading it
|
||||
if temp_ppm.exists() {
|
||||
let result = image::open(&temp_ppm);
|
||||
let _ = std::fs::remove_file(&temp_ppm);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use crate::{
|
|||
pub struct UserId(pub Uuid);
|
||||
|
||||
impl UserId {
|
||||
/// Creates a new user ID.
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
|
@ -94,14 +95,17 @@ pub enum LibraryPermission {
|
|||
}
|
||||
|
||||
impl LibraryPermission {
|
||||
/// Checks if read permission is granted.
|
||||
pub fn can_read(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks if write permission is granted.
|
||||
pub fn can_write(&self) -> bool {
|
||||
matches!(self, Self::Write | Self::Admin)
|
||||
}
|
||||
|
||||
/// Checks if admin permission is granted.
|
||||
pub fn can_admin(&self) -> bool {
|
||||
matches!(self, Self::Admin)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
// Common test utilities shared across integration tests
|
||||
// Functions may appear unused in individual test binaries - they're used across
|
||||
// the test suite
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
use pinakes_core::{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pinakes_core::{
|
||||
model::*,
|
||||
storage::{StorageBackend, sqlite::SqliteBackend},
|
||||
};
|
||||
use pinakes_core::{model::*, storage::StorageBackend};
|
||||
|
||||
mod common;
|
||||
use common::{make_test_media, setup};
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ pub fn create_router_with_tls(
|
|||
)
|
||||
// Webhooks (read)
|
||||
.route("/webhooks", get(routes::webhooks::list_webhooks))
|
||||
// Auth endpoints (self-service) — login handled separately with stricter rate limit
|
||||
// Auth endpoints (self-service); login is handled separately with a stricter rate limit
|
||||
.route("/auth/logout", post(routes::auth::logout))
|
||||
.route("/auth/me", get(routes::auth::me))
|
||||
.route("/auth/revoke-all", post(routes::auth::revoke_all_sessions))
|
||||
|
|
|
|||
|
|
@ -721,8 +721,6 @@ impl From<pinakes_core::users::UserLibraryAccess> for UserLibraryResponse {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Social (Ratings, Comments, Favorites, Shares) =====
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RatingResponse {
|
||||
pub id: String,
|
||||
|
|
@ -816,8 +814,6 @@ impl From<pinakes_core::social::ShareLink> for ShareLinkResponse {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Playlists =====
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PlaylistResponse {
|
||||
pub id: String,
|
||||
|
|
@ -875,8 +871,6 @@ pub struct ReorderPlaylistRequest {
|
|||
pub new_position: i32,
|
||||
}
|
||||
|
||||
// ===== Analytics =====
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UsageEventResponse {
|
||||
pub id: String,
|
||||
|
|
@ -924,8 +918,6 @@ pub struct WatchProgressResponse {
|
|||
pub progress_secs: f64,
|
||||
}
|
||||
|
||||
// ===== Subtitles =====
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SubtitleResponse {
|
||||
pub id: String,
|
||||
|
|
@ -968,8 +960,6 @@ pub struct UpdateSubtitleOffsetRequest {
|
|||
pub offset_ms: i64,
|
||||
}
|
||||
|
||||
// ===== Enrichment =====
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExternalMetadataResponse {
|
||||
pub id: String,
|
||||
|
|
@ -1005,8 +995,6 @@ impl From<pinakes_core::enrichment::ExternalMetadata>
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Transcode =====
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TranscodeSessionResponse {
|
||||
pub id: String,
|
||||
|
|
@ -1039,8 +1027,6 @@ pub struct CreateTranscodeRequest {
|
|||
pub profile: String,
|
||||
}
|
||||
|
||||
// ===== Managed Storage / Upload =====
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UploadResponse {
|
||||
pub media_id: String,
|
||||
|
|
@ -1081,8 +1067,6 @@ impl From<pinakes_core::model::ManagedStorageStats>
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Sync =====
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterDeviceRequest {
|
||||
pub name: String,
|
||||
|
|
@ -1269,8 +1253,6 @@ pub struct AcknowledgeChangesRequest {
|
|||
pub cursor: i64,
|
||||
}
|
||||
|
||||
// ===== Enhanced Sharing =====
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateShareRequest {
|
||||
pub target_type: String,
|
||||
|
|
|
|||
|
|
@ -438,13 +438,123 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
},
|
||||
JobKind::Enrich { media_ids } => {
|
||||
// Enrichment job placeholder
|
||||
use pinakes_core::{
|
||||
enrichment::{
|
||||
MetadataEnricher,
|
||||
books::BookEnricher,
|
||||
lastfm::LastFmEnricher,
|
||||
musicbrainz::MusicBrainzEnricher,
|
||||
tmdb::TmdbEnricher,
|
||||
},
|
||||
media_type::MediaCategory,
|
||||
};
|
||||
|
||||
let enrich_cfg = &config.enrichment;
|
||||
let mut enrichers: Vec<Box<dyn MetadataEnricher>> = Vec::new();
|
||||
|
||||
if enrich_cfg.enabled {
|
||||
if enrich_cfg.sources.musicbrainz.enabled {
|
||||
enrichers.push(Box::new(MusicBrainzEnricher::new()));
|
||||
}
|
||||
if let (true, Some(key)) = (
|
||||
enrich_cfg.sources.tmdb.enabled,
|
||||
enrich_cfg.sources.tmdb.api_key.clone(),
|
||||
) {
|
||||
enrichers.push(Box::new(TmdbEnricher::new(key)));
|
||||
}
|
||||
if let (true, Some(key)) = (
|
||||
enrich_cfg.sources.lastfm.enabled,
|
||||
enrich_cfg.sources.lastfm.api_key.clone(),
|
||||
) {
|
||||
enrichers.push(Box::new(LastFmEnricher::new(key)));
|
||||
}
|
||||
// BookEnricher handles documents/epub. No dedicated config
|
||||
// key is required; the Google Books key is optional.
|
||||
enrichers.push(Box::new(BookEnricher::new(None)));
|
||||
}
|
||||
|
||||
let total = media_ids.len();
|
||||
let mut enriched: usize = 0;
|
||||
let mut errors: usize = 0;
|
||||
|
||||
'items: for media_id in media_ids {
|
||||
if cancel.is_cancelled() {
|
||||
break 'items;
|
||||
}
|
||||
let item = match storage.get_media(media_id).await {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
%media_id,
|
||||
error = %e,
|
||||
"enrich: failed to fetch media item"
|
||||
);
|
||||
errors += 1;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
// Select enrichers appropriate for this media category.
|
||||
let category = item.media_type.category();
|
||||
for enricher in &enrichers {
|
||||
let source = enricher.source();
|
||||
use pinakes_core::enrichment::EnrichmentSourceType;
|
||||
let applicable = match source {
|
||||
EnrichmentSourceType::MusicBrainz
|
||||
| EnrichmentSourceType::LastFm => {
|
||||
category == MediaCategory::Audio
|
||||
},
|
||||
EnrichmentSourceType::Tmdb => {
|
||||
category == MediaCategory::Video
|
||||
},
|
||||
EnrichmentSourceType::OpenLibrary
|
||||
| EnrichmentSourceType::GoogleBooks => {
|
||||
category == MediaCategory::Document
|
||||
},
|
||||
};
|
||||
if !applicable {
|
||||
continue;
|
||||
}
|
||||
|
||||
match enricher.enrich(&item).await {
|
||||
Ok(Some(meta)) => {
|
||||
if let Err(e) = storage.store_external_metadata(&meta).await
|
||||
{
|
||||
tracing::warn!(
|
||||
%media_id,
|
||||
%source,
|
||||
error = %e,
|
||||
"enrich: failed to store external metadata"
|
||||
);
|
||||
errors += 1;
|
||||
} else {
|
||||
enriched += 1;
|
||||
}
|
||||
},
|
||||
Ok(None) => {},
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
%media_id,
|
||||
%source,
|
||||
error = %e,
|
||||
"enrich: enricher returned error"
|
||||
);
|
||||
errors += 1;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JobQueue::complete(
|
||||
&jobs,
|
||||
job_id,
|
||||
serde_json::json!({"media_ids": media_ids.len(), "status": "not_implemented"}),
|
||||
)
|
||||
.await;
|
||||
&jobs,
|
||||
job_id,
|
||||
serde_json::json!({
|
||||
"total": total,
|
||||
"enriched": enriched,
|
||||
"errors": errors,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
},
|
||||
JobKind::CleanupAnalytics => {
|
||||
let before = chrono::Utc::now() - chrono::Duration::days(90);
|
||||
|
|
@ -460,6 +570,27 @@ async fn main() -> Result<()> {
|
|||
Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await,
|
||||
}
|
||||
},
|
||||
JobKind::TrashPurge => {
|
||||
let retention_days = config.trash.retention_days;
|
||||
let before = chrono::Utc::now()
|
||||
- chrono::Duration::days(retention_days as i64);
|
||||
|
||||
match storage.purge_old_trash(before).await {
|
||||
Ok(count) => {
|
||||
tracing::info!(count, "purged {} items from trash", count);
|
||||
JobQueue::complete(
|
||||
&jobs,
|
||||
job_id,
|
||||
serde_json::json!({"purged": count, "retention_days": retention_days}),
|
||||
)
|
||||
.await;
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to purge trash");
|
||||
JobQueue::fail(&jobs, job_id, e.to_string()).await;
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
drop(cancel);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -836,8 +836,6 @@ pub async fn get_media_count(
|
|||
Ok(Json(MediaCountResponse { count }))
|
||||
}
|
||||
|
||||
// ===== File Management Endpoints =====
|
||||
|
||||
pub async fn rename_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -978,8 +976,6 @@ pub async fn batch_move_media(
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Trash Endpoints =====
|
||||
|
||||
pub async fn soft_delete_media(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ use uuid::Uuid;
|
|||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ===== Response DTOs =====
|
||||
|
||||
/// Response for backlinks query
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BacklinksResponse {
|
||||
|
|
@ -200,8 +198,6 @@ pub struct UnresolvedLinksResponse {
|
|||
pub count: u64,
|
||||
}
|
||||
|
||||
// ===== Handlers =====
|
||||
|
||||
/// Get backlinks (incoming links) to a media item.
|
||||
///
|
||||
/// GET /api/v1/media/{id}/backlinks
|
||||
|
|
|
|||
|
|
@ -93,7 +93,12 @@ pub async fn create_share(
|
|||
let recipient = match req.recipient_type.as_str() {
|
||||
"public_link" => {
|
||||
let token = generate_share_token();
|
||||
let password_hash = req.password.as_ref().map(|p| hash_share_password(p));
|
||||
let password_hash = req
|
||||
.password
|
||||
.as_ref()
|
||||
.map(|p| hash_share_password(p))
|
||||
.transpose()
|
||||
.map_err(ApiError)?;
|
||||
ShareRecipient::PublicLink {
|
||||
token,
|
||||
password_hash,
|
||||
|
|
@ -409,35 +414,37 @@ pub async fn access_shared(
|
|||
.map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?;
|
||||
|
||||
// Check expiration
|
||||
if let Some(expires_at) = share.expires_at {
|
||||
if Utc::now() > expires_at {
|
||||
return Err(ApiError::not_found("Share has expired"));
|
||||
}
|
||||
if let Some(expires_at) = share.expires_at
|
||||
&& Utc::now() > expires_at
|
||||
{
|
||||
return Err(ApiError::not_found("Share has expired"));
|
||||
}
|
||||
|
||||
// Check password if required
|
||||
if let ShareRecipient::PublicLink { password_hash, .. } = &share.recipient {
|
||||
if let Some(hash) = password_hash {
|
||||
let provided_password = params
|
||||
.password
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::unauthorized("Password required"))?;
|
||||
if let ShareRecipient::PublicLink {
|
||||
password_hash: Some(hash),
|
||||
..
|
||||
} = &share.recipient
|
||||
{
|
||||
let provided_password = params
|
||||
.password
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::unauthorized("Password required"))?;
|
||||
|
||||
if !verify_share_password(provided_password, hash) {
|
||||
// Log failed attempt
|
||||
let activity = ShareActivity {
|
||||
id: Uuid::now_v7(),
|
||||
share_id: share.id,
|
||||
actor_id: None,
|
||||
actor_ip: Some(addr.ip().to_string()),
|
||||
action: ShareActivityAction::PasswordFailed,
|
||||
details: None,
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
let _ = state.storage.record_share_activity(&activity).await;
|
||||
if !verify_share_password(provided_password, hash) {
|
||||
// Log failed attempt
|
||||
let activity = ShareActivity {
|
||||
id: Uuid::now_v7(),
|
||||
share_id: share.id,
|
||||
actor_id: None,
|
||||
actor_ip: Some(addr.ip().to_string()),
|
||||
action: ShareActivityAction::PasswordFailed,
|
||||
details: None,
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
let _ = state.storage.record_share_activity(&activity).await;
|
||||
|
||||
return Err(ApiError::unauthorized("Invalid password"));
|
||||
}
|
||||
return Err(ApiError::unauthorized("Invalid password"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -473,8 +480,6 @@ pub async fn access_shared(
|
|||
Ok(Json(item.into()))
|
||||
},
|
||||
_ => {
|
||||
// For collections/tags, return a placeholder
|
||||
// Full implementation would return the collection contents
|
||||
Err(ApiError::bad_request(
|
||||
"Collection/tag sharing not yet fully implemented",
|
||||
))
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ pub struct ShareLinkQuery {
|
|||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
// ===== Ratings =====
|
||||
|
||||
pub async fn rate_media(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -46,8 +44,6 @@ pub async fn get_media_ratings(
|
|||
))
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
|
||||
pub async fn add_comment(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -80,8 +76,6 @@ pub async fn get_media_comments(
|
|||
))
|
||||
}
|
||||
|
||||
// ===== Favorites =====
|
||||
|
||||
pub async fn add_favorite(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
@ -120,8 +114,6 @@ pub async fn list_favorites(
|
|||
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
|
||||
}
|
||||
|
||||
// ===== Share Links =====
|
||||
|
||||
pub async fn create_share_link(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ pub async fn report_changes(
|
|||
if !config.sync.enabled {
|
||||
return Err(ApiError::bad_request("Sync is not enabled"));
|
||||
}
|
||||
let conflict_resolution = config.sync.default_conflict_resolution.clone();
|
||||
let conflict_resolution = config.sync.default_conflict_resolution;
|
||||
drop(config);
|
||||
|
||||
let mut accepted = Vec::new();
|
||||
|
|
@ -514,7 +514,7 @@ pub async fn create_upload(
|
|||
.ok_or_else(|| ApiError::unauthorized("Invalid device token"))?;
|
||||
|
||||
let chunk_size = req.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
|
||||
let chunk_count = (req.expected_size + chunk_size - 1) / chunk_size;
|
||||
let chunk_count = req.expected_size.div_ceil(chunk_size);
|
||||
let now = Utc::now();
|
||||
|
||||
let session = UploadSession {
|
||||
|
|
@ -784,10 +784,10 @@ pub async fn cancel_upload(
|
|||
})?;
|
||||
|
||||
// Clean up temp file if manager is available
|
||||
if let Some(ref manager) = state.chunked_upload_manager {
|
||||
if let Err(e) = manager.cancel(id).await {
|
||||
tracing::warn!(session_id = %id, error = %e, "failed to clean up temp file");
|
||||
}
|
||||
if let Some(ref manager) = state.chunked_upload_manager
|
||||
&& let Err(e) = manager.cancel(id).await
|
||||
{
|
||||
tracing::warn!(session_id = %id, error = %e, "failed to clean up temp file");
|
||||
}
|
||||
|
||||
session.status = UploadStatus::Cancelled;
|
||||
|
|
@ -827,38 +827,37 @@ pub async fn download_file(
|
|||
let file_size = metadata.len();
|
||||
|
||||
// Check for Range header
|
||||
if let Some(range_header) = headers.get(header::RANGE) {
|
||||
if let Ok(range_str) = range_header.to_str() {
|
||||
if let Some(range) = parse_range_header(range_str, file_size) {
|
||||
// Partial content response
|
||||
let (start, end) = range;
|
||||
let length = end - start + 1;
|
||||
if let Some(range_header) = headers.get(header::RANGE)
|
||||
&& let Ok(range_str) = range_header.to_str()
|
||||
&& let Some(range) = parse_range_header(range_str, file_size)
|
||||
{
|
||||
// Partial content response
|
||||
let (start, end) = range;
|
||||
let length = end - start + 1;
|
||||
|
||||
let file = tokio::fs::File::open(&item.path).await.map_err(|e| {
|
||||
ApiError::internal(format!("Failed to reopen file: {}", e))
|
||||
})?;
|
||||
let file = tokio::fs::File::open(&item.path).await.map_err(|e| {
|
||||
ApiError::internal(format!("Failed to reopen file: {}", e))
|
||||
})?;
|
||||
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
return Ok(
|
||||
return Ok(
|
||||
(
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
[
|
||||
(header::CONTENT_TYPE, item.media_type.mime_type()),
|
||||
(header::CONTENT_LENGTH, length.to_string()),
|
||||
(
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
[
|
||||
(header::CONTENT_TYPE, item.media_type.mime_type()),
|
||||
(header::CONTENT_LENGTH, length.to_string()),
|
||||
(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {}-{}/{}", start, end, file_size),
|
||||
),
|
||||
(header::ACCEPT_RANGES, "bytes".to_string()),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
}
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {}-{}/{}", start, end, file_size),
|
||||
),
|
||||
(header::ACCEPT_RANGES, "bytes".to_string()),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
|
||||
// Full content response
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ use pinakes_core::{
|
|||
ThumbnailConfig,
|
||||
TlsConfig,
|
||||
TranscodingConfig,
|
||||
TrashConfig,
|
||||
UiConfig,
|
||||
UserAccount,
|
||||
UserRole,
|
||||
|
|
@ -151,6 +152,7 @@ fn default_config() -> Config {
|
|||
managed_storage: ManagedStorageConfig::default(),
|
||||
sync: SyncConfig::default(),
|
||||
sharing: SharingConfig::default(),
|
||||
trash: TrashConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -298,10 +300,6 @@ async fn response_body(
|
|||
serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Existing tests (no auth)
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_media_empty() {
|
||||
let app = setup_app().await;
|
||||
|
|
@ -515,10 +513,6 @@ async fn test_user_duplicate_username() {
|
|||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Authentication tests
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unauthenticated_request_rejected() {
|
||||
let (app, ..) = setup_app_with_auth().await;
|
||||
|
|
@ -623,10 +617,6 @@ async fn test_logout() {
|
|||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Authorization / RBAC tests
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_viewer_cannot_access_editor_routes() {
|
||||
let (app, _, _, viewer_token) = setup_app_with_auth().await;
|
||||
|
|
@ -713,10 +703,6 @@ async fn test_admin_can_access_all() {
|
|||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Social
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rating_invalid_stars_zero() {
|
||||
let (app, _, editor_token, _) = setup_app_with_auth().await;
|
||||
|
|
@ -775,10 +761,6 @@ async fn test_favorites_list_empty() {
|
|||
assert!(body.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Playlists
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_crud() {
|
||||
let (app, _, editor_token, _) = setup_app_with_auth().await;
|
||||
|
|
@ -860,10 +842,6 @@ async fn test_playlist_empty_name() {
|
|||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Analytics
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_most_viewed_empty() {
|
||||
let (app, _, _, viewer_token) = setup_app_with_auth().await;
|
||||
|
|
@ -896,10 +874,6 @@ async fn test_record_event_and_query() {
|
|||
assert_eq!(body["recorded"], true);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Streaming/Transcode
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transcode_session_not_found() {
|
||||
let (app, _, _, viewer_token) = setup_app_with_auth().await;
|
||||
|
|
@ -951,10 +925,6 @@ async fn test_hls_segment_no_session() {
|
|||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Subtitles
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_subtitles_list() {
|
||||
let (app, _, _, viewer_token) = setup_app_with_auth().await;
|
||||
|
|
@ -974,10 +944,6 @@ async fn test_subtitles_list() {
|
|||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Health: public access test
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_public() {
|
||||
let (app, ..) = setup_app_with_auth().await;
|
||||
|
|
@ -988,10 +954,6 @@ async fn test_health_public() {
|
|||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Input validation & edge case tests
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_uuid_in_path() {
|
||||
let (app, _, _, viewer_token) = setup_app_with_auth().await;
|
||||
|
|
@ -1026,7 +988,7 @@ async fn test_share_link_expired() {
|
|||
// (need real media items). Verify the expire check logic works.
|
||||
let app = setup_app().await;
|
||||
|
||||
// First import a dummy file to get a media_id — but we can't without a real
|
||||
// First import a dummy file to get a media_id, but we can't without a real
|
||||
// file. So let's test the public share access endpoint with a nonexistent
|
||||
// token.
|
||||
let response = app
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ use pinakes_core::{
|
|||
ThumbnailConfig,
|
||||
TlsConfig,
|
||||
TranscodingConfig,
|
||||
TrashConfig,
|
||||
UiConfig,
|
||||
WebhookConfig,
|
||||
},
|
||||
|
|
@ -118,6 +119,7 @@ async fn setup_app_with_plugins()
|
|||
managed_storage: ManagedStorageConfig::default(),
|
||||
sync: SyncConfig::default(),
|
||||
sharing: SharingConfig::default(),
|
||||
trash: TrashConfig::default(),
|
||||
};
|
||||
|
||||
let job_queue =
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ pub fn BacklinksPanel(
|
|||
for backlink in &data.backlinks {
|
||||
BacklinkItemView {
|
||||
backlink: backlink.clone(),
|
||||
on_navigate: on_navigate.clone(),
|
||||
on_navigate: on_navigate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -328,7 +328,7 @@ pub fn OutgoingLinksPanel(
|
|||
for link in &data.links {
|
||||
OutgoingLinkItemView {
|
||||
link: link.clone(),
|
||||
on_navigate: on_navigate.clone(),
|
||||
on_navigate: on_navigate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -484,7 +484,7 @@ pub fn Detail(
|
|||
span { class: "detail-value mono", "{media.content_hash}" }
|
||||
}
|
||||
|
||||
// Editable fields — conditional by media category
|
||||
// Editable fields, conditional by media category
|
||||
div { class: "detail-field",
|
||||
label { class: "detail-label", "Title" }
|
||||
input {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ pub fn GraphView(
|
|||
ForceDirectedGraph {
|
||||
nodes: graph.nodes.clone(),
|
||||
edges: graph.edges.clone(),
|
||||
selected_node: selected_node.clone(),
|
||||
selected_node: selected_node,
|
||||
on_node_click: move |id: String| {
|
||||
selected_node.set(Some(id.clone()));
|
||||
},
|
||||
|
|
@ -525,7 +525,7 @@ fn ForceDirectedGraph(
|
|||
},
|
||||
onwheel: move |evt| {
|
||||
let delta = if evt.delta().strip_units().y < 0.0 { 1.1 } else { 0.9 };
|
||||
let new_zoom = (*zoom.read() * delta).max(0.1).min(5.0);
|
||||
let new_zoom = (*zoom.read() * delta).clamp(0.1, 5.0);
|
||||
zoom.set(new_zoom);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -80,12 +80,11 @@ pub fn MarkdownViewer(
|
|||
})();
|
||||
"#;
|
||||
|
||||
if let Ok(result) = eval(check_js).await {
|
||||
if let Some(target) = result.as_str() {
|
||||
if !target.is_empty() {
|
||||
handler.call(target.to_string());
|
||||
}
|
||||
}
|
||||
if let Ok(result) = eval(check_js).await
|
||||
&& let Some(target) = result.as_str()
|
||||
&& !target.is_empty()
|
||||
{
|
||||
handler.call(target.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue