perf: fix issues #1,#2,#3,#13 from performance audit
- #1 image_transcode: dedicated rayon pool + moka cache (no tokio blocking) - #2 ltree materialized paths: eliminate N+1 folder/file queries - #3 compute_content_hash: read blob_hash column instead of loading file into RAM - #13 event delegation + DocumentFragment: ~15 delegated listeners replace ~19k per-item listeners Backend: 82/82 tests pass, cargo check clean. Frontend: ui.js and app.js syntax-validated via Node.js.
This commit is contained in:
Generated
+32
-1
@@ -442,6 +442,16 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-deque"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.18"
|
||||||
@@ -1740,7 +1750,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oxicloud"
|
name = "oxicloud"
|
||||||
version = "0.3.5"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1768,6 +1778,7 @@ dependencies = [
|
|||||||
"moka",
|
"moka",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
"rayon",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2170,6 +2181,26 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"rayon-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon-core"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-deque",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ http-body-util = "0.1.3"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
|
rayon = "1.10"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
+66
-2
@@ -305,7 +305,7 @@ COMMENT ON TABLE carddav.contact_groups IS 'Contact groups within address books'
|
|||||||
COMMENT ON TABLE carddav.group_memberships IS 'Many-to-many relationship between contacts and groups';
|
COMMENT ON TABLE carddav.group_memberships IS 'Many-to-many relationship between contacts and groups';
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 4. STORAGE SCHEMA — 100% Blob Storage Model
|
-- 4. STORAGE SCHEMA — 100% Blob Storage Model + ltree hierarchy
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- All file/folder metadata lives here. Actual file content is stored
|
-- All file/folder metadata lives here. Actual file content is stored
|
||||||
-- as content-addressed blobs on the filesystem (.blobs/{prefix}/{hash}.blob).
|
-- as content-addressed blobs on the filesystem (.blobs/{prefix}/{hash}.blob).
|
||||||
@@ -313,9 +313,16 @@ COMMENT ON TABLE carddav.group_memberships IS 'Many-to-many relationship between
|
|||||||
-- files or in-memory HashMaps are used.
|
-- files or in-memory HashMaps are used.
|
||||||
-- No physical directories are created for user folders — they are
|
-- No physical directories are created for user folders — they are
|
||||||
-- virtual records in this schema.
|
-- virtual records in this schema.
|
||||||
|
--
|
||||||
|
-- Folder hierarchy uses PostgreSQL ltree for O(1) path lookups,
|
||||||
|
-- sub-tree queries, and ancestor/descendant operations — replacing
|
||||||
|
-- expensive recursive CTEs.
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
CREATE SCHEMA IF NOT EXISTS storage;
|
CREATE SCHEMA IF NOT EXISTS storage;
|
||||||
|
|
||||||
|
-- Enable ltree extension for hierarchical path operations
|
||||||
|
CREATE EXTENSION IF NOT EXISTS ltree;
|
||||||
|
|
||||||
-- Content-addressable blob index (dedup)
|
-- Content-addressable blob index (dedup)
|
||||||
-- One row per unique content hash; multiple storage.files rows may
|
-- One row per unique content hash; multiple storage.files rows may
|
||||||
-- reference the same blob via blob_hash → storage.blobs.hash.
|
-- reference the same blob via blob_hash → storage.blobs.hash.
|
||||||
@@ -334,11 +341,17 @@ CREATE INDEX IF NOT EXISTS idx_blobs_orphaned
|
|||||||
COMMENT ON TABLE storage.blobs IS 'Content-addressable blob dedup index — one row per unique SHA-256 hash';
|
COMMENT ON TABLE storage.blobs IS 'Content-addressable blob dedup index — one row per unique SHA-256 hash';
|
||||||
|
|
||||||
-- Virtual folders (replaces physical directories on disk)
|
-- Virtual folders (replaces physical directories on disk)
|
||||||
|
-- `path` is a materialized readable path (e.g. "Home - user1/Documents/Work")
|
||||||
|
-- maintained automatically by triggers on INSERT/UPDATE of name or parent_id.
|
||||||
|
-- `lpath` is an ltree label path using sanitized UUIDs for GiST-indexed
|
||||||
|
-- hierarchical queries (ancestor, descendant, sub-tree).
|
||||||
CREATE TABLE IF NOT EXISTS storage.folders (
|
CREATE TABLE IF NOT EXISTS storage.folders (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
parent_id UUID REFERENCES storage.folders(id) ON DELETE CASCADE,
|
parent_id UUID REFERENCES storage.folders(id) ON DELETE CASCADE,
|
||||||
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL DEFAULT '',
|
||||||
|
lpath ltree NOT NULL DEFAULT '',
|
||||||
is_trashed BOOLEAN NOT NULL DEFAULT FALSE,
|
is_trashed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
trashed_at TIMESTAMP WITH TIME ZONE,
|
trashed_at TIMESTAMP WITH TIME ZONE,
|
||||||
original_parent_id UUID,
|
original_parent_id UUID,
|
||||||
@@ -355,6 +368,57 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_unique_name_root
|
|||||||
CREATE INDEX IF NOT EXISTS idx_folders_user_id ON storage.folders(user_id);
|
CREATE INDEX IF NOT EXISTS idx_folders_user_id ON storage.folders(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_folders_parent_id ON storage.folders(parent_id);
|
CREATE INDEX IF NOT EXISTS idx_folders_parent_id ON storage.folders(parent_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_folders_trashed ON storage.folders(user_id, is_trashed);
|
CREATE INDEX IF NOT EXISTS idx_folders_trashed ON storage.folders(user_id, is_trashed);
|
||||||
|
-- ltree GiST index for sub-tree, ancestor, and descendant queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_lpath ON storage.folders USING gist (lpath);
|
||||||
|
-- B-tree index on path for exact path lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_path ON storage.folders (path text_pattern_ops);
|
||||||
|
|
||||||
|
-- ── ltree trigger: compute path & lpath on INSERT or UPDATE of name/parent_id ──
|
||||||
|
CREATE OR REPLACE FUNCTION storage.compute_folder_path()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
parent_path TEXT;
|
||||||
|
parent_lpath ltree;
|
||||||
|
my_label TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Convert UUID to a valid ltree label (replace '-' with '_')
|
||||||
|
my_label := replace(NEW.id::text, '-', '_');
|
||||||
|
|
||||||
|
IF NEW.parent_id IS NULL THEN
|
||||||
|
NEW.path := NEW.name;
|
||||||
|
NEW.lpath := my_label::ltree;
|
||||||
|
ELSE
|
||||||
|
SELECT path, lpath INTO parent_path, parent_lpath
|
||||||
|
FROM storage.folders WHERE id = NEW.parent_id;
|
||||||
|
|
||||||
|
NEW.path := parent_path || '/' || NEW.name;
|
||||||
|
NEW.lpath := parent_lpath || my_label::ltree;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_folders_path
|
||||||
|
BEFORE INSERT OR UPDATE OF name, parent_id ON storage.folders
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION storage.compute_folder_path();
|
||||||
|
|
||||||
|
-- ── Cascade trigger: when a folder's path/lpath changes, update all descendants ──
|
||||||
|
CREATE OR REPLACE FUNCTION storage.cascade_folder_path()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.path IS DISTINCT FROM NEW.path OR OLD.lpath IS DISTINCT FROM NEW.lpath THEN
|
||||||
|
-- Update all descendant folders (recursive via trigger re-fire)
|
||||||
|
UPDATE storage.folders
|
||||||
|
SET parent_id = parent_id -- no-op value change, but fires the BEFORE UPDATE trigger
|
||||||
|
WHERE parent_id = NEW.id;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_folders_cascade_path
|
||||||
|
AFTER UPDATE OF path, lpath ON storage.folders
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION storage.cascade_folder_path();
|
||||||
|
|
||||||
-- Files as references to content-addressable blobs
|
-- Files as references to content-addressable blobs
|
||||||
CREATE TABLE IF NOT EXISTS storage.files (
|
CREATE TABLE IF NOT EXISTS storage.files (
|
||||||
@@ -394,6 +458,6 @@ CREATE OR REPLACE VIEW storage.trash_items AS
|
|||||||
original_parent_id, created_at
|
original_parent_id, created_at
|
||||||
FROM storage.folders WHERE is_trashed = TRUE;
|
FROM storage.folders WHERE is_trashed = TRUE;
|
||||||
|
|
||||||
COMMENT ON TABLE storage.folders IS 'Virtual folder hierarchy — no physical directories on disk';
|
COMMENT ON TABLE storage.folders IS 'Virtual folder hierarchy with ltree — no physical directories on disk';
|
||||||
COMMENT ON TABLE storage.files IS 'File metadata pointing to content-addressable blobs';
|
COMMENT ON TABLE storage.files IS 'File metadata pointing to content-addressable blobs';
|
||||||
COMMENT ON VIEW storage.trash_items IS 'Unified view of all trashed files and folders';
|
COMMENT ON VIEW storage.trash_items IS 'Unified view of all trashed files and folders';
|
||||||
|
|||||||
@@ -159,9 +159,6 @@ pub trait DedupPort: Send + Sync + 'static {
|
|||||||
/// Returns `true` if the blob was deleted (ref_count reached 0).
|
/// Returns `true` if the blob was deleted (ref_count reached 0).
|
||||||
async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError>;
|
async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError>;
|
||||||
|
|
||||||
/// Calculate SHA-256 hash of in-memory content.
|
|
||||||
fn hash_bytes(&self, content: &[u8]) -> String;
|
|
||||||
|
|
||||||
/// Calculate SHA-256 hash of a file (streaming).
|
/// Calculate SHA-256 hash of a file (streaming).
|
||||||
async fn hash_file(&self, path: &Path) -> Result<String, DomainError>;
|
async fn hash_file(&self, path: &Path) -> Result<String, DomainError>;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ pub trait FileReadPort: Send + Sync + 'static {
|
|||||||
/// Gets the parent folder ID from a path (WebDAV).
|
/// Gets the parent folder ID from a path (WebDAV).
|
||||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
|
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
|
||||||
|
|
||||||
|
/// Gets the content-addressable blob hash for a file (O(1) DB lookup).
|
||||||
|
///
|
||||||
|
/// Returns the SHA-256 hash stored in `storage.files.blob_hash`.
|
||||||
|
/// Used for dedup reference tracking without loading file content.
|
||||||
|
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError>;
|
||||||
|
|
||||||
/// Find a file by its logical path (folder_name/.../file_name).
|
/// Find a file by its logical path (folder_name/.../file_name).
|
||||||
///
|
///
|
||||||
/// The default implementation falls back to `list_files(None)` + linear
|
/// The default implementation falls back to `list_files(None)` + linear
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use tracing::{debug, error, info, warn};
|
|||||||
/// Service for file management operations (move, delete).
|
/// Service for file management operations (move, delete).
|
||||||
///
|
///
|
||||||
/// The `delete_with_cleanup` method internalises:
|
/// The `delete_with_cleanup` method internalises:
|
||||||
/// 1. Content-hash computation for dedup tracking
|
/// 1. Blob-hash lookup for dedup tracking (O(1) DB read)
|
||||||
/// 2. Trash-first soft-delete
|
/// 2. Trash-first soft-delete
|
||||||
/// 3. Fallback to permanent delete
|
/// 3. Fallback to permanent delete
|
||||||
/// 4. Dedup reference-count decrement
|
/// 4. Dedup reference-count decrement
|
||||||
@@ -57,18 +57,22 @@ impl FileManagementService {
|
|||||||
|
|
||||||
// ── private helpers ──────────────────────────────────────────
|
// ── private helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
/// Compute the content hash for dedup tracking. Returns `None` on failure.
|
/// Look up the blob hash from the database. Returns `None` when
|
||||||
async fn compute_content_hash(&self, id: &str) -> Option<String> {
|
/// dedup is inactive or the file is not found.
|
||||||
let dedup = self.dedup_service.as_ref()?;
|
///
|
||||||
|
/// This is O(1) — a single `SELECT blob_hash` by primary key.
|
||||||
|
/// It replaces the old `compute_content_hash` which loaded the
|
||||||
|
/// entire file into RAM just to re-derive the same hash.
|
||||||
|
async fn lookup_blob_hash(&self, id: &str) -> Option<String> {
|
||||||
|
self.dedup_service.as_ref()?; // dedup must be active
|
||||||
let file_read = self.file_read.as_ref()?;
|
let file_read = self.file_read.as_ref()?;
|
||||||
match file_read.get_file_content(id).await {
|
match file_read.get_blob_hash(id).await {
|
||||||
Ok(content) => {
|
Ok(hash) => {
|
||||||
let hash = dedup.hash_bytes(&content);
|
info!("🔗 DEDUP: File {} has blob hash: {}", id, &hash[..12]);
|
||||||
debug!("🔗 DEDUP: File {} has content hash: {}", id, &hash[..12]);
|
|
||||||
Some(hash)
|
Some(hash)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
debug!("Could not read file content for dedup: {}", e);
|
warn!("Could not get blob hash for dedup: {}", e);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,8 +181,8 @@ impl FileManagementUseCase for FileManagementService {
|
|||||||
|
|
||||||
/// Smart delete: trash-first with dedup reference cleanup.
|
/// Smart delete: trash-first with dedup reference cleanup.
|
||||||
async fn delete_with_cleanup(&self, id: &str, user_id: &str) -> Result<bool, DomainError> {
|
async fn delete_with_cleanup(&self, id: &str, user_id: &str) -> Result<bool, DomainError> {
|
||||||
// Step 1: Compute content hash for dedup tracking
|
// Step 1: Look up blob hash for dedup tracking (O(1) DB read)
|
||||||
let content_hash = self.compute_content_hash(id).await;
|
let content_hash = self.lookup_blob_hash(id).await;
|
||||||
|
|
||||||
// Step 2: Try trash (soft delete)
|
// Step 2: Try trash (soft delete)
|
||||||
if let Some(trash) = &self.trash_service {
|
if let Some(trash) = &self.trash_service {
|
||||||
|
|||||||
@@ -479,6 +479,10 @@ mod tests {
|
|||||||
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ impl FileReadPort for MockFileRepository {
|
|||||||
async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result<String, DomainError> {
|
async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result<String, DomainError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_blob_hash(&self, _file_id: &str) -> std::result::Result<String, DomainError> {
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
+4
-4
@@ -131,6 +131,10 @@ impl FileReadPort for StubFileReadPort {
|
|||||||
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
||||||
Ok("root".to_string())
|
Ok("root".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -709,10 +713,6 @@ impl DedupPort for StubDedupPort {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_bytes(&self, _content: &[u8]) -> String {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn hash_file(&self, _path: &Path) -> Result<String, DomainError> {
|
async fn hash_file(&self, _path: &Path) -> Result<String, DomainError> {
|
||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
//! Implements `FileReadPort` using:
|
//! Implements `FileReadPort` using:
|
||||||
//! - `storage.files` table for metadata lookups
|
//! - `storage.files` table for metadata lookups
|
||||||
//! - `DedupPort` for reading content-addressable blobs from the filesystem
|
//! - `DedupPort` for reading content-addressable blobs from the filesystem
|
||||||
|
//!
|
||||||
|
//! File paths are resolved by JOINing with `storage.folders.path` (the
|
||||||
|
//! materialized path column), so no recursive CTEs or N+1 queries are needed.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -15,18 +18,14 @@ use crate::application::ports::dedup_ports::DedupPort;
|
|||||||
use crate::application::ports::storage_ports::FileReadPort;
|
use crate::application::ports::storage_ports::FileReadPort;
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::entities::file::File;
|
use crate::domain::entities::file::File;
|
||||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
|
||||||
use crate::domain::services::path_service::StoragePath;
|
use crate::domain::services::path_service::StoragePath;
|
||||||
|
|
||||||
use super::folder_db_repository::FolderDbRepository;
|
|
||||||
|
|
||||||
/// File read repository backed by PostgreSQL metadata + blob storage.
|
/// File read repository backed by PostgreSQL metadata + blob storage.
|
||||||
pub struct FileBlobReadRepository {
|
pub struct FileBlobReadRepository {
|
||||||
pool: Arc<PgPool>,
|
pool: Arc<PgPool>,
|
||||||
dedup: Arc<dyn DedupPort>,
|
dedup: Arc<dyn DedupPort>,
|
||||||
folder_repo: Arc<FolderDbRepository>,
|
|
||||||
/// Lightweight cache: file_id → blob_hash.
|
/// Lightweight cache: file_id → blob_hash.
|
||||||
/// Populated by `get_file()`, consumed by `get_blob_hash()`.
|
/// Populated by `get_file()`, consumed by `resolve_blob_hash()`.
|
||||||
/// Avoids an extra SQL round-trip on the hot download path.
|
/// Avoids an extra SQL round-trip on the hot download path.
|
||||||
hash_cache: std::sync::Mutex<HashMap<String, String>>,
|
hash_cache: std::sync::Mutex<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
@@ -35,42 +34,35 @@ impl FileBlobReadRepository {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
pool: Arc<PgPool>,
|
pool: Arc<PgPool>,
|
||||||
dedup: Arc<dyn DedupPort>,
|
dedup: Arc<dyn DedupPort>,
|
||||||
folder_repo: Arc<FolderDbRepository>,
|
_folder_repo: Arc<super::folder_db_repository::FolderDbRepository>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
dedup,
|
dedup,
|
||||||
folder_repo,
|
|
||||||
hash_cache: std::sync::Mutex::new(HashMap::new()),
|
hash_cache: std::sync::Mutex::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a virtual StoragePath for a file.
|
/// Build a `StoragePath` from the materialized folder path + file name.
|
||||||
async fn build_file_path(
|
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
||||||
&self,
|
match folder_path {
|
||||||
folder_id: Option<&str>,
|
Some(fp) if !fp.is_empty() => StoragePath::from_string(&format!("{fp}/{file_name}")),
|
||||||
file_name: &str,
|
_ => StoragePath::from_string(file_name),
|
||||||
) -> Result<StoragePath, DomainError> {
|
|
||||||
if let Some(fid) = folder_id {
|
|
||||||
let folder_path = self.folder_repo.get_folder_path(fid).await?;
|
|
||||||
Ok(folder_path.join(file_name))
|
|
||||||
} else {
|
|
||||||
Ok(StoragePath::from_string(file_name))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn row_to_file(
|
fn row_to_file(
|
||||||
&self,
|
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
folder_id: Option<String>,
|
folder_id: Option<String>,
|
||||||
|
folder_path: Option<String>,
|
||||||
size: i64,
|
size: i64,
|
||||||
mime_type: String,
|
mime_type: String,
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
modified_at: i64,
|
modified_at: i64,
|
||||||
) -> Result<File, DomainError> {
|
) -> Result<File, DomainError> {
|
||||||
let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?;
|
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
|
||||||
File::with_timestamps(
|
File::with_timestamps(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -84,9 +76,9 @@ impl FileBlobReadRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the blob hash for a file.
|
/// Resolve the blob hash for a file (internal helper).
|
||||||
/// Checks the in-memory cache first (populated by `get_file`).
|
/// Checks the in-memory cache first (populated by `get_file`).
|
||||||
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
async fn resolve_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
||||||
// Fast path: already cached from a prior get_file call
|
// Fast path: already cached from a prior get_file call
|
||||||
if let Some(hash) = self.hash_cache.lock().unwrap().remove(file_id) {
|
if let Some(hash) = self.hash_cache.lock().unwrap().remove(file_id) {
|
||||||
return Ok(hash);
|
return Ok(hash);
|
||||||
@@ -109,23 +101,26 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
let row = sqlx::query_as::<
|
let row = sqlx::query_as::<
|
||||||
_,
|
_,
|
||||||
(
|
(
|
||||||
String,
|
String, // id
|
||||||
String,
|
String, // name
|
||||||
Option<String>,
|
Option<String>, // folder_id
|
||||||
i64,
|
Option<String>, // folder path
|
||||||
String,
|
i64, // size
|
||||||
i64,
|
String, // mime_type
|
||||||
i64,
|
i64, // created_at
|
||||||
String,
|
i64, // updated_at
|
||||||
|
String, // blob_hash
|
||||||
),
|
),
|
||||||
>(
|
>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
fi.size, fi.mime_type,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
blob_hash
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||||
FROM storage.files
|
fi.blob_hash
|
||||||
WHERE id = $1::uuid AND NOT is_trashed
|
FROM storage.files fi
|
||||||
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fi.id = $1::uuid AND NOT fi.is_trashed
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -139,23 +134,24 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
self.hash_cache
|
self.hash_cache
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(id.to_string(), row.7.clone());
|
.insert(id.to_string(), row.8.clone());
|
||||||
|
|
||||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
Self::row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7)
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
|
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
|
||||||
let rows: Vec<(String, String, Option<String>, i64, String, i64, i64)> =
|
let rows: Vec<(String, String, Option<String>, Option<String>, i64, String, i64, i64)> =
|
||||||
if let Some(fid) = folder_id {
|
if let Some(fid) = folder_id {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
fi.size, fi.mime_type,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
FROM storage.files
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint
|
||||||
WHERE folder_id = $1::uuid AND NOT is_trashed
|
FROM storage.files fi
|
||||||
ORDER BY name
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
|
||||||
|
ORDER BY fi.name
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(fid)
|
.bind(fid)
|
||||||
@@ -164,12 +160,14 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
fi.size, fi.mime_type,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
FROM storage.files
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint
|
||||||
WHERE folder_id IS NULL AND NOT is_trashed
|
FROM storage.files fi
|
||||||
ORDER BY name
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
|
||||||
|
ORDER BY fi.name
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(self.pool.as_ref())
|
.fetch_all(self.pool.as_ref())
|
||||||
@@ -177,38 +175,19 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
}
|
}
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
|
||||||
|
|
||||||
// ── N+1 fix: resolve the folder path ONCE (all rows share
|
rows.into_iter()
|
||||||
// the same folder_id when listing a specific folder). ──
|
.map(|(id, name, fid, fpath, size, mime, ca, ma)| {
|
||||||
let shared_folder_path = if let Some(fid) = folder_id {
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma)
|
||||||
Some(self.folder_repo.get_folder_path(fid).await?)
|
})
|
||||||
} else {
|
.collect()
|
||||||
None
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let mut files = Vec::with_capacity(rows.len());
|
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
||||||
for (id, name, fid, size, mime, ca, ma) in rows {
|
self.resolve_blob_hash(file_id).await
|
||||||
let storage_path = match &shared_folder_path {
|
|
||||||
Some(fp) => fp.join(&name),
|
|
||||||
None => StoragePath::from_string(&name),
|
|
||||||
};
|
|
||||||
let file = File::with_timestamps(
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
storage_path,
|
|
||||||
size as u64,
|
|
||||||
mime,
|
|
||||||
fid,
|
|
||||||
ca as u64,
|
|
||||||
ma as u64,
|
|
||||||
)
|
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))?;
|
|
||||||
files.push(file);
|
|
||||||
}
|
|
||||||
Ok(files)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
|
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
|
||||||
let blob_hash = self.get_blob_hash(id).await?;
|
let blob_hash = self.resolve_blob_hash(id).await?;
|
||||||
self.dedup.read_blob(&blob_hash).await
|
self.dedup.read_blob(&blob_hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +197,7 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||||
// True streaming: reads the blob file in 64 KB chunks.
|
// True streaming: reads the blob file in 64 KB chunks.
|
||||||
// Memory usage is ~64 KB regardless of file size.
|
// Memory usage is ~64 KB regardless of file size.
|
||||||
let blob_hash = self.get_blob_hash(id).await?;
|
let blob_hash = self.resolve_blob_hash(id).await?;
|
||||||
let stream = self.dedup.read_blob_stream(&blob_hash).await?;
|
let stream = self.dedup.read_blob_stream(&blob_hash).await?;
|
||||||
Ok(Box::new(stream))
|
Ok(Box::new(stream))
|
||||||
}
|
}
|
||||||
@@ -231,7 +210,7 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||||
// True range streaming: seeks to `start` and reads only the requested range.
|
// True range streaming: seeks to `start` and reads only the requested range.
|
||||||
// A 1 MB range on a 1 GB file uses ~64 KB of RAM.
|
// A 1 MB range on a 1 GB file uses ~64 KB of RAM.
|
||||||
let blob_hash = self.get_blob_hash(id).await?;
|
let blob_hash = self.resolve_blob_hash(id).await?;
|
||||||
let stream = self
|
let stream = self
|
||||||
.dedup
|
.dedup
|
||||||
.read_blob_range_stream(&blob_hash, start, end)
|
.read_blob_range_stream(&blob_hash, start, end)
|
||||||
@@ -242,16 +221,17 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
|
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
|
||||||
// For RPi targets, mmap is less beneficial than streaming.
|
// For RPi targets, mmap is less beneficial than streaming.
|
||||||
// Keep as a fallback that loads content for small/medium files.
|
// Keep as a fallback that loads content for small/medium files.
|
||||||
let blob_hash = self.get_blob_hash(id).await?;
|
let blob_hash = self.resolve_blob_hash(id).await?;
|
||||||
self.dedup.read_blob_bytes(&blob_hash).await
|
self.dedup.read_blob_bytes(&blob_hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
||||||
let row = sqlx::query_as::<_, (String, Option<String>)>(
|
let row = sqlx::query_as::<_, (String, Option<String>)>(
|
||||||
r#"
|
r#"
|
||||||
SELECT name, folder_id::text
|
SELECT fi.name, fo.path
|
||||||
FROM storage.files
|
FROM storage.files fi
|
||||||
WHERE id = $1::uuid AND NOT is_trashed
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fi.id = $1::uuid AND NOT fi.is_trashed
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -260,11 +240,10 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))?
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("File", id))?;
|
.ok_or_else(|| DomainError::not_found("File", id))?;
|
||||||
|
|
||||||
self.build_file_path(row.1.as_deref(), &row.0).await
|
Ok(Self::make_file_path(row.1.as_deref(), &row.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
|
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
|
||||||
// Walk the path to find the parent folder, searching by folder names
|
|
||||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
|
||||||
@@ -272,49 +251,30 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
return Err(DomainError::not_found("Folder", "empty path"));
|
return Err(DomainError::not_found("Folder", "empty path"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// For path "a/b/c/file.txt", the parent folder path is "a/b/c"
|
// For a path like "Home - user/Docs/file.txt", the parent folder path
|
||||||
// But we don't know which part is folders vs filename.
|
// is everything except the last segment: "Home - user/Docs"
|
||||||
// Walk segments trying to find matching folders.
|
// We try the longest folder path first.
|
||||||
let mut current_parent: Option<String> = None;
|
let folder_path = segments[..segments.len() - 1].join("/");
|
||||||
|
|
||||||
for segment in &segments {
|
if folder_path.is_empty() {
|
||||||
let row = if let Some(ref pid) = current_parent {
|
return Err(DomainError::not_found(
|
||||||
sqlx::query_as::<_, (String,)>(
|
"Folder",
|
||||||
r#"
|
format!("parent for path: {path}"),
|
||||||
SELECT id::text FROM storage.folders
|
));
|
||||||
WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(segment)
|
|
||||||
.bind(pid)
|
|
||||||
.fetch_optional(self.pool.as_ref())
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, (String,)>(
|
|
||||||
r#"
|
|
||||||
SELECT id::text FROM storage.folders
|
|
||||||
WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(segment)
|
|
||||||
.fetch_optional(self.pool.as_ref())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
Some(r) => current_parent = Some(r.0),
|
|
||||||
None => break, // This segment is not a folder → it's the filename
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
current_parent
|
sqlx::query_scalar::<_, String>(
|
||||||
.ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}")))
|
"SELECT id::text FROM storage.folders WHERE path = $1 AND NOT is_trashed",
|
||||||
|
)
|
||||||
|
.bind(&folder_path)
|
||||||
|
.fetch_optional(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("parent lookup: {e}")))?
|
||||||
|
.ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct SQL lookup: split path into folder segments + filename,
|
/// Direct SQL lookup using materialized folder paths.
|
||||||
/// walk the folder hierarchy, then match the file by name + folder_id.
|
/// O(1) query instead of O(depth) folder walk.
|
||||||
/// O(depth) queries instead of O(total_files).
|
|
||||||
async fn find_file_by_path(&self, path: &str) -> Result<Option<File>, DomainError> {
|
async fn find_file_by_path(&self, path: &str) -> Result<Option<File>, DomainError> {
|
||||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
@@ -323,62 +283,40 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last segment is the filename, preceding segments are folders
|
// Last segment is the filename, preceding segments are the folder path
|
||||||
let filename = segments[segments.len() - 1];
|
let filename = segments[segments.len() - 1];
|
||||||
let folder_segments = &segments[..segments.len() - 1];
|
let folder_path = segments[..segments.len() - 1].join("/");
|
||||||
|
|
||||||
// Walk folder hierarchy to find parent folder_id
|
let row = if folder_path.is_empty() {
|
||||||
let mut current_parent: Option<String> = None;
|
// File at root level (no parent folder)
|
||||||
for segment in folder_segments {
|
sqlx::query_as::<_, (String, String, Option<String>, Option<String>, i64, String, i64, i64)>(
|
||||||
let row = if let Some(ref pid) = current_parent {
|
|
||||||
sqlx::query_scalar::<_, String>(
|
|
||||||
"SELECT id::text FROM storage.folders WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed",
|
|
||||||
)
|
|
||||||
.bind(segment)
|
|
||||||
.bind(pid)
|
|
||||||
.fetch_optional(self.pool.as_ref())
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
sqlx::query_scalar::<_, String>(
|
|
||||||
"SELECT id::text FROM storage.folders WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed",
|
|
||||||
)
|
|
||||||
.bind(segment)
|
|
||||||
.fetch_optional(self.pool.as_ref())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
Some(id) => current_parent = Some(id),
|
|
||||||
None => return Ok(None), // Folder not found → file doesn't exist at this path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now find the file by name + folder_id
|
|
||||||
let row = if let Some(ref fid) = current_parent {
|
|
||||||
sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
fi.size, fi.mime_type,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
FROM storage.files
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint
|
||||||
WHERE name = $1 AND folder_id = $2::uuid AND NOT is_trashed
|
FROM storage.files fi
|
||||||
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fi.name = $1 AND fi.folder_id IS NULL AND NOT fi.is_trashed
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(filename)
|
.bind(filename)
|
||||||
.bind(fid)
|
|
||||||
.fetch_optional(self.pool.as_ref())
|
.fetch_optional(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
// File inside a folder — look up by folder path + filename
|
||||||
|
sqlx::query_as::<_, (String, String, Option<String>, Option<String>, i64, String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
fi.size, fi.mime_type,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||||
FROM storage.files
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint
|
||||||
WHERE name = $1 AND folder_id IS NULL AND NOT is_trashed
|
FROM storage.files fi
|
||||||
|
JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||||
|
WHERE fo.path = $1 AND fi.name = $2 AND NOT fi.is_trashed
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(&folder_path)
|
||||||
.bind(filename)
|
.bind(filename)
|
||||||
.fetch_optional(self.pool.as_ref())
|
.fetch_optional(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
@@ -386,9 +324,9 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("find file: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("find file: {e}")))?;
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
Some(r) => Ok(Some(
|
Some(r) => Ok(Some(Self::row_to_file(
|
||||||
self.row_to_file(r.0, r.1, r.2, r.3, r.4, r.5, r.6).await?,
|
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7,
|
||||||
)),
|
)?)),
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
//! Implements `FileWritePort` using:
|
//! Implements `FileWritePort` using:
|
||||||
//! - `storage.files` table for metadata
|
//! - `storage.files` table for metadata
|
||||||
//! - `DedupPort` for content-addressable blob storage on the filesystem
|
//! - `DedupPort` for content-addressable blob storage on the filesystem
|
||||||
|
//!
|
||||||
|
//! File paths are resolved by querying the materialized `storage.folders.path`
|
||||||
|
//! column (O(1) per lookup), so no recursive CTEs are needed.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -13,7 +16,6 @@ use crate::application::ports::dedup_ports::DedupPort;
|
|||||||
use crate::application::ports::storage_ports::FileWritePort;
|
use crate::application::ports::storage_ports::FileWritePort;
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::entities::file::File;
|
use crate::domain::entities::file::File;
|
||||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
|
||||||
use crate::domain::services::path_service::StoragePath;
|
use crate::domain::services::path_service::StoragePath;
|
||||||
|
|
||||||
use super::folder_db_repository::FolderDbRepository;
|
use super::folder_db_repository::FolderDbRepository;
|
||||||
@@ -38,32 +40,49 @@ impl FileBlobWriteRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a virtual StoragePath for a file from its DB metadata.
|
/// Build a `StoragePath` from the materialized folder path + file name.
|
||||||
async fn build_file_path(
|
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
||||||
|
match folder_path {
|
||||||
|
Some(fp) if !fp.is_empty() => StoragePath::from_string(&format!("{fp}/{file_name}")),
|
||||||
|
_ => StoragePath::from_string(file_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up the materialized folder path. O(1) — no recursive CTE.
|
||||||
|
async fn lookup_folder_path(
|
||||||
&self,
|
&self,
|
||||||
folder_id: Option<&str>,
|
folder_id: Option<&str>,
|
||||||
file_name: &str,
|
) -> Result<Option<String>, DomainError> {
|
||||||
) -> Result<StoragePath, DomainError> {
|
match folder_id {
|
||||||
if let Some(fid) = folder_id {
|
Some(fid) => {
|
||||||
let folder_path = self.folder_repo.get_folder_path(fid).await?;
|
let path: String = sqlx::query_scalar(
|
||||||
Ok(folder_path.join(file_name))
|
"SELECT path FROM storage.folders WHERE id = $1::uuid",
|
||||||
} else {
|
)
|
||||||
Ok(StoragePath::from_string(file_name))
|
.bind(fid)
|
||||||
|
.fetch_optional(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("FileBlobWrite", format!("folder path: {e}"))
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| DomainError::not_found("Folder", fid))?;
|
||||||
|
Ok(Some(path))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn row_to_file(
|
fn row_to_file(
|
||||||
&self,
|
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
folder_id: Option<String>,
|
folder_id: Option<String>,
|
||||||
|
folder_path: Option<String>,
|
||||||
size: i64,
|
size: i64,
|
||||||
mime_type: String,
|
mime_type: String,
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
modified_at: i64,
|
modified_at: i64,
|
||||||
) -> Result<File, DomainError> {
|
) -> Result<File, DomainError> {
|
||||||
let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?;
|
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
|
||||||
File::with_timestamps(
|
File::with_timestamps(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -159,8 +178,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
&blob_hash[..12]
|
&blob_hash[..12]
|
||||||
);
|
);
|
||||||
|
|
||||||
self.row_to_file(row.0, name, folder_id, size, content_type, row.1, row.2)
|
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
|
||||||
.await
|
Self::row_to_file(row.0, name, folder_id, folder_path, size, content_type, row.1, row.2)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_file_from_temp(
|
async fn save_file_from_temp(
|
||||||
@@ -232,16 +251,17 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
&blob_hash[..12]
|
&blob_hash[..12]
|
||||||
);
|
);
|
||||||
|
|
||||||
self.row_to_file(
|
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
|
||||||
|
Self::row_to_file(
|
||||||
row.0,
|
row.0,
|
||||||
name,
|
name,
|
||||||
folder_id,
|
folder_id,
|
||||||
|
folder_path,
|
||||||
size as i64,
|
size as i64,
|
||||||
content_type,
|
content_type,
|
||||||
row.1,
|
row.1,
|
||||||
row.2,
|
row.2,
|
||||||
)
|
)
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn move_file(
|
async fn move_file(
|
||||||
@@ -267,8 +287,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))?
|
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
||||||
|
|
||||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
|
||||||
.await
|
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn copy_file(
|
async fn copy_file(
|
||||||
@@ -350,8 +370,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
&blob_hash[..12]
|
&blob_hash[..12]
|
||||||
);
|
);
|
||||||
|
|
||||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
|
||||||
.await
|
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError> {
|
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError> {
|
||||||
@@ -379,8 +399,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
})?
|
})?
|
||||||
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
||||||
|
|
||||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
|
||||||
.await
|
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
|
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
|
||||||
@@ -502,17 +522,17 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
|
||||||
|
|
||||||
let file = self
|
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
|
||||||
.row_to_file(
|
let file = Self::row_to_file(
|
||||||
row.0.clone(),
|
row.0.clone(),
|
||||||
name,
|
name,
|
||||||
folder_id,
|
folder_id,
|
||||||
size as i64,
|
folder_path,
|
||||||
content_type,
|
size as i64,
|
||||||
row.1,
|
content_type,
|
||||||
row.2,
|
row.1,
|
||||||
)
|
row.2,
|
||||||
.await?;
|
)?;
|
||||||
|
|
||||||
// The target_path is not meaningful for blob storage (content goes to .blobs/)
|
// The target_path is not meaningful for blob storage (content goes to .blobs/)
|
||||||
// but the WriteBehindCache API requires it. We return a synthetic path.
|
// but the WriteBehindCache API requires it. We return a synthetic path.
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
//! Implements `FolderRepository` (and thus `FolderStoragePort`) using the
|
//! Implements `FolderRepository` (and thus `FolderStoragePort`) using the
|
||||||
//! `storage.folders` table. Folders are purely virtual — no physical
|
//! `storage.folders` table. Folders are purely virtual — no physical
|
||||||
//! directories are created on the filesystem.
|
//! directories are created on the filesystem.
|
||||||
|
//!
|
||||||
|
//! Folder paths are **materialized** in a `path TEXT` column maintained by
|
||||||
|
//! database triggers, so reading a folder's full path is always O(1) — no
|
||||||
|
//! recursive CTEs or N+1 queries.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -41,43 +45,20 @@ impl FolderDbRepository {
|
|||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Build the full virtual path for a folder by walking up the `parent_id` chain.
|
|
||||||
async fn build_folder_path(&self, folder_id: &str) -> Result<StoragePath, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, (String, i32)>(
|
|
||||||
r#"
|
|
||||||
WITH RECURSIVE ancestors AS (
|
|
||||||
SELECT id, name, parent_id, 0 AS depth
|
|
||||||
FROM storage.folders
|
|
||||||
WHERE id = $1::uuid
|
|
||||||
UNION ALL
|
|
||||||
SELECT f.id, f.name, f.parent_id, a.depth + 1
|
|
||||||
FROM storage.folders f
|
|
||||||
JOIN ancestors a ON f.id = a.parent_id
|
|
||||||
)
|
|
||||||
SELECT name, depth FROM ancestors ORDER BY depth DESC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(folder_id)
|
|
||||||
.fetch_all(self.pool())
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("path query: {e}")))?;
|
|
||||||
|
|
||||||
let path_parts: Vec<&str> = rows.iter().map(|(name, _)| name.as_str()).collect();
|
|
||||||
let path_str = path_parts.join("/");
|
|
||||||
Ok(StoragePath::from_string(&path_str))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a database row into a `Folder` domain entity.
|
/// Convert a database row into a `Folder` domain entity.
|
||||||
async fn row_to_folder(
|
///
|
||||||
&self,
|
/// The `path` comes directly from the materialized `path` column — no
|
||||||
|
/// extra queries needed.
|
||||||
|
fn row_to_folder(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
path: String,
|
||||||
parent_id: Option<String>,
|
parent_id: Option<String>,
|
||||||
user_id: Option<String>,
|
user_id: Option<String>,
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
modified_at: i64,
|
modified_at: i64,
|
||||||
) -> Result<Folder, DomainError> {
|
) -> Result<Folder, DomainError> {
|
||||||
let storage_path = self.build_folder_path(&id).await?;
|
let storage_path = StoragePath::from_string(&path);
|
||||||
Folder::with_timestamps_and_owner(
|
Folder::with_timestamps_and_owner(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -117,11 +98,12 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
INSERT INTO storage.folders (name, parent_id, user_id)
|
||||||
VALUES ($1, $2::uuid, $3)
|
VALUES ($1, $2::uuid, $3)
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
|
path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
"#,
|
"#,
|
||||||
@@ -143,14 +125,13 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
DomainError::internal_error("FolderDb", format!("insert: {e}"))
|
DomainError::internal_error("FolderDb", format!("insert: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
self.row_to_folder(row.0, name, parent_id, Some(user_id), row.1, row.2)
|
Self::row_to_folder(row.0, name, row.1, parent_id, Some(user_id), row.2, row.3)
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||||
let row = sqlx::query_as::<_, (String, String, Option<String>, String, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -163,61 +144,41 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))?
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||||
|
|
||||||
self.row_to_folder(row.0, row.1, row.2, Some(row.3), row.4, row.5).await
|
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
||||||
// Walk the path segments to find the folder.
|
|
||||||
let path_str = storage_path.to_string();
|
let path_str = storage_path.to_string();
|
||||||
let segments: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
|
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
|
||||||
|
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
||||||
|
|
||||||
if segments.is_empty() {
|
if lookup.is_empty() {
|
||||||
return Err(DomainError::not_found("Folder", "empty path"));
|
return Err(DomainError::not_found("Folder", "empty path"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut current_parent: Option<String> = None;
|
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
||||||
let mut current_id = String::new();
|
r#"
|
||||||
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
|
FROM storage.folders
|
||||||
|
WHERE path = $1 AND NOT is_trashed
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(lookup)
|
||||||
|
.fetch_optional(self.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
|
||||||
|
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
|
||||||
|
|
||||||
for segment in &segments {
|
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
||||||
let row = if let Some(ref pid) = current_parent {
|
|
||||||
sqlx::query_as::<_, (String,)>(
|
|
||||||
r#"
|
|
||||||
SELECT id::text FROM storage.folders
|
|
||||||
WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(segment)
|
|
||||||
.bind(pid)
|
|
||||||
.fetch_optional(self.pool())
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, (String,)>(
|
|
||||||
r#"
|
|
||||||
SELECT id::text FROM storage.folders
|
|
||||||
WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(segment)
|
|
||||||
.fetch_optional(self.pool())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("path walk: {e}")))?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
DomainError::not_found("Folder", format!("segment '{segment}' in path"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
current_id = row.0;
|
|
||||||
current_parent = Some(current_id.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.get_folder(¤t_id).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
|
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
|
||||||
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -231,7 +192,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -244,11 +205,11 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
}
|
}
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
||||||
|
|
||||||
let mut folders = Vec::with_capacity(rows.len());
|
rows.into_iter()
|
||||||
for (id, name, pid, uid, ca, ma) in rows {
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||||
folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?);
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||||
}
|
})
|
||||||
Ok(folders)
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_folders_by_owner(
|
async fn list_folders_by_owner(
|
||||||
@@ -256,12 +217,10 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
parent_id: Option<&str>,
|
parent_id: Option<&str>,
|
||||||
owner_id: &str,
|
owner_id: &str,
|
||||||
) -> Result<Vec<Folder>, DomainError> {
|
) -> Result<Vec<Folder>, DomainError> {
|
||||||
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||||
// For sub-folders the owner is implicit (parent belongs to user),
|
|
||||||
// but we still filter to be safe.
|
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -274,10 +233,9 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.fetch_all(self.pool())
|
.fetch_all(self.pool())
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
// Root-level: only this user's home folders
|
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -291,11 +249,11 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
}
|
}
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
|
||||||
|
|
||||||
let mut folders = Vec::with_capacity(rows.len());
|
rows.into_iter()
|
||||||
for (id, name, pid, uid, ca, ma) in rows {
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||||
folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?);
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||||
}
|
})
|
||||||
Ok(folders)
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_folders_paginated(
|
async fn list_folders_paginated(
|
||||||
@@ -326,10 +284,10 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -346,7 +304,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -362,11 +320,13 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
}
|
}
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
||||||
|
|
||||||
let mut folders = Vec::with_capacity(rows.len());
|
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||||
for (id, name, pid, uid, ca, ma) in rows {
|
.into_iter()
|
||||||
folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?);
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||||
}
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||||
Ok((folders, total))
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((folders?, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_folders_by_owner_paginated(
|
async fn list_folders_by_owner_paginated(
|
||||||
@@ -400,10 +360,10 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -421,7 +381,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -438,14 +398,18 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
}
|
}
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
|
||||||
|
|
||||||
let mut folders = Vec::with_capacity(rows.len());
|
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||||
for (id, name, pid, uid, ca, ma) in rows {
|
.into_iter()
|
||||||
folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?);
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||||
}
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||||
Ok((folders, total))
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((folders?, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
|
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
|
||||||
|
// The BEFORE UPDATE trigger on `name` will recompute path/lpath
|
||||||
|
// and cascade to descendants automatically.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.folders
|
UPDATE storage.folders
|
||||||
@@ -474,6 +438,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
id: &str,
|
id: &str,
|
||||||
new_parent_id: Option<&str>,
|
new_parent_id: Option<&str>,
|
||||||
) -> Result<Folder, DomainError> {
|
) -> Result<Folder, DomainError> {
|
||||||
|
// The BEFORE UPDATE trigger on `parent_id` will recompute path/lpath
|
||||||
|
// and cascade to descendants automatically.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.folders
|
UPDATE storage.folders
|
||||||
@@ -505,16 +471,31 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
||||||
// Try to find by walking the path
|
let path_str = storage_path.to_string();
|
||||||
match self.get_folder_by_path(storage_path).await {
|
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
||||||
Ok(_) => Ok(true),
|
|
||||||
Err(e) if e.to_string().contains("not found") => Ok(false),
|
let exists: bool = sqlx::query_scalar(
|
||||||
Err(e) => Err(e),
|
"SELECT EXISTS(SELECT 1 FROM storage.folders WHERE path = $1 AND NOT is_trashed)",
|
||||||
}
|
)
|
||||||
|
.bind(lookup)
|
||||||
|
.fetch_one(self.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("exists: {e}")))?;
|
||||||
|
|
||||||
|
Ok(exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
||||||
self.build_folder_path(id).await
|
let path: String = sqlx::query_scalar(
|
||||||
|
"SELECT path FROM storage.folders WHERE id = $1::uuid",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(self.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_path: {e}")))?
|
||||||
|
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||||
|
|
||||||
|
Ok(StoragePath::from_string(&path))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Trash operations ──
|
// ── Trash operations ──
|
||||||
@@ -566,6 +547,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
_original_path: &str,
|
_original_path: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
// Atomic CTE: restore folder + all descendant files in a single statement.
|
// Atomic CTE: restore folder + all descendant files in a single statement.
|
||||||
|
// The BEFORE UPDATE trigger on parent_id will recompute path/lpath
|
||||||
|
// automatically when original_parent_id is restored.
|
||||||
let result = sqlx::query_scalar::<_, i64>(
|
let result = sqlx::query_scalar::<_, i64>(
|
||||||
r#"
|
r#"
|
||||||
WITH restore_folder AS (
|
WITH restore_folder AS (
|
||||||
@@ -632,12 +615,13 @@ impl FolderDbRepository {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<Folder, DomainError> {
|
) -> Result<Folder, DomainError> {
|
||||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
INSERT INTO storage.folders (name, parent_id, user_id)
|
||||||
VALUES ($1, NULL, $2)
|
VALUES ($1, NULL, $2)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
|
path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
"#,
|
"#,
|
||||||
@@ -649,12 +633,13 @@ impl FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
Some((id, ca, ma)) => self.row_to_folder(id, name.to_string(), None, Some(user_id.to_string()), ca, ma).await,
|
Some((id, path, ca, ma)) => Self::row_to_folder(id, name.to_string(), path, None, Some(user_id.to_string()), ca, ma),
|
||||||
None => {
|
None => {
|
||||||
// Already exists — fetch it
|
// Already exists — fetch it
|
||||||
let existing = sqlx::query_as::<_, (String, i64, i64)>(
|
let existing = sqlx::query_as::<_, (String, String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text,
|
SELECT id::text,
|
||||||
|
path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
@@ -666,8 +651,7 @@ impl FolderDbRepository {
|
|||||||
.fetch_one(self.pool())
|
.fetch_one(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?;
|
||||||
self.row_to_folder(existing.0, name.to_string(), None, Some(user_id.to_string()), existing.1, existing.2)
|
Self::row_to_folder(existing.0, name.to_string(), existing.1, None, Some(user_id.to_string()), existing.2, existing.3)
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -810,10 +810,6 @@ impl DedupPort for DedupService {
|
|||||||
self.remove_reference(hash).await
|
self.remove_reference(hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_bytes(&self, content: &[u8]) -> String {
|
|
||||||
DedupService::hash_bytes(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn hash_file(&self, path: &Path) -> Result<String, DomainError> {
|
async fn hash_file(&self, path: &Path) -> Result<String, DomainError> {
|
||||||
DedupService::hash_file(path)
|
DedupService::hash_file(path)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -3,22 +3,20 @@
|
|||||||
//! Automatically transcodes images to WebP format when the browser supports it,
|
//! Automatically transcodes images to WebP format when the browser supports it,
|
||||||
//! reducing bandwidth by 30-50% compared to JPEG/PNG.
|
//! reducing bandwidth by 30-50% compared to JPEG/PNG.
|
||||||
//!
|
//!
|
||||||
//! Features:
|
//! Architecture:
|
||||||
//! - Detects browser WebP support via Accept header
|
//! - **Dedicated `rayon` thread pool** for CPU-bound transcoding (never blocks Tokio)
|
||||||
//! - Caches transcoded versions to avoid re-conversion
|
//! - **`moka` lock-free cache** for hot transcoded images (no write-lock on reads)
|
||||||
|
//! - Disk cache for persistence across restarts
|
||||||
//! - Supports JPEG, PNG, GIF → WebP conversion
|
//! - Supports JPEG, PNG, GIF → WebP conversion
|
||||||
//! - Configurable quality settings
|
//! - Falls back to original if conversion fails or result is larger
|
||||||
//! - Falls back to original if conversion fails
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use image::{DynamicImage, ImageFormat};
|
use image::ImageFormat;
|
||||||
use lru::LruCache;
|
|
||||||
use std::num::NonZeroUsize;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
use crate::application::ports::transcode_ports::{
|
use crate::application::ports::transcode_ports::{
|
||||||
ImageTranscodePort, OutputFormat as PortOutputFormat, TranscodeStatsDto,
|
ImageTranscodePort, OutputFormat as PortOutputFormat, TranscodeStatsDto,
|
||||||
@@ -28,11 +26,20 @@ use crate::domain::errors::{DomainError, ErrorKind};
|
|||||||
/// Maximum file size for transcoding (5MB - larger files stream directly)
|
/// Maximum file size for transcoding (5MB - larger files stream directly)
|
||||||
pub const MAX_TRANSCODE_SIZE: u64 = 5 * 1024 * 1024;
|
pub const MAX_TRANSCODE_SIZE: u64 = 5 * 1024 * 1024;
|
||||||
|
|
||||||
/// Cache key for transcoded images
|
/// Number of threads in the dedicated transcoding pool
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
const TRANSCODE_POOL_THREADS: usize = 2;
|
||||||
struct TranscodeKey {
|
|
||||||
file_id: String,
|
/// Dedicated rayon thread pool for CPU-bound image transcoding.
|
||||||
format: OutputFormat,
|
/// Isolated from Tokio's blocking pool to prevent starvation of other I/O.
|
||||||
|
fn transcode_pool() -> &'static rayon::ThreadPool {
|
||||||
|
static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
|
||||||
|
POOL.get_or_init(|| {
|
||||||
|
rayon::ThreadPoolBuilder::new()
|
||||||
|
.num_threads(TRANSCODE_POOL_THREADS)
|
||||||
|
.thread_name(|idx| format!("transcode-{idx}"))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create transcode thread pool")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supported output formats
|
/// Supported output formats
|
||||||
@@ -75,7 +82,6 @@ impl BrowserCapabilities {
|
|||||||
|
|
||||||
/// Get the best output format for this browser
|
/// Get the best output format for this browser
|
||||||
pub fn best_format(&self) -> Option<OutputFormat> {
|
pub fn best_format(&self) -> Option<OutputFormat> {
|
||||||
// WebP has best support currently
|
|
||||||
if self.supports_webp {
|
if self.supports_webp {
|
||||||
Some(OutputFormat::WebP)
|
Some(OutputFormat::WebP)
|
||||||
} else {
|
} else {
|
||||||
@@ -84,21 +90,17 @@ impl BrowserCapabilities {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Image Transcoding Service
|
/// Lock-free transcoding statistics using atomics (no RwLock needed)
|
||||||
pub struct ImageTranscodeService {
|
#[derive(Debug, Default)]
|
||||||
/// Cache directory for transcoded images
|
struct AtomicTranscodeStats {
|
||||||
cache_dir: PathBuf,
|
cache_hits: AtomicU64,
|
||||||
/// In-memory LRU cache for hot transcoded images
|
disk_hits: AtomicU64,
|
||||||
memory_cache: Arc<RwLock<LruCache<TranscodeKey, Bytes>>>,
|
transcodes: AtomicU64,
|
||||||
/// Maximum memory cache size in bytes
|
bytes_saved: AtomicU64,
|
||||||
max_memory_bytes: usize,
|
transcode_errors: AtomicU64,
|
||||||
/// Current memory usage
|
|
||||||
current_memory_bytes: Arc<RwLock<usize>>,
|
|
||||||
/// Statistics
|
|
||||||
stats: Arc<RwLock<TranscodeStats>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transcoding statistics
|
/// Snapshot of transcoding statistics
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct TranscodeStats {
|
pub struct TranscodeStats {
|
||||||
pub cache_hits: u64,
|
pub cache_hits: u64,
|
||||||
@@ -108,19 +110,58 @@ pub struct TranscodeStats {
|
|||||||
pub transcode_errors: u64,
|
pub transcode_errors: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AtomicTranscodeStats {
|
||||||
|
fn snapshot(&self) -> TranscodeStats {
|
||||||
|
TranscodeStats {
|
||||||
|
cache_hits: self.cache_hits.load(Ordering::Relaxed),
|
||||||
|
disk_hits: self.disk_hits.load(Ordering::Relaxed),
|
||||||
|
transcodes: self.transcodes.load(Ordering::Relaxed),
|
||||||
|
bytes_saved: self.bytes_saved.load(Ordering::Relaxed),
|
||||||
|
transcode_errors: self.transcode_errors.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image Transcoding Service
|
||||||
|
///
|
||||||
|
/// Uses a dedicated `rayon` thread pool for CPU-bound work and `moka` for
|
||||||
|
/// lock-free concurrent caching with automatic weight-based eviction.
|
||||||
|
pub struct ImageTranscodeService {
|
||||||
|
/// Cache directory for transcoded images on disk
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
/// Lock-free concurrent cache (moka) — no write-lock on reads
|
||||||
|
memory_cache: moka::future::Cache<String, Bytes>,
|
||||||
|
/// Lock-free statistics
|
||||||
|
stats: Arc<AtomicTranscodeStats>,
|
||||||
|
}
|
||||||
|
|
||||||
impl ImageTranscodeService {
|
impl ImageTranscodeService {
|
||||||
/// Create new transcoding service
|
/// Create new transcoding service
|
||||||
|
///
|
||||||
|
/// - `storage_root`: base path for disk cache
|
||||||
|
/// - `max_cache_entries`: max number of transcoded images in memory
|
||||||
|
/// - `max_memory_bytes`: max total bytes for in-memory cache
|
||||||
pub fn new(storage_root: &Path, max_cache_entries: usize, max_memory_bytes: usize) -> Self {
|
pub fn new(storage_root: &Path, max_cache_entries: usize, max_memory_bytes: usize) -> Self {
|
||||||
let cache_dir = storage_root.join(".transcoded");
|
let cache_dir = storage_root.join(".transcoded");
|
||||||
|
|
||||||
|
// Build moka cache with weight-based eviction (by content size)
|
||||||
|
let memory_cache = moka::future::Cache::builder()
|
||||||
|
.max_capacity(max_memory_bytes as u64)
|
||||||
|
.weigher(|_key: &String, value: &Bytes| -> u32 {
|
||||||
|
// Weight = byte size, capped to u32::MAX
|
||||||
|
value.len().min(u32::MAX as usize) as u32
|
||||||
|
})
|
||||||
|
.time_to_live(std::time::Duration::from_secs(600)) // 10 min TTL for freshness
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Ignore max_cache_entries — moka uses weight-based eviction, which is
|
||||||
|
// more accurate than entry-count limits for variable-size images.
|
||||||
|
let _ = max_cache_entries;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
cache_dir,
|
cache_dir,
|
||||||
memory_cache: Arc::new(RwLock::new(LruCache::new(
|
memory_cache,
|
||||||
NonZeroUsize::new(max_cache_entries).unwrap_or(NonZeroUsize::new(1000).unwrap()),
|
stats: Arc::new(AtomicTranscodeStats::default()),
|
||||||
))),
|
|
||||||
max_memory_bytes,
|
|
||||||
current_memory_bytes: Arc::new(RwLock::new(0)),
|
|
||||||
stats: Arc::new(RwLock::new(TranscodeStats::default())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +170,8 @@ impl ImageTranscodeService {
|
|||||||
fs::create_dir_all(&self.cache_dir).await?;
|
fs::create_dir_all(&self.cache_dir).await?;
|
||||||
fs::create_dir_all(self.cache_dir.join("webp")).await?;
|
fs::create_dir_all(self.cache_dir.join("webp")).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🖼️ Image transcode service initialized at {:?}",
|
"🖼️ Image transcode service initialized (rayon pool: {} threads, cache dir: {:?})",
|
||||||
|
TRANSCODE_POOL_THREADS,
|
||||||
self.cache_dir
|
self.cache_dir
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -148,8 +190,8 @@ impl ImageTranscodeService {
|
|||||||
Self::can_transcode(mime_type) && file_size <= MAX_TRANSCODE_SIZE
|
Self::can_transcode(mime_type) && file_size <= MAX_TRANSCODE_SIZE
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transcoded version of an image
|
/// Get transcoded version of an image.
|
||||||
/// Returns (content, mime_type, was_transcoded)
|
/// Returns `(content, mime_type, was_transcoded)`.
|
||||||
pub async fn get_transcoded(
|
pub async fn get_transcoded(
|
||||||
&self,
|
&self,
|
||||||
file_id: &str,
|
file_id: &str,
|
||||||
@@ -157,34 +199,25 @@ impl ImageTranscodeService {
|
|||||||
original_mime: &str,
|
original_mime: &str,
|
||||||
target_format: OutputFormat,
|
target_format: OutputFormat,
|
||||||
) -> Result<(Bytes, String, bool), String> {
|
) -> Result<(Bytes, String, bool), String> {
|
||||||
let key = TranscodeKey {
|
let cache_key = format!("{}:{}", file_id, target_format.extension());
|
||||||
file_id: file_id.to_string(),
|
|
||||||
format: target_format,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check memory cache first
|
// ── 1. Check moka memory cache (lock-free read) ──
|
||||||
{
|
if let Some(cached) = self.memory_cache.get(&cache_key).await {
|
||||||
let mut cache = self.memory_cache.write().await;
|
self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
if let Some(cached) = cache.get(&key) {
|
tracing::debug!("🔥 Transcode memory cache HIT: {}", file_id);
|
||||||
let mut stats = self.stats.write().await;
|
return Ok((cached, target_format.mime_type().to_string(), true));
|
||||||
stats.cache_hits += 1;
|
|
||||||
tracing::debug!("🔥 Transcode memory cache HIT: {}", file_id);
|
|
||||||
return Ok((cached.clone(), target_format.mime_type().to_string(), true));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check disk cache
|
// ── 2. Check disk cache (async fs) ──
|
||||||
let cache_path = self.get_cache_path(file_id, target_format);
|
let cache_path = self.get_cache_path(file_id, target_format);
|
||||||
if cache_path.exists() {
|
if tokio::fs::try_exists(&cache_path).await.unwrap_or(false) {
|
||||||
match fs::read(&cache_path).await {
|
match fs::read(&cache_path).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let content = Bytes::from(data);
|
let content = Bytes::from(data);
|
||||||
|
self.memory_cache
|
||||||
// Store in memory cache
|
.insert(cache_key.clone(), content.clone())
|
||||||
self.cache_in_memory(&key, content.clone()).await;
|
.await;
|
||||||
|
self.stats.disk_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
let mut stats = self.stats.write().await;
|
|
||||||
stats.disk_hits += 1;
|
|
||||||
tracing::debug!("💾 Transcode disk cache HIT: {}", file_id);
|
tracing::debug!("💾 Transcode disk cache HIT: {}", file_id);
|
||||||
return Ok((content, target_format.mime_type().to_string(), true));
|
return Ok((content, target_format.mime_type().to_string(), true));
|
||||||
}
|
}
|
||||||
@@ -194,16 +227,27 @@ impl ImageTranscodeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to transcode
|
// ── 3. Transcode on dedicated rayon pool (never blocks Tokio) ──
|
||||||
let transcoded = self.transcode_image(original_content, original_mime, target_format)?;
|
let content_owned = original_content.to_vec();
|
||||||
let transcoded_bytes = Bytes::from(transcoded.clone());
|
let mime_owned = original_mime.to_string();
|
||||||
|
|
||||||
// Calculate savings
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
|
||||||
|
transcode_pool().spawn(move || {
|
||||||
|
let result = transcode_image_blocking(&content_owned, &mime_owned, target_format);
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
let transcoded = rx
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Transcode task was cancelled".to_string())??;
|
||||||
|
|
||||||
|
let transcoded_bytes = Bytes::from(transcoded);
|
||||||
|
|
||||||
|
// ── 4. Evaluate savings ──
|
||||||
let original_size = original_content.len();
|
let original_size = original_content.len();
|
||||||
let transcoded_size = transcoded_bytes.len();
|
let transcoded_size = transcoded_bytes.len();
|
||||||
let saved = original_size.saturating_sub(transcoded_size);
|
|
||||||
|
|
||||||
// Only use transcoded if it's actually smaller
|
|
||||||
if transcoded_size >= original_size {
|
if transcoded_size >= original_size {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"⚠️ Transcode not beneficial for {}: {} -> {} bytes",
|
"⚠️ Transcode not beneficial for {}: {} -> {} bytes",
|
||||||
@@ -218,27 +262,30 @@ impl ImageTranscodeService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to disk cache (async, don't wait)
|
let saved = original_size - transcoded_size;
|
||||||
|
|
||||||
|
// ── 5. Persist to disk cache (fire-and-forget) ──
|
||||||
let cache_path_clone = cache_path.clone();
|
let cache_path_clone = cache_path.clone();
|
||||||
let transcoded_clone = transcoded.clone();
|
let transcoded_for_disk = transcoded_bytes.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Some(parent) = cache_path_clone.parent() {
|
if let Some(parent) = cache_path_clone.parent() {
|
||||||
let _ = fs::create_dir_all(parent).await;
|
let _ = fs::create_dir_all(parent).await;
|
||||||
}
|
}
|
||||||
if let Err(e) = fs::write(&cache_path_clone, &transcoded_clone).await {
|
if let Err(e) = fs::write(&cache_path_clone, &transcoded_for_disk).await {
|
||||||
tracing::warn!("Failed to cache transcoded image: {}", e);
|
tracing::warn!("Failed to cache transcoded image: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store in memory cache
|
// ── 6. Store in moka memory cache (lock-free) ──
|
||||||
self.cache_in_memory(&key, transcoded_bytes.clone()).await;
|
self.memory_cache
|
||||||
|
.insert(cache_key, transcoded_bytes.clone())
|
||||||
|
.await;
|
||||||
|
|
||||||
// Update stats
|
// ── 7. Update stats (lock-free atomics) ──
|
||||||
{
|
self.stats.transcodes.fetch_add(1, Ordering::Relaxed);
|
||||||
let mut stats = self.stats.write().await;
|
self.stats
|
||||||
stats.transcodes += 1;
|
.bytes_saved
|
||||||
stats.bytes_saved += saved as u64;
|
.fetch_add(saved as u64, Ordering::Relaxed);
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"✨ Transcoded {}: {} -> {} bytes ({:.1}% smaller)",
|
"✨ Transcoded {}: {} -> {} bytes ({:.1}% smaller)",
|
||||||
@@ -255,43 +302,6 @@ impl ImageTranscodeService {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform actual image transcoding
|
|
||||||
fn transcode_image(
|
|
||||||
&self,
|
|
||||||
content: &[u8],
|
|
||||||
original_mime: &str,
|
|
||||||
target_format: OutputFormat,
|
|
||||||
) -> Result<Vec<u8>, String> {
|
|
||||||
// Determine input format
|
|
||||||
let input_format = match original_mime {
|
|
||||||
"image/jpeg" | "image/jpg" => ImageFormat::Jpeg,
|
|
||||||
"image/png" => ImageFormat::Png,
|
|
||||||
"image/gif" => ImageFormat::Gif,
|
|
||||||
_ => return Err(format!("Unsupported input format: {}", original_mime)),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load image
|
|
||||||
let img = image::load_from_memory_with_format(content, input_format)
|
|
||||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
|
||||||
|
|
||||||
// Encode to target format
|
|
||||||
match target_format {
|
|
||||||
OutputFormat::WebP => self.encode_webp(&img),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode image to WebP
|
|
||||||
fn encode_webp(&self, img: &DynamicImage) -> Result<Vec<u8>, String> {
|
|
||||||
let mut buffer = Vec::new();
|
|
||||||
let mut cursor = std::io::Cursor::new(&mut buffer);
|
|
||||||
|
|
||||||
// Use image crate's WebP encoder
|
|
||||||
img.write_to(&mut cursor, ImageFormat::WebP)
|
|
||||||
.map_err(|e| format!("Failed to encode WebP: {}", e))?;
|
|
||||||
|
|
||||||
Ok(buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get path for cached transcoded file
|
/// Get path for cached transcoded file
|
||||||
fn get_cache_path(&self, file_id: &str, format: OutputFormat) -> PathBuf {
|
fn get_cache_path(&self, file_id: &str, format: OutputFormat) -> PathBuf {
|
||||||
self.cache_dir
|
self.cache_dir
|
||||||
@@ -299,67 +309,28 @@ impl ImageTranscodeService {
|
|||||||
.join(format!("{}.{}", file_id, format.extension()))
|
.join(format!("{}.{}", file_id, format.extension()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store transcoded image in memory cache
|
|
||||||
async fn cache_in_memory(&self, key: &TranscodeKey, content: Bytes) {
|
|
||||||
let size = content.len();
|
|
||||||
|
|
||||||
let mut current = self.current_memory_bytes.write().await;
|
|
||||||
|
|
||||||
// Evict if needed
|
|
||||||
while *current + size > self.max_memory_bytes {
|
|
||||||
let mut cache = self.memory_cache.write().await;
|
|
||||||
if let Some((_, evicted)) = cache.pop_lru() {
|
|
||||||
*current = current.saturating_sub(evicted.len());
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to cache
|
|
||||||
if *current + size <= self.max_memory_bytes {
|
|
||||||
let mut cache = self.memory_cache.write().await;
|
|
||||||
cache.put(key.clone(), content);
|
|
||||||
*current += size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invalidate cached transcodes for a file
|
/// Invalidate cached transcodes for a file
|
||||||
pub async fn invalidate(&self, file_id: &str) {
|
pub async fn invalidate(&self, file_id: &str) {
|
||||||
// Remove from memory cache
|
let cache_key = format!("{}:{}", file_id, OutputFormat::WebP.extension());
|
||||||
{
|
self.memory_cache.invalidate(&cache_key).await;
|
||||||
let mut cache = self.memory_cache.write().await;
|
|
||||||
let key = TranscodeKey {
|
|
||||||
file_id: file_id.to_string(),
|
|
||||||
format: OutputFormat::WebP,
|
|
||||||
};
|
|
||||||
if let Some(removed) = cache.pop(&key) {
|
|
||||||
let mut current = self.current_memory_bytes.write().await;
|
|
||||||
*current = current.saturating_sub(removed.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove disk cache
|
|
||||||
let cache_path = self.get_cache_path(file_id, OutputFormat::WebP);
|
let cache_path = self.get_cache_path(file_id, OutputFormat::WebP);
|
||||||
let _ = fs::remove_file(&cache_path).await;
|
let _ = fs::remove_file(&cache_path).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transcoding statistics
|
/// Get transcoding statistics
|
||||||
pub async fn get_stats(&self) -> TranscodeStats {
|
pub async fn get_stats(&self) -> TranscodeStats {
|
||||||
self.stats.read().await.clone()
|
self.stats.snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all caches
|
/// Clear all caches
|
||||||
pub async fn clear_cache(&self) -> std::io::Result<()> {
|
pub async fn clear_cache(&self) -> std::io::Result<()> {
|
||||||
// Clear memory
|
self.memory_cache.invalidate_all();
|
||||||
{
|
|
||||||
let mut cache = self.memory_cache.write().await;
|
|
||||||
cache.clear();
|
|
||||||
let mut current = self.current_memory_bytes.write().await;
|
|
||||||
*current = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear disk
|
if tokio::fs::try_exists(&self.cache_dir)
|
||||||
if self.cache_dir.exists() {
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
fs::remove_dir_all(&self.cache_dir).await?;
|
fs::remove_dir_all(&self.cache_dir).await?;
|
||||||
fs::create_dir_all(&self.cache_dir).await?;
|
fs::create_dir_all(&self.cache_dir).await?;
|
||||||
fs::create_dir_all(self.cache_dir.join("webp")).await?;
|
fs::create_dir_all(self.cache_dir.join("webp")).await?;
|
||||||
@@ -369,6 +340,36 @@ impl ImageTranscodeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── CPU-bound transcoding (runs on rayon, never on Tokio) ───────────────────
|
||||||
|
|
||||||
|
/// Perform actual image transcoding. This is a pure CPU function — safe to call
|
||||||
|
/// from `rayon::spawn` or `spawn_blocking`.
|
||||||
|
fn transcode_image_blocking(
|
||||||
|
content: &[u8],
|
||||||
|
original_mime: &str,
|
||||||
|
target_format: OutputFormat,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let input_format = match original_mime {
|
||||||
|
"image/jpeg" | "image/jpg" => ImageFormat::Jpeg,
|
||||||
|
"image/png" => ImageFormat::Png,
|
||||||
|
"image/gif" => ImageFormat::Gif,
|
||||||
|
_ => return Err(format!("Unsupported input format: {}", original_mime)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let img = image::load_from_memory_with_format(content, input_format)
|
||||||
|
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||||
|
|
||||||
|
match target_format {
|
||||||
|
OutputFormat::WebP => {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let mut cursor = std::io::Cursor::new(&mut buffer);
|
||||||
|
img.write_to(&mut cursor, ImageFormat::WebP)
|
||||||
|
.map_err(|e| format!("Failed to encode WebP: {}", e))?;
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Port implementation ─────────────────────────────────────────────────────
|
// ─── Port implementation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Convert port OutputFormat to infra OutputFormat.
|
/// Convert port OutputFormat to infra OutputFormat.
|
||||||
@@ -481,4 +482,11 @@ mod tests {
|
|||||||
1024 * 1024
|
1024 * 1024
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_transcode_pool_initializes() {
|
||||||
|
// Verify the pool can be created without panic
|
||||||
|
let pool = transcode_pool();
|
||||||
|
assert_eq!(pool.current_num_threads(), TRANSCODE_POOL_THREADS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-7
@@ -788,6 +788,7 @@ async function loadFiles(options = {}) {
|
|||||||
|
|
||||||
// Clear existing files in both views
|
// Clear existing files in both views
|
||||||
if (window.multiSelect) window.multiSelect.clear();
|
if (window.multiSelect) window.multiSelect.clear();
|
||||||
|
ui._items.clear();
|
||||||
elements.filesGrid.innerHTML = '';
|
elements.filesGrid.innerHTML = '';
|
||||||
elements.filesListView.innerHTML = `
|
elements.filesListView.innerHTML = `
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
@@ -815,9 +816,7 @@ async function loadFiles(options = {}) {
|
|||||||
|
|
||||||
// Backend already scopes folders to the authenticated user,
|
// Backend already scopes folders to the authenticated user,
|
||||||
// so no client-side filtering is needed.
|
// so no client-side filtering is needed.
|
||||||
folderList.forEach(folder => {
|
ui.renderFolders(folderList);
|
||||||
ui.addFolderToView(folder);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also load files in this folder
|
// Also load files in this folder
|
||||||
const cacheTimestamp = new Date().getTime();
|
const cacheTimestamp = new Date().getTime();
|
||||||
@@ -846,10 +845,7 @@ async function loadFiles(options = {}) {
|
|||||||
const fileList = Array.isArray(files) ? files : [];
|
const fileList = Array.isArray(files) ? files : [];
|
||||||
console.log(`Processing ${fileList.length} files`);
|
console.log(`Processing ${fileList.length} files`);
|
||||||
|
|
||||||
fileList.forEach(file => {
|
ui.renderFiles(fileList);
|
||||||
console.log(`Adding file to view: ${file.name} (${file.id})`);
|
|
||||||
ui.addFileToView(file);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const errorText = await filesResponse.text();
|
const errorText = await filesResponse.text();
|
||||||
console.error(`Error loading files: ${filesResponse.status} - ${errorText}`);
|
console.error(`Error loading files: ${filesResponse.status} - ${errorText}`);
|
||||||
|
|||||||
+376
-385
@@ -673,34 +673,257 @@ const ui = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Data store + event delegation (replaces per-item listeners)
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
/** @type {Map<string, Object>} item data keyed by id */
|
||||||
|
_items: new Map(),
|
||||||
|
|
||||||
|
/** @type {boolean} */
|
||||||
|
_delegationReady: false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add folder to the view
|
* Attach a fixed set of delegated event listeners to the two
|
||||||
* @param {Object} folder - Folder object
|
* container elements (files-grid, files-list-view).
|
||||||
|
* Called once – idempotent.
|
||||||
*/
|
*/
|
||||||
addFolderToView(folder) {
|
initDelegation() {
|
||||||
// Check if the folder already exists in the view to avoid duplicates
|
if (this._delegationReady) return;
|
||||||
if (document.querySelector(`.file-card[data-folder-id="${folder.id}"]`) ||
|
const grid = document.getElementById('files-grid');
|
||||||
document.querySelector(`.file-item[data-folder-id="${folder.id}"]`)) {
|
const list = document.getElementById('files-list-view');
|
||||||
console.log(`Folder ${folder.name} (${folder.id}) already exists in the view, not duplicating`);
|
if (!grid || !list) return;
|
||||||
return;
|
this._delegationReady = true;
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────
|
||||||
|
const itemInfo = (card) => {
|
||||||
|
if (!card) return null;
|
||||||
|
const fileId = card.dataset.fileId;
|
||||||
|
if (fileId) return { type: 'file', id: fileId, data: self._items.get(fileId) };
|
||||||
|
const folderId = card.dataset.folderId;
|
||||||
|
if (folderId) return { type: 'folder', id: folderId, data: self._items.get(folderId) };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFile = (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (window.recent) {
|
||||||
|
document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } }));
|
||||||
|
}
|
||||||
|
if (self.isViewableFile(file)) {
|
||||||
|
if (window.inlineViewer) window.inlineViewer.openFile(file);
|
||||||
|
else if (window.fileViewer) window.fileViewer.open(file);
|
||||||
|
else window.fileOps.downloadFile(file.id, file.name);
|
||||||
|
} else {
|
||||||
|
window.fileOps.downloadFile(file.id, file.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateFolder = (card) => {
|
||||||
|
window.app.currentPath = card.dataset.folderId;
|
||||||
|
self.updateBreadcrumb(card.dataset.folderName);
|
||||||
|
window.loadFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setContextTarget = (card, info) => {
|
||||||
|
if (info.type === 'folder') {
|
||||||
|
window.app.contextMenuTargetFolder = {
|
||||||
|
id: info.id,
|
||||||
|
name: card.dataset.folderName,
|
||||||
|
parent_id: card.dataset.parentId || ""
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
window.app.contextMenuTargetFile = {
|
||||||
|
id: info.id,
|
||||||
|
name: card.dataset.fileName,
|
||||||
|
folder_id: card.dataset.folderId || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── GRID: click (select) ──────────────────────────────────
|
||||||
|
grid.addEventListener('click', (e) => {
|
||||||
|
const card = e.target.closest('.file-card');
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
if (e.target.closest('.file-card-more')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const info = itemInfo(card);
|
||||||
|
if (!info) return;
|
||||||
|
setContextTarget(card, info);
|
||||||
|
const menuId = info.type === 'folder'
|
||||||
|
? 'folder-context-menu' : 'file-context-menu';
|
||||||
|
showContextMenuAtElement(
|
||||||
|
e.target.closest('.file-card-more'), menuId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.closest('.file-card-checkbox')) {
|
||||||
|
toggleCardSelection(card, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCardSelection(card, e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GRID: dblclick (navigate / open) ──────────────────────
|
||||||
|
grid.addEventListener('dblclick', (e) => {
|
||||||
|
const card = e.target.closest('.file-card');
|
||||||
|
if (!card) return;
|
||||||
|
if (e.target.closest('.file-card-more') ||
|
||||||
|
e.target.closest('.file-card-checkbox')) return;
|
||||||
|
|
||||||
|
const info = itemInfo(card);
|
||||||
|
if (!info) return;
|
||||||
|
|
||||||
|
if (info.type === 'folder') {
|
||||||
|
navigateFolder(card);
|
||||||
|
} else {
|
||||||
|
openFile(info.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── LIST: click (navigate / open) ─────────────────────────
|
||||||
|
list.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.list-header')) return;
|
||||||
|
const card = e.target.closest('.file-item');
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
if (e.target.closest('.list-item-checkbox') ||
|
||||||
|
e.target.closest('.item-checkbox')) {
|
||||||
|
toggleCardSelection(card, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = itemInfo(card);
|
||||||
|
if (!info) return;
|
||||||
|
|
||||||
|
if (info.type === 'folder') {
|
||||||
|
navigateFolder(card);
|
||||||
|
} else {
|
||||||
|
openFile(info.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── shared events on both containers ──────────────────────
|
||||||
|
for (const container of [grid, list]) {
|
||||||
|
const sel = container === grid ? '.file-card' : '.file-item';
|
||||||
|
|
||||||
|
// contextmenu
|
||||||
|
container.addEventListener('contextmenu', (e) => {
|
||||||
|
const card = e.target.closest(sel);
|
||||||
|
if (!card) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const info = itemInfo(card);
|
||||||
|
if (!info) return;
|
||||||
|
setContextTarget(card, info);
|
||||||
|
const menuId = info.type === 'folder'
|
||||||
|
? 'folder-context-menu' : 'file-context-menu';
|
||||||
|
const menu = document.getElementById(menuId);
|
||||||
|
menu.style.left = `${e.pageX}px`;
|
||||||
|
menu.style.top = `${e.pageY}px`;
|
||||||
|
menu.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// dragstart
|
||||||
|
container.addEventListener('dragstart', (e) => {
|
||||||
|
const card = e.target.closest(sel);
|
||||||
|
if (!card) { e.preventDefault(); return; }
|
||||||
|
|
||||||
|
// Grid items must be selected to start dragging
|
||||||
|
if (container === grid &&
|
||||||
|
!card.classList.contains('selected')) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = itemInfo(card);
|
||||||
|
if (!info) { e.preventDefault(); return; }
|
||||||
|
|
||||||
|
e.dataTransfer.setData('text/plain', info.id);
|
||||||
|
if (info.type === 'folder') {
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
'application/oxicloud-folder', 'true');
|
||||||
|
}
|
||||||
|
card.classList.add('dragging');
|
||||||
|
});
|
||||||
|
|
||||||
|
// dragend
|
||||||
|
container.addEventListener('dragend', (e) => {
|
||||||
|
const card = e.target.closest(sel);
|
||||||
|
if (card) card.classList.remove('dragging');
|
||||||
|
document.querySelectorAll('.drop-target')
|
||||||
|
.forEach(el => el.classList.remove('drop-target'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// dragover – only folders are valid drop targets
|
||||||
|
container.addEventListener('dragover', (e) => {
|
||||||
|
const card = e.target.closest(sel);
|
||||||
|
if (!card || card.dataset.fileId) return;
|
||||||
|
if (!card.dataset.folderId) return;
|
||||||
|
e.preventDefault();
|
||||||
|
card.classList.add('drop-target');
|
||||||
|
});
|
||||||
|
|
||||||
|
// dragleave
|
||||||
|
container.addEventListener('dragleave', (e) => {
|
||||||
|
const card = e.target.closest(sel);
|
||||||
|
if (!card || card.dataset.fileId) return;
|
||||||
|
card.classList.remove('drop-target');
|
||||||
|
});
|
||||||
|
|
||||||
|
// drop – only folders accept drops
|
||||||
|
container.addEventListener('drop', async (e) => {
|
||||||
|
const card = e.target.closest(sel);
|
||||||
|
if (!card || card.dataset.fileId) return;
|
||||||
|
const targetFolderId = card.dataset.folderId;
|
||||||
|
if (!targetFolderId) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
card.classList.remove('drop-target');
|
||||||
|
|
||||||
|
const id = e.dataTransfer.getData('text/plain');
|
||||||
|
const isFolder =
|
||||||
|
e.dataTransfer.getData('application/oxicloud-folder') === 'true';
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
if (isFolder) {
|
||||||
|
if (id === targetFolderId) {
|
||||||
|
alert("You cannot move a folder to itself");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fileOps.moveFolder(id, targetFolderId);
|
||||||
|
} else {
|
||||||
|
await fileOps.moveFile(id, targetFolderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
console.log(`Adding folder to the view: ${folder.name} (${folder.id})`);
|
|
||||||
|
|
||||||
// Grid view element
|
|
||||||
const folderGridElement = document.createElement('div');
|
|
||||||
folderGridElement.className = 'file-card';
|
|
||||||
folderGridElement.dataset.folderId = folder.id;
|
|
||||||
folderGridElement.dataset.folderName = folder.name;
|
|
||||||
folderGridElement.dataset.parentId = folder.parent_id || "";
|
|
||||||
|
|
||||||
// Check if folder is a favorite
|
/* ================================================================
|
||||||
const isFolderFav = window.favorites && window.favorites.isFavorite(folder.id, 'folder');
|
* Pure element-creation helpers (no addEventListener)
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
folderGridElement.innerHTML = `
|
/** Create a grid card for a folder */
|
||||||
|
_createFolderCard(folder) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'file-card';
|
||||||
|
el.dataset.folderId = folder.id;
|
||||||
|
el.dataset.folderName = folder.name;
|
||||||
|
el.dataset.parentId = folder.parent_id || "";
|
||||||
|
|
||||||
|
const isFav = window.favorites &&
|
||||||
|
window.favorites.isFavorite(folder.id, 'folder');
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
<div class="file-card-checkbox"><i class="fas fa-check"></i></div>
|
<div class="file-card-checkbox"><i class="fas fa-check"></i></div>
|
||||||
<button class="file-card-more"><i class="fas fa-ellipsis-v"></i></button>
|
<button class="file-card-more"><i class="fas fa-ellipsis-v"></i></button>
|
||||||
${isFolderFav ? '<div class="favorite-star active"><i class="fas fa-star"></i></div>' : ''}
|
${isFav ? '<div class="favorite-star active"><i class="fas fa-star"></i></div>' : ''}
|
||||||
<div class="file-icon folder-icon">
|
<div class="file-icon folder-icon">
|
||||||
<i class="fas fa-folder"></i>
|
<i class="fas fa-folder"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -708,245 +931,59 @@ const ui = {
|
|||||||
<div class="file-info">Folder</div>
|
<div class="file-info">Folder</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Drag and drop setup for folders
|
|
||||||
if (window.app.currentPath !== "") {
|
if (window.app.currentPath !== "") {
|
||||||
folderGridElement.setAttribute('draggable', 'true');
|
el.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
folderGridElement.addEventListener('dragstart', (e) => {
|
|
||||||
if (!folderGridElement.classList.contains('selected')) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.dataTransfer.setData('text/plain', folder.id);
|
|
||||||
e.dataTransfer.setData('application/oxicloud-folder', 'true');
|
|
||||||
folderGridElement.classList.add('dragging');
|
|
||||||
});
|
|
||||||
|
|
||||||
folderGridElement.addEventListener('dragend', () => {
|
|
||||||
folderGridElement.classList.remove('dragging');
|
|
||||||
document.querySelectorAll('.drop-target').forEach(el => {
|
|
||||||
el.classList.remove('drop-target');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
// Single click to select, double click to navigate
|
/** Create a list row for a folder */
|
||||||
folderGridElement.addEventListener('click', (e) => {
|
_createFolderItem(folder) {
|
||||||
if (e.target.closest('.file-card-more') || e.target.closest('.file-card-checkbox')) return;
|
const el = document.createElement('div');
|
||||||
toggleCardSelection(folderGridElement, e);
|
el.className = 'file-item';
|
||||||
});
|
el.dataset.folderId = folder.id;
|
||||||
|
el.dataset.folderName = folder.name;
|
||||||
folderGridElement.addEventListener('dblclick', () => {
|
el.dataset.parentId = folder.parent_id || "";
|
||||||
window.app.currentPath = folder.id;
|
|
||||||
this.updateBreadcrumb(folder.name);
|
|
||||||
window.loadFiles();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Checkbox click
|
|
||||||
folderGridElement.querySelector('.file-card-checkbox').addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleCardSelection(folderGridElement, e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// More actions button
|
|
||||||
folderGridElement.querySelector('.file-card-more').addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
window.app.contextMenuTargetFolder = {
|
|
||||||
id: folder.id,
|
|
||||||
name: folder.name,
|
|
||||||
parent_id: folder.parent_id || ""
|
|
||||||
};
|
|
||||||
showContextMenuAtElement(e.currentTarget, 'folder-context-menu');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Context menu
|
|
||||||
folderGridElement.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
window.app.contextMenuTargetFolder = {
|
|
||||||
id: folder.id,
|
|
||||||
name: folder.name,
|
|
||||||
parent_id: folder.parent_id || ""
|
|
||||||
};
|
|
||||||
|
|
||||||
let folderContextMenu = document.getElementById('folder-context-menu');
|
|
||||||
folderContextMenu.style.left = `${e.pageX}px`;
|
|
||||||
folderContextMenu.style.top = `${e.pageY}px`;
|
|
||||||
folderContextMenu.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drop target setup
|
|
||||||
folderGridElement.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
folderGridElement.classList.add('drop-target');
|
|
||||||
});
|
|
||||||
|
|
||||||
folderGridElement.addEventListener('dragleave', () => {
|
|
||||||
folderGridElement.classList.remove('drop-target');
|
|
||||||
});
|
|
||||||
|
|
||||||
folderGridElement.addEventListener('drop', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
folderGridElement.classList.remove('drop-target');
|
|
||||||
|
|
||||||
const id = e.dataTransfer.getData('text/plain');
|
|
||||||
const isFolder = e.dataTransfer.getData('application/oxicloud-folder') === 'true';
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
if (isFolder) {
|
|
||||||
if (id === folder.id) {
|
|
||||||
alert("You cannot move a folder to itself");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fileOps.moveFolder(id, folder.id);
|
|
||||||
} else {
|
|
||||||
await fileOps.moveFile(id, folder.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('files-grid').appendChild(folderGridElement);
|
|
||||||
|
|
||||||
// List view element - Improved
|
|
||||||
const folderListElement = document.createElement('div');
|
|
||||||
folderListElement.className = 'file-item';
|
|
||||||
folderListElement.dataset.folderId = folder.id;
|
|
||||||
folderListElement.dataset.folderName = folder.name;
|
|
||||||
folderListElement.dataset.parentId = folder.parent_id || "";
|
|
||||||
|
|
||||||
|
const isFav = window.favorites &&
|
||||||
|
window.favorites.isFavorite(folder.id, 'folder');
|
||||||
const formattedDate = window.formatDateTime(folder.modified_at);
|
const formattedDate = window.formatDateTime(folder.modified_at);
|
||||||
|
|
||||||
// Make draggable if not in root
|
|
||||||
if (window.app.currentPath !== "") {
|
if (window.app.currentPath !== "") {
|
||||||
folderListElement.setAttribute('draggable', 'true');
|
el.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
folderListElement.addEventListener('dragstart', (e) => {
|
|
||||||
e.dataTransfer.setData('text/plain', folder.id);
|
|
||||||
e.dataTransfer.setData('application/oxicloud-folder', 'true');
|
|
||||||
folderListElement.classList.add('dragging');
|
|
||||||
});
|
|
||||||
|
|
||||||
folderListElement.addEventListener('dragend', () => {
|
|
||||||
folderListElement.classList.remove('dragging');
|
|
||||||
document.querySelectorAll('.drop-target').forEach(el => {
|
|
||||||
el.classList.remove('drop-target');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Improved: Structure and classes for list view
|
el.innerHTML = `
|
||||||
folderListElement.innerHTML = `
|
|
||||||
<div class="list-item-checkbox"><input type="checkbox" class="item-checkbox"></div>
|
<div class="list-item-checkbox"><input type="checkbox" class="item-checkbox"></div>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<div class="file-icon folder-icon">
|
<div class="file-icon folder-icon">
|
||||||
<i class="fas fa-folder"></i>
|
<i class="fas fa-folder"></i>
|
||||||
</div>
|
</div>
|
||||||
<span>${escapeHtml(folder.name)}</span>
|
<span>${escapeHtml(folder.name)}</span>
|
||||||
${isFolderFav ? '<i class="fas fa-star favorite-star-inline"></i>' : ''}
|
${isFav ? '<i class="fas fa-star favorite-star-inline"></i>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="type-cell">${window.i18n ? window.i18n.t('files.file_types.folder') : 'Folder'}</div>
|
<div class="type-cell">${window.i18n ? window.i18n.t('files.file_types.folder') : 'Folder'}</div>
|
||||||
<div class="size-cell">--</div>
|
<div class="size-cell">--</div>
|
||||||
<div class="date-cell">${formattedDate}</div>
|
<div class="date-cell">${formattedDate}</div>
|
||||||
`;
|
`;
|
||||||
|
return el;
|
||||||
// Checkbox click in list view
|
|
||||||
folderListElement.querySelector('.item-checkbox').addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleCardSelection(folderListElement, e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click to navigate
|
|
||||||
folderListElement.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.list-item-checkbox')) return;
|
|
||||||
window.app.currentPath = folder.id;
|
|
||||||
this.updateBreadcrumb(folder.name);
|
|
||||||
window.loadFiles();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Context menu
|
|
||||||
folderListElement.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
window.app.contextMenuTargetFolder = {
|
|
||||||
id: folder.id,
|
|
||||||
name: folder.name,
|
|
||||||
parent_id: folder.parent_id || ""
|
|
||||||
};
|
|
||||||
|
|
||||||
let folderContextMenu = document.getElementById('folder-context-menu');
|
|
||||||
folderContextMenu.style.left = `${e.pageX}px`;
|
|
||||||
folderContextMenu.style.top = `${e.pageY}px`;
|
|
||||||
folderContextMenu.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drop target setup for list view
|
|
||||||
folderListElement.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
folderListElement.classList.add('drop-target');
|
|
||||||
});
|
|
||||||
|
|
||||||
folderListElement.addEventListener('dragleave', () => {
|
|
||||||
folderListElement.classList.remove('drop-target');
|
|
||||||
});
|
|
||||||
|
|
||||||
folderListElement.addEventListener('drop', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
folderListElement.classList.remove('drop-target');
|
|
||||||
|
|
||||||
const id = e.dataTransfer.getData('text/plain');
|
|
||||||
const isFolder = e.dataTransfer.getData('application/oxicloud-folder') === 'true';
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
if (isFolder) {
|
|
||||||
if (id === folder.id) {
|
|
||||||
alert("You cannot move a folder to itself");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fileOps.moveFolder(id, folder.id);
|
|
||||||
} else {
|
|
||||||
await fileOps.moveFile(id, folder.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('files-list-view').appendChild(folderListElement);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/** Create a grid card for a file */
|
||||||
* Add file to the view
|
_createFileCard(file) {
|
||||||
* @param {Object} file - File object
|
|
||||||
*/
|
|
||||||
addFileToView(file) {
|
|
||||||
// Check if the file already exists in the view to avoid duplicates
|
|
||||||
if (document.querySelector(`.file-card[data-file-id="${file.id}"]`) ||
|
|
||||||
document.querySelector(`.file-item[data-file-id="${file.id}"]`)) {
|
|
||||||
console.log(`File ${file.name} (${file.id}) already exists in the view, not duplicating`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Adding file to the view: ${file.name} (${file.id})`);
|
|
||||||
|
|
||||||
// Use pre-computed display fields from the DTO when available
|
|
||||||
const iconClass = file.icon_class || 'fas fa-file';
|
const iconClass = file.icon_class || 'fas fa-file';
|
||||||
const iconSpecialClass = file.icon_special_class || '';
|
const isFileFav = window.favorites &&
|
||||||
const cat = file.category || '';
|
window.favorites.isFavorite(file.id, 'file');
|
||||||
const typeLabel = cat
|
|
||||||
? (window.i18n ? window.i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : cat)
|
|
||||||
: (window.i18n ? window.i18n.t('files.file_types.document') : 'Document');
|
|
||||||
|
|
||||||
// Format size and date — prefer DTO fields, fall back to computation
|
|
||||||
const fileSize = file.size_formatted || window.formatFileSize(file.size);
|
|
||||||
const formattedDate = window.formatDateTime(file.modified_at);
|
const formattedDate = window.formatDateTime(file.modified_at);
|
||||||
|
|
||||||
// Grid view element
|
const el = document.createElement('div');
|
||||||
const fileGridElement = document.createElement('div');
|
el.className = 'file-card';
|
||||||
fileGridElement.className = 'file-card';
|
el.dataset.fileId = file.id;
|
||||||
|
el.dataset.fileName = file.name;
|
||||||
|
el.dataset.folderId = file.folder_id || "";
|
||||||
|
el.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
// Check if file is a favorite
|
el.innerHTML = `
|
||||||
const isFileFav = window.favorites && window.favorites.isFavorite(file.id, 'file');
|
|
||||||
|
|
||||||
fileGridElement.innerHTML = `
|
|
||||||
<div class="file-card-checkbox"><i class="fas fa-check"></i></div>
|
<div class="file-card-checkbox"><i class="fas fa-check"></i></div>
|
||||||
<button class="file-card-more"><i class="fas fa-ellipsis-v"></i></button>
|
<button class="file-card-more"><i class="fas fa-ellipsis-v"></i></button>
|
||||||
${isFileFav ? '<div class="favorite-star active"><i class="fas fa-star"></i></div>' : ''}
|
${isFileFav ? '<div class="favorite-star active"><i class="fas fa-star"></i></div>' : ''}
|
||||||
@@ -956,102 +993,34 @@ const ui = {
|
|||||||
<div class="file-name">${escapeHtml(file.name)}</div>
|
<div class="file-name">${escapeHtml(file.name)}</div>
|
||||||
<div class="file-info">Modified ${formattedDate.split(' ')[0]}</div>
|
<div class="file-info">Modified ${formattedDate.split(' ')[0]}</div>
|
||||||
`;
|
`;
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
fileGridElement.dataset.fileId = file.id;
|
/** Create a list row for a file */
|
||||||
fileGridElement.dataset.fileName = file.name;
|
_createFileItem(file) {
|
||||||
fileGridElement.dataset.folderId = file.folder_id || "";
|
const iconClass = file.icon_class || 'fas fa-file';
|
||||||
|
const iconSpecialClass = file.icon_special_class || '';
|
||||||
|
const cat = file.category || '';
|
||||||
|
const typeLabel = cat
|
||||||
|
? (window.i18n
|
||||||
|
? window.i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat
|
||||||
|
: cat)
|
||||||
|
: (window.i18n
|
||||||
|
? window.i18n.t('files.file_types.document')
|
||||||
|
: 'Document');
|
||||||
|
const fileSize = file.size_formatted || window.formatFileSize(file.size);
|
||||||
|
const formattedDate = window.formatDateTime(file.modified_at);
|
||||||
|
const isFileFav = window.favorites &&
|
||||||
|
window.favorites.isFavorite(file.id, 'file');
|
||||||
|
|
||||||
// Make draggable
|
const el = document.createElement('div');
|
||||||
fileGridElement.setAttribute('draggable', 'true');
|
el.className = 'file-item';
|
||||||
|
el.dataset.fileId = file.id;
|
||||||
|
el.dataset.fileName = file.name;
|
||||||
|
el.dataset.folderId = file.folder_id || "";
|
||||||
|
el.setAttribute('draggable', 'true');
|
||||||
|
|
||||||
fileGridElement.addEventListener('dragstart', (e) => {
|
el.innerHTML = `
|
||||||
if (!fileGridElement.classList.contains('selected')) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.dataTransfer.setData('text/plain', file.id);
|
|
||||||
fileGridElement.classList.add('dragging');
|
|
||||||
});
|
|
||||||
|
|
||||||
fileGridElement.addEventListener('dragend', () => {
|
|
||||||
fileGridElement.classList.remove('dragging');
|
|
||||||
document.querySelectorAll('.drop-target').forEach(el => {
|
|
||||||
el.classList.remove('drop-target');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Single click = select, double click = open/download
|
|
||||||
fileGridElement.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.file-card-more') || e.target.closest('.file-card-checkbox')) return;
|
|
||||||
toggleCardSelection(fileGridElement, e);
|
|
||||||
});
|
|
||||||
|
|
||||||
fileGridElement.addEventListener('dblclick', () => {
|
|
||||||
// Track this file access for recent files
|
|
||||||
if (window.recent) {
|
|
||||||
document.dispatchEvent(new CustomEvent('file-accessed', {
|
|
||||||
detail: { file }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a viewable file type
|
|
||||||
if (this.isViewableFile(file)) {
|
|
||||||
if (window.inlineViewer) {
|
|
||||||
window.inlineViewer.openFile(file);
|
|
||||||
} else if (window.fileViewer) {
|
|
||||||
window.fileViewer.open(file);
|
|
||||||
} else {
|
|
||||||
window.fileOps.downloadFile(file.id, file.name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
window.fileOps.downloadFile(file.id, file.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Checkbox click
|
|
||||||
fileGridElement.querySelector('.file-card-checkbox').addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleCardSelection(fileGridElement, e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// More actions button
|
|
||||||
fileGridElement.querySelector('.file-card-more').addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
window.app.contextMenuTargetFile = {
|
|
||||||
id: file.id,
|
|
||||||
name: file.name,
|
|
||||||
folder_id: file.folder_id || ""
|
|
||||||
};
|
|
||||||
showContextMenuAtElement(e.currentTarget, 'file-context-menu');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Context menu
|
|
||||||
fileGridElement.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
window.app.contextMenuTargetFile = {
|
|
||||||
id: file.id,
|
|
||||||
name: file.name,
|
|
||||||
folder_id: file.folder_id || ""
|
|
||||||
};
|
|
||||||
|
|
||||||
let fileContextMenu = document.getElementById('file-context-menu');
|
|
||||||
fileContextMenu.style.left = `${e.pageX}px`;
|
|
||||||
fileContextMenu.style.top = `${e.pageY}px`;
|
|
||||||
fileContextMenu.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('files-grid').appendChild(fileGridElement);
|
|
||||||
|
|
||||||
// List view element - Improved with specific classes and enhanced layout
|
|
||||||
const fileListElement = document.createElement('div');
|
|
||||||
fileListElement.className = 'file-item';
|
|
||||||
fileListElement.dataset.fileId = file.id;
|
|
||||||
fileListElement.dataset.fileName = file.name;
|
|
||||||
fileListElement.dataset.folderId = file.folder_id || "";
|
|
||||||
|
|
||||||
fileListElement.innerHTML = `
|
|
||||||
<div class="list-item-checkbox"><input type="checkbox" class="item-checkbox"></div>
|
<div class="list-item-checkbox"><input type="checkbox" class="item-checkbox"></div>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<div class="file-icon ${iconSpecialClass}">
|
<div class="file-icon ${iconSpecialClass}">
|
||||||
@@ -1064,73 +1033,95 @@ const ui = {
|
|||||||
<div class="size-cell">${fileSize}</div>
|
<div class="size-cell">${fileSize}</div>
|
||||||
<div class="date-cell">${formattedDate}</div>
|
<div class="date-cell">${formattedDate}</div>
|
||||||
`;
|
`;
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
// Checkbox click in list view
|
/* ================================================================
|
||||||
fileListElement.querySelector('.item-checkbox').addEventListener('click', (e) => {
|
* Batch rendering with DocumentFragment
|
||||||
e.stopPropagation();
|
* ================================================================ */
|
||||||
toggleCardSelection(fileListElement, e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make draggable (list view)
|
/**
|
||||||
fileListElement.setAttribute('draggable', 'true');
|
* Render an array of folders into both grid and list views
|
||||||
|
* using DocumentFragment for minimal reflows.
|
||||||
|
*/
|
||||||
|
renderFolders(folders) {
|
||||||
|
if (!this._delegationReady) this.initDelegation();
|
||||||
|
const gridFrag = document.createDocumentFragment();
|
||||||
|
const listFrag = document.createDocumentFragment();
|
||||||
|
|
||||||
fileListElement.addEventListener('dragstart', (e) => {
|
for (const folder of folders) {
|
||||||
e.dataTransfer.setData('text/plain', file.id);
|
this._items.set(folder.id, folder);
|
||||||
fileListElement.classList.add('dragging');
|
gridFrag.appendChild(this._createFolderCard(folder));
|
||||||
});
|
listFrag.appendChild(this._createFolderItem(folder));
|
||||||
|
}
|
||||||
|
|
||||||
fileListElement.addEventListener('dragend', () => {
|
document.getElementById('files-grid').appendChild(gridFrag);
|
||||||
fileListElement.classList.remove('dragging');
|
document.getElementById('files-list-view').appendChild(listFrag);
|
||||||
document.querySelectorAll('.drop-target').forEach(el => {
|
},
|
||||||
el.classList.remove('drop-target');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// View or download on click
|
/**
|
||||||
fileListElement.addEventListener('click', (e) => {
|
* Render an array of files into both grid and list views
|
||||||
if (e.target.closest('.list-item-checkbox')) return;
|
* using DocumentFragment for minimal reflows.
|
||||||
// Track this file access for recent files
|
*/
|
||||||
if (window.recent) {
|
renderFiles(files) {
|
||||||
document.dispatchEvent(new CustomEvent('file-accessed', {
|
if (!this._delegationReady) this.initDelegation();
|
||||||
detail: { file }
|
const gridFrag = document.createDocumentFragment();
|
||||||
}));
|
const listFrag = document.createDocumentFragment();
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a viewable file type
|
|
||||||
if (this.isViewableFile(file)) {
|
|
||||||
// Open in the inline viewer
|
|
||||||
if (window.inlineViewer) {
|
|
||||||
window.inlineViewer.openFile(file);
|
|
||||||
} else if (window.fileViewer) {
|
|
||||||
// Fallback to standard file viewer
|
|
||||||
window.fileViewer.open(file);
|
|
||||||
} else {
|
|
||||||
// No viewer available, download directly
|
|
||||||
window.fileOps.downloadFile(file.id, file.name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For other file types, download directly
|
|
||||||
window.fileOps.downloadFile(file.id, file.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Context menu (list view)
|
for (const file of files) {
|
||||||
fileListElement.addEventListener('contextmenu', (e) => {
|
this._items.set(file.id, file);
|
||||||
e.preventDefault();
|
gridFrag.appendChild(this._createFileCard(file));
|
||||||
|
listFrag.appendChild(this._createFileItem(file));
|
||||||
|
}
|
||||||
|
|
||||||
window.app.contextMenuTargetFile = {
|
document.getElementById('files-grid').appendChild(gridFrag);
|
||||||
id: file.id,
|
document.getElementById('files-list-view').appendChild(listFrag);
|
||||||
name: file.name,
|
},
|
||||||
folder_id: file.folder_id || ""
|
|
||||||
};
|
|
||||||
|
|
||||||
let fileContextMenu = document.getElementById('file-context-menu');
|
/* ================================================================
|
||||||
fileContextMenu.style.left = `${e.pageX}px`;
|
* Single-item add (backward-compatible API for post-upload, etc.)
|
||||||
fileContextMenu.style.top = `${e.pageY}px`;
|
* ================================================================ */
|
||||||
fileContextMenu.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('files-list-view').appendChild(fileListElement);
|
/**
|
||||||
|
* Add a single folder to both views.
|
||||||
|
* @param {Object} folder - Folder object
|
||||||
|
*/
|
||||||
|
addFolderToView(folder) {
|
||||||
|
if (!this._delegationReady) this.initDelegation();
|
||||||
|
|
||||||
|
// Duplicate guard
|
||||||
|
if (document.querySelector(`.file-card[data-folder-id="${folder.id}"]`) ||
|
||||||
|
document.querySelector(`.file-item[data-folder-id="${folder.id}"]`)) {
|
||||||
|
console.log(`Folder ${folder.name} (${folder.id}) already exists in the view, not duplicating`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._items.set(folder.id, folder);
|
||||||
|
document.getElementById('files-grid')
|
||||||
|
.appendChild(this._createFolderCard(folder));
|
||||||
|
document.getElementById('files-list-view')
|
||||||
|
.appendChild(this._createFolderItem(folder));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single file to both views.
|
||||||
|
* @param {Object} file - File object
|
||||||
|
*/
|
||||||
|
addFileToView(file) {
|
||||||
|
if (!this._delegationReady) this.initDelegation();
|
||||||
|
|
||||||
|
// Duplicate guard
|
||||||
|
if (document.querySelector(`.file-card[data-file-id="${file.id}"]`) ||
|
||||||
|
document.querySelector(`.file-item[data-file-id="${file.id}"]`)) {
|
||||||
|
console.log(`File ${file.name} (${file.id}) already exists in the view, not duplicating`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._items.set(file.id, file);
|
||||||
|
document.getElementById('files-grid')
|
||||||
|
.appendChild(this._createFileCard(file));
|
||||||
|
document.getElementById('files-list-view')
|
||||||
|
.appendChild(this._createFileItem(file));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user