Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼 사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드, 인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포. - archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram - archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트) - archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수 - archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡 - 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널 Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
134 lines
4.1 KiB
Rust
134 lines
4.1 KiB
Rust
//! 로컬 파일시스템 백엔드 — OS가 마운트한 네트워크 드라이브(UNC, Z:\, /Volumes)도
|
|
//! 이 백엔드로 처리된다.
|
|
|
|
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
|
use std::ops::Range;
|
|
use std::path::Path;
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
|
|
|
pub struct LocalFs;
|
|
|
|
impl LocalFs {
|
|
pub fn new() -> Self {
|
|
LocalFs
|
|
}
|
|
}
|
|
|
|
impl Default for LocalFs {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
fn entry_from_meta(name: String, meta: &std::fs::Metadata) -> VEntry {
|
|
VEntry {
|
|
name,
|
|
is_dir: meta.is_dir(),
|
|
size: meta.len(),
|
|
mtime_ms: meta
|
|
.modified()
|
|
.map(crate::system_time_to_ms)
|
|
.unwrap_or(0),
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl VfsProvider for LocalFs {
|
|
fn capabilities(&self) -> Caps {
|
|
Caps::RANGE_READ
|
|
| Caps::SERVER_MOVE
|
|
| Caps::NATIVE_TRASH
|
|
| Caps::MTIME_WRITE
|
|
| Caps::PARALLEL_READS
|
|
| Caps::WATCHABLE
|
|
}
|
|
|
|
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
|
let mut rd = tokio::fs::read_dir(path).await?;
|
|
let mut out = Vec::new();
|
|
while let Some(e) = rd.next_entry().await? {
|
|
let meta = match e.metadata().await {
|
|
Ok(m) => m,
|
|
Err(_) => continue, // 접근 불가 항목은 건너뜀
|
|
};
|
|
out.push(entry_from_meta(e.file_name().to_string_lossy().into_owned(), &meta));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
async fn stat(&self, path: &str) -> Result<VEntry> {
|
|
let meta = tokio::fs::metadata(path).await?;
|
|
let name = Path::new(path)
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
Ok(entry_from_meta(name, &meta))
|
|
}
|
|
|
|
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
|
let mut file = tokio::fs::File::open(path).await?;
|
|
match range {
|
|
None => Ok(Box::new(file)),
|
|
Some(r) => {
|
|
file.seek(std::io::SeekFrom::Start(r.start)).await?;
|
|
let len = r.end.saturating_sub(r.start);
|
|
Ok(Box::new(file.take(len)))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
|
tokio::fs::rename(from, to).await.map_err(Into::into)
|
|
}
|
|
|
|
async fn delete(&self, path: &str) -> Result<()> {
|
|
// 주의: 하드 삭제. 휴지통 삭제는 fileops 계층(trash 크레이트)이 담당하고,
|
|
// VFS delete는 앱 레벨 휴지통 rename 이후의 퍼지 등 내부 용도로만 쓴다.
|
|
let meta = tokio::fs::metadata(path).await?;
|
|
if meta.is_dir() {
|
|
return Err(VfsError::Protocol("디렉터리 삭제는 지원하지 않음".into()));
|
|
}
|
|
tokio::fs::remove_file(path).await.map_err(Into::into)
|
|
}
|
|
|
|
async fn mkdir(&self, path: &str) -> Result<()> {
|
|
tokio::fs::create_dir_all(path).await.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
#[tokio::test]
|
|
async fn range_read_returns_exact_window() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let p = dir.path().join("a.bin");
|
|
std::fs::write(&p, b"0123456789").unwrap();
|
|
|
|
let fs = LocalFs::new();
|
|
let mut r = fs
|
|
.open_range(p.to_str().unwrap(), Some(2..6))
|
|
.await
|
|
.unwrap();
|
|
let mut buf = Vec::new();
|
|
r.read_to_end(&mut buf).await.unwrap();
|
|
assert_eq!(&buf, b"2345");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_dir_and_stat() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("한글파일.jpg"), b"xx").unwrap();
|
|
std::fs::create_dir(dir.path().join("sub")).unwrap();
|
|
|
|
let fs = LocalFs::new();
|
|
let entries = fs.list_dir(dir.path().to_str().unwrap()).await.unwrap();
|
|
assert_eq!(entries.len(), 2);
|
|
let file = entries.iter().find(|e| !e.is_dir).unwrap();
|
|
assert_eq!(file.name, "한글파일.jpg");
|
|
assert_eq!(file.size, 2);
|
|
}
|
|
}
|