Merge pull request #14 from Trivernis/develop

Version 1.0.0-rc.1
pull/30/head v1.0.0-rc.1
Julius Riegel 3 years ago committed by GitHub
commit 6f39780430
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,15 @@
version = 1
[[analyzers]]
name = "javascript"
enabled = true
[analyzers.meta]
plugins = ["angular"]
[[analyzers]]
name = "rust"
enabled = true
[analyzers.meta]
msrv = "1.30.0"

@ -1,6 +1,6 @@
[package]
name = "mediarepo-api"
version = "0.28.1"
version = "0.31.0"
edition = "2018"
license = "gpl-3"
@ -15,12 +15,12 @@ serde_json = { version = "1.0.78", optional = true }
directories = { version = "4.0.1", optional = true }
mime_guess = { version = "2.0.3", optional = true }
serde_piecewise_default = "0.2.0"
futures = { version = "0.3.19", optional = true }
futures = { version = "0.3.21", optional = true }
url = { version = "2.2.2", optional = true }
pathsearch = { version = "0.2.0", optional = true }
[dependencies.bromine]
version = "0.17.1"
version = "0.18.1"
optional = true
features = ["serialize_bincode"]

@ -10,3 +10,6 @@ pub enum ApiError {
#[error("The servers api version (version {server:?}) is incompatible with the api client {client:?}")]
VersionMismatch { server: String, client: String },
}
unsafe impl Send for ApiError {}
unsafe impl Sync for ApiError {}

@ -153,8 +153,11 @@ impl FileApi {
/// Permanently deletes a file from the disk and database
#[tracing::instrument(level = "debug", skip(self))]
pub async fn delete_file(&self, file_id: FileIdentifier) -> ApiResult<()> {
self.emit_and_get("delete_file", file_id, Some(Duration::from_secs(10)))
.await
self.emit("delete_file", file_id)
.await_reply()
.await?;
Ok(())
}
/// Returns a list of all thumbnails of the file
@ -198,7 +201,7 @@ impl FileApi {
/// Deletes all thumbnails of a file to regenerate them when requested
#[tracing::instrument(level = "debug", skip(self))]
pub async fn delete_thumbnails(&self, file_id: FileIdentifier) -> ApiResult<()> {
self.emit("delete_thumbnails", file_id).await?;
self.emit("delete_thumbnails", file_id).await_reply().await?;
Ok(())
}

@ -26,15 +26,12 @@ impl JobApi {
/// Runs a job of the given type and returns when it has finished
#[tracing::instrument(level = "debug", skip(self))]
pub async fn run_job(&self, job_type: JobType) -> ApiResult<()> {
self.emit_and_get(
"run_job",
RunJobRequest {
job_type,
sync: true,
},
Some(Duration::from_secs(3600)),
)
.await
pub async fn run_job(&self, job_type: JobType, sync: bool) -> ApiResult<()> {
self.emit("run_job", RunJobRequest { job_type, sync })
.await_reply()
.with_timeout(Duration::from_secs(3600))
.await?;
Ok(())
}
}

@ -13,8 +13,8 @@ use crate::client_api::repo::RepoApi;
use crate::client_api::tag::TagApi;
use crate::types::misc::{check_apis_compatible, get_api_version, InfoResponse};
use async_trait::async_trait;
use bromine::ipc::stream_emitter::EmitMetadata;
use bromine::prelude::*;
use bromine::prelude::emit_metadata::EmitMetadata;
use tokio::time::Duration;
use crate::client_api::preset::PresetApi;
@ -84,12 +84,14 @@ impl ApiClient {
pub async fn connect<L: AsyncStreamProtocolListener>(
address: L::AddressType,
) -> ApiResult<Self> {
tracing::debug!("Connecting to {:?}", address);
let ctx = IPCBuilder::<L>::new()
.address(address)
.timeout(Duration::from_secs(10))
.build_pooled_client(8)
.await?;
let client = Self::new(ctx);
tracing::debug!("Retrieving info on daemon version...");
let info = client.info().await?;
let server_api_version = info.api_version();
@ -113,6 +115,8 @@ impl ApiClient {
pub async fn info(&self) -> ApiResult<InfoResponse> {
let ctx = self.ctx.acquire();
let res = ctx.emit("info", ()).await_reply().await?;
tracing::trace!("Got info event {:?}", res);
Ok(res.payload::<InfoResponse>()?)
}

@ -1,8 +1,8 @@
use std::time::Duration;
use bromine::prelude::*;
use crate::client_api::error::ApiResult;
use crate::types::filtering::{SortingPreset, SortKey};
use super::IPCApi;
use crate::client_api::error::ApiResult;
use crate::types::filtering::{SortKey, SortingPreset};
use bromine::prelude::*;
use std::time::Duration;
#[derive(Clone)]
pub struct PresetApi {
@ -27,28 +27,22 @@ impl PresetApi {
/// Returns all sorting presets of the repository
#[tracing::instrument(level = "debug", skip(self))]
pub async fn all_sorting_presets(&self) -> ApiResult<Vec<SortingPreset>> {
self.emit_and_get(
"all_sorting_presets",
(),
Some(Duration::from_secs(1))
)
self.emit_and_get("all_sorting_presets", (), Some(Duration::from_secs(1)))
.await
}
/// Adds a new sorting preset with the given keys
#[tracing::instrument(level = "debug", skip(self))]
pub async fn add_sorting_preset(&self, keys: Vec<SortKey>) -> ApiResult<SortingPreset> {
self.emit_and_get(
"add_sorting_preset",
keys,
Some(Duration::from_secs(1))
)
self.emit_and_get("add_sorting_preset", keys, Some(Duration::from_secs(1)))
.await
}
/// Deletes a given sorting preset by id
#[tracing::instrument(level = "debug", skip(self))]
pub async fn delete_sorting_preset(&self, id: i32) -> ApiResult<()> {
self.emit_and_get("delete_sorting_preset", id, Some(Duration::from_secs(1))).await
self.emit("delete_sorting_preset", id).await_reply().await?;
Ok(())
}
}

@ -0,0 +1,158 @@
use crate::tauri_plugin::error::{PluginError, PluginResult};
use futures::future;
use std::collections::HashMap;
use std::fmt::{Debug, Formatter};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use std::{mem, thread};
use tokio::sync::{Mutex, RwLock};
#[derive(Clone, Debug)]
pub struct TaskContext {
tasks: Arc<RwLock<HashMap<String, AsyncTask>>>,
}
impl TaskContext {
pub fn new() -> Self {
Self {
tasks: Default::default(),
}
}
pub async fn add_task<S: ToString, F: 'static + Future<Output = PluginResult<()>>>(
&self,
name: S,
task: F,
) {
self.tasks
.write()
.await
.insert(name.to_string(), AsyncTask::new(task));
}
pub async fn task_state<S: AsRef<str>>(&self, name: S) -> Option<TaskState> {
let state = {
let tasks = self.tasks.read().await;
if let Some(task) = tasks.get(name.as_ref()) {
Some(task.state().await)
} else {
None
}
};
if let Some(TaskState::Finished) = state {
self.tasks.write().await.remove(name.as_ref());
}
state
}
/// Returns all tasks queued for execution
async fn queued_tasks(&self) -> Vec<AsyncTask> {
let task_map = self.tasks.read().await;
let mut tasks = Vec::new();
for task in task_map.values() {
if task.state().await == TaskState::Queued {
tasks.push(task.clone());
}
}
tasks
}
}
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
pub enum TaskState {
Queued,
Running,
Finished,
Error,
}
impl TaskState {
pub fn error(&self) -> bool {
*self == TaskState::Error
}
}
#[derive(Clone)]
pub struct AsyncTask {
state: Arc<RwLock<TaskState>>,
inner: Arc<Mutex<Option<Pin<Box<dyn Future<Output = PluginResult<()>>>>>>>,
error: Arc<RwLock<Option<PluginError>>>,
}
impl Debug for AsyncTask {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "AsyncTask (state: {:?})", self.state)
}
}
impl AsyncTask {
pub fn new<F: 'static + Future<Output = PluginResult<()>>>(inner: F) -> Self {
Self {
state: Arc::new(RwLock::new(TaskState::Queued)),
inner: Arc::new(Mutex::new(Some(Box::pin(inner)))),
error: Default::default(),
}
}
pub async fn exec(&self) {
self.set_state(TaskState::Running).await;
let inner = self.inner.lock().await.take();
if let Some(task) = inner {
if let Err(e) = task.await {
let _ = mem::replace(&mut *self.error.write().await, Some(e));
self.set_state(TaskState::Error).await;
} else {
self.set_state(TaskState::Finished).await;
}
} else {
self.set_state(TaskState::Finished).await;
}
}
pub async fn state(&self) -> TaskState {
self.state.read().await.clone()
}
async fn set_state(&self, state: TaskState) {
let _ = mem::replace(&mut *self.state.write().await, state);
}
}
unsafe impl Send for AsyncTask {}
unsafe impl Sync for AsyncTask {}
pub fn start_background_task_runtime(ctx: TaskContext) {
thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.thread_name("background_tasks")
.enable_time()
.build()
.expect("failed to build background task runtime")
.block_on(async move {
tracing::debug!("background task listener ready");
loop {
let tasks = ctx.queued_tasks().await;
if tasks.len() > 0 {
tracing::debug!("executing {} async background tasks", tasks.len());
let start = SystemTime::now();
future::join_all(tasks.iter().map(|t| t.exec())).await;
tracing::debug!(
"background tasks executed in {} ms",
start.elapsed().unwrap().as_millis()
);
} else {
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
});
tracing::error!("background task executor exited!");
});
}

@ -3,9 +3,9 @@ use crate::tauri_plugin::error::PluginResult;
use crate::types::jobs::JobType;
#[tauri::command]
pub async fn run_job(api_state: ApiAccess<'_>, job_type: JobType) -> PluginResult<()> {
pub async fn run_job(api_state: ApiAccess<'_>, job_type: JobType, sync: bool) -> PluginResult<()> {
let api = api_state.api().await?;
api.job.run_job(job_type).await?;
api.job.run_job(job_type, sync).await?;
Ok(())
}

@ -1,3 +1,5 @@
use crate::client_api::ApiClient;
use crate::tauri_plugin::background_tasks::TaskContext;
use crate::tauri_plugin::error::{PluginError, PluginResult};
use crate::tauri_plugin::state::{ApiState, BufferState};
use crate::types::identifier::FileIdentifier;
@ -5,7 +7,7 @@ use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::http::{Request, Response, ResponseBuilder};
use tauri::{AppHandle, Builder, Manager, Runtime};
use tauri::{AppHandle, Builder, Manager, Runtime, State};
use tokio::runtime::{Builder as TokioRuntimeBuilder, Runtime as TokioRuntime};
use url::Url;
@ -54,7 +56,6 @@ fn once_scheme<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Result<Resp
#[tracing::instrument(level = "debug", skip_all)]
async fn content_scheme<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Result<Response> {
let api_state = app.state::<ApiState>();
let buf_state = app.state::<BufferState>();
let hash = request.uri().trim_start_matches("content://");
@ -66,7 +67,10 @@ async fn content_scheme<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Re
.body(buffer.buf)
} else {
tracing::debug!("Fetching content from daemon");
let api_state = app.state::<ApiState>();
let api = api_state.api().await?;
let file = api
.file
.get_file(FileIdentifier::CD(hash.to_string()))
@ -78,16 +82,16 @@ async fn content_scheme<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Re
.await?;
tracing::debug!("Received {} content bytes", bytes.len());
buf_state.add_entry(hash.to_string(), mime.clone(), bytes.clone());
ResponseBuilder::new()
.mimetype(&mime)
.status(200)
.mimetype(&mime)
.body(bytes)
}
}
#[tracing::instrument(level = "debug", skip_all)]
async fn thumb_scheme<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Result<Response> {
let api_state = app.state::<ApiState>();
let buf_state = app.state::<BufferState>();
let url = Url::parse(request.uri())?;
@ -116,26 +120,62 @@ async fn thumb_scheme<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Resu
.mimetype(&buffer.mime)
.body(buffer.buf)
} else {
tracing::debug!("Fetching content from daemon");
tracing::debug!("Content not loaded. Signaling retry.");
let task_ctx = app.state::<TaskContext>();
let state = task_ctx.task_state(request.uri()).await;
if state.is_none() || state.unwrap().error() {
let buf_state = buf_state.inner().clone();
let api_state = app.state::<ApiState>();
let api = api_state.api().await?;
add_fetch_thumbnail_task(
request.uri(),
task_ctx,
buf_state,
api,
hash.to_string(),
request.uri().to_string(),
width,
height,
)
.await;
}
ResponseBuilder::new()
.mimetype("text/plain")
.status(301)
.header("Retry-After", "1")
.body("Content loading. Retry in 1s.".as_bytes().to_vec())
}
}
async fn add_fetch_thumbnail_task(
name: &str,
task_ctx: State<'_, TaskContext>,
buf_state: BufferState,
api: ApiClient,
hash: String,
request_uri: String,
width: u32,
height: u32,
) {
task_ctx
.add_task(name, async move {
tracing::debug!("Fetching content from daemon");
let (thumb, bytes) = api
.file
.get_thumbnail_of_size(
FileIdentifier::CD(hash.to_string()),
((height as f32 * 0.8) as u32, (width as f32 * 0.8) as u32),
((height as f32 * 1.2) as u32, (width as f32 * 1.2) as u32),
FileIdentifier::CD(hash),
((height as f32 * 0.5) as u32, (width as f32 * 0.5) as u32),
((height as f32 * 1.5) as u32, (width as f32 * 1.5) as u32),
)
.await?;
tracing::debug!("Received {} content bytes", bytes.len());
buf_state.add_entry(
request.uri().to_string(),
thumb.mime_type.clone(),
bytes.clone(),
);
buf_state.add_entry(request_uri, thumb.mime_type.clone(), bytes.clone());
ResponseBuilder::new()
.mimetype(&thumb.mime_type)
.status(200)
.body(bytes)
}
Ok(())
})
.await;
}

@ -7,6 +7,7 @@ use crate::tauri_plugin::state::{AppState, BufferState};
use std::thread;
use std::time::Duration;
mod background_tasks;
pub(crate) mod commands;
pub mod custom_schemes;
pub mod error;
@ -14,9 +15,10 @@ mod settings;
mod state;
mod utils;
use crate::tauri_plugin::background_tasks::{start_background_task_runtime, TaskContext};
use commands::*;
const MAX_BUFFER_SIZE: usize = 2 * 1024 * 1024 * 1024;
const MAX_BUFFER_SIZE: usize = 2 * 1024 * 1024 * 1024; // 2GiB
pub fn register_plugin<R: Runtime>(builder: Builder<R>) -> Builder<R> {
let repo_plugin = MediarepoPlugin::new();
@ -99,6 +101,10 @@ impl<R: Runtime> Plugin<R> for MediarepoPlugin<R> {
let repo_state = AppState::load()?;
app.manage(repo_state);
let task_context = TaskContext::new();
start_background_task_runtime(task_context.clone());
app.manage(task_context);
thread::spawn(move || loop {
thread::sleep(Duration::from_secs(10));
buffer_state.clear_expired();

@ -58,6 +58,7 @@ pub struct FileMetadataResponse {
pub creation_time: NaiveDateTime,
pub change_time: NaiveDateTime,
pub import_time: NaiveDateTime,
pub size: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]

@ -10,6 +10,7 @@ pub struct RunJobRequest {
pub enum JobType {
MigrateContentDescriptors,
CalculateSizes,
GenerateThumbnails,
CheckIntegrity,
Vacuum,
}

@ -269,15 +269,16 @@ dependencies = [
[[package]]
name = "bromine"
version = "0.17.1"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6774171be092328d9631f246d3122f196a50ddaad224214ca274acec2a1db65d"
checksum = "5dd7887995490657bf3ec578f39e747ef7b5355a8dc6c99b3d5be59ca70dc4d5"
dependencies = [
"async-trait",
"bincode",
"byteorder",
"futures 0.3.19",
"futures-core",
"lazy_static",
"num_enum",
"serde 1.0.136",
"thiserror",
"tokio",
@ -428,7 +429,7 @@ checksum = "829835c211a0247cd11e65e13cec8696b879374879c35ce162ce8098b23c90d4"
dependencies = [
"console-api",
"crossbeam-channel",
"futures 0.3.19",
"futures 0.3.21",
"hdrhistogram",
"humantime",
"serde 1.0.136",
@ -840,9 +841,9 @@ checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678"
[[package]]
name = "futures"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28560757fe2bb34e79f907794bb6b22ae8b0e5c669b638a1132f2592b19035b4"
checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e"
dependencies = [
"futures-channel",
"futures-core",
@ -855,9 +856,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3dda0b6588335f360afc675d0564c17a77a2bda81ca178a4b6081bd86c7f0b"
checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010"
dependencies = [
"futures-core",
"futures-sink",
@ -865,15 +866,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0c8ff0461b82559810cdccfde3215c3f373807f5e5232b71479bff7bb2583d7"
checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
[[package]]
name = "futures-executor"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29d6d2ff5bb10fb95c85b8ce46538a2e5f5e7fdc755623a7d4529ab8a4ed9d2a"
checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6"
dependencies = [
"futures-core",
"futures-task",
@ -893,15 +894,15 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9d34af5a1aac6fb380f735fe510746c38067c5bf16c7fd250280503c971b2"
checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
[[package]]
name = "futures-macro"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbd947adfffb0efc70599b3ddcf7b5597bb5fa9e245eb99f62b3a5f7bb8bd3c"
checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512"
dependencies = [
"proc-macro2 1.0.36",
"quote 1.0.15",
@ -910,21 +911,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3055baccb68d74ff6480350f8d6eb8fcfa3aa11bdc1a1ae3afdd0514617d508"
checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868"
[[package]]
name = "futures-task"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ee7c6485c30167ce4dfb83ac568a849fe53274c831081476ee13e0dce1aad72"
checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a"
[[package]]
name = "futures-util"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b5cf40b47a271f77a8b1bec03ca09044d99d2372c0de244e66430761127164"
checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a"
dependencies = [
"futures-channel",
"futures-core",
@ -1360,7 +1361,7 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "mediarepo-api"
version = "0.28.1"
version = "0.31.0"
dependencies = [
"bromine",
"chrono",
@ -1377,7 +1378,7 @@ dependencies = [
"base64",
"config",
"data-encoding",
"futures 0.3.19",
"futures 0.3.21",
"glob",
"itertools",
"mediarepo-api",
@ -1397,7 +1398,7 @@ dependencies = [
[[package]]
name = "mediarepo-daemon"
version = "0.13.4"
version = "1.0.0-rc.1"
dependencies = [
"console-subscriber",
"glob",
@ -1708,6 +1709,27 @@ dependencies = [
"libc",
]
[[package]]
name = "num_enum"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "720d3ea1055e4e4574c0c0b0f8c3fd4f24c4cdaf465948206dea090b57b526ad"
dependencies = [
"num_enum_derive",
]
[[package]]
name = "num_enum_derive"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d992b768490d7fe0d8586d9b5745f6c49f557da6d81dc982b1d167ad4edbb21"
dependencies = [
"proc-macro-crate",
"proc-macro2 1.0.36",
"quote 1.0.15",
"syn 1.0.86",
]
[[package]]
name = "num_threads"
version = "0.1.3"
@ -2196,14 +2218,14 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "sea-orm"
version = "0.5.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5452736ac11d11f9dcf1980897d3a6302d78ee2bfcb928b0f9c03569f2e6b12c"
checksum = "dd24380b48dacd3ed1c3d467c7b17ffa5818555a2c04066f4a0a9e17d830abc9"
dependencies = [
"async-stream",
"async-trait",
"chrono",
"futures 0.3.19",
"futures 0.3.21",
"futures-util",
"once_cell",
"ouroboros",
@ -2221,9 +2243,9 @@ dependencies = [
[[package]]
name = "sea-orm-macros"
version = "0.5.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8b7b6d423b591bf447685e9ea7cecd65e0c282e18dc5ddc7438425cd296faa8"
checksum = "c199fa8630b1e195d7aef24ce8944af8f4ced67c4eccffd8926453b59f2565a1"
dependencies = [
"bae",
"heck",
@ -2234,9 +2256,9 @@ dependencies = [
[[package]]
name = "sea-query"
version = "0.20.0"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83e4a0dd79545b61c6ca453a28d0a88829487869a8559a35a3192f1b6aacad8"
checksum = "9088ff96158860a75d98a85a654fdd9d97b10515773af6d87339bfc48258c800"
dependencies = [
"chrono",
"rust_decimal",
@ -2812,7 +2834,7 @@ checksum = "d08ebea7dc6b22273290d8ece2ca448f979f836e38ba629b650595c64204b4f2"
dependencies = [
"anyhow",
"async-recursion",
"futures 0.3.19",
"futures 0.3.21",
"log",
"tokio",
"tokio-util",

@ -4,7 +4,7 @@ default-members = ["mediarepo-core", "mediarepo-database", "mediarepo-logic", "m
[package]
name = "mediarepo-daemon"
version = "0.13.4"
version = "1.0.0-rc.1"
edition = "2018"
license = "gpl-3"
repository = "https://github.com/Trivernis/mediarepo-daemon"

@ -14,7 +14,7 @@ base64 = "0.13.0"
toml = "0.5.8"
serde = "1.0.136"
typemap_rev = "0.1.5"
futures = "0.3.19"
futures = "0.3.21"
itertools = "0.10.3"
glob = "0.3.0"
tracing = "0.1.30"
@ -26,7 +26,7 @@ version = "0.3.0"
default-features = false
[dependencies.sea-orm]
version = "0.5.0"
version = "0.6.0"
default-features = false
[dependencies.sqlx]

@ -9,6 +9,7 @@ use crate::error::RepoResult;
/// Parses a normalized tag into its two components of namespace and tag
pub fn parse_namespace_and_tag(norm_tag: String) -> (Option<String>, String) {
norm_tag
.to_lowercase()
.split_once(':')
.map(|(n, t)| (Some(n.trim().to_string()), t.trim().to_string()))
.unwrap_or((None, norm_tag.trim().to_string()))
@ -49,10 +50,7 @@ pub async fn get_folder_size(path: PathBuf) -> RepoResult<u64> {
let futures = all_files.into_iter().map(|f| read_file_size(f));
let results = future::join_all(futures).await;
let size = results
.into_iter()
.filter_map(|r| r.ok())
.fold(0u64, |acc, val| acc + val);
let size = results.into_iter().filter_map(|r| r.ok()).sum();
Ok(size)
}

@ -18,6 +18,6 @@ version = "0.5.10"
features = ["migrate"]
[dependencies.sea-orm]
version = "0.5.0"
version = "0.6.0"
features = ["sqlx-sqlite", "runtime-tokio-native-tls", "macros", "debug-print"]
default-features = false

@ -22,7 +22,7 @@ path = "../mediarepo-core"
path = "../mediarepo-database"
[dependencies.sea-orm]
version = "0.5.0"
version = "0.6.0"
features = ["runtime-tokio-native-tls", "macros"]
default-features = false

@ -2,9 +2,10 @@ use std::io::Cursor;
use chrono::{Local, NaiveDateTime};
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ConnectionTrait, DatabaseTransaction};
use sea_orm::{ActiveModelTrait, DatabaseTransaction, TransactionTrait};
use mediarepo_core::error::RepoResult;
use mediarepo_core::thumbnailer::ThumbnailSize;
use mediarepo_database::entities::{content_descriptor, file, file_metadata};
use crate::dao::file::FileDao;
@ -44,8 +45,11 @@ impl FileDao {
.await?;
trx.commit().await?;
let dto = FileDto::new(file, cd, Some(metadata));
self.create_thumbnails(&dto, vec![ThumbnailSize::Medium])
.await?;
Ok(FileDto::new(file, cd, Some(metadata)))
Ok(dto)
}
}

@ -1,12 +1,12 @@
use sea_orm::ConnectionTrait;
use sea_orm::prelude::*;
use sea_orm::TransactionTrait;
use mediarepo_core::error::{RepoResult};
use mediarepo_core::error::RepoResult;
use mediarepo_database::entities::{
content_descriptor, content_descriptor_tag, file, file_metadata,
};
use crate::dao::file::{FileDao};
use crate::dao::file::FileDao;
use crate::dto::FileDto;
impl FileDao {

@ -4,7 +4,7 @@ use std::str::FromStr;
use sea_orm::prelude::*;
use sea_orm::ActiveValue::{Set, Unchanged};
use sea_orm::{ConnectionTrait, NotSet};
use sea_orm::{NotSet, TransactionTrait};
use mediarepo_core::error::{RepoError, RepoResult};
use mediarepo_core::fs::thumbnail_store::Dimensions;
@ -58,7 +58,7 @@ impl FileDao {
#[tracing::instrument(level = "debug", skip(self))]
pub async fn create_thumbnails<I: IntoIterator<Item = ThumbnailSize> + Debug>(
&self,
file: FileDto,
file: &FileDto,
sizes: I,
) -> RepoResult<Vec<ThumbnailDto>> {
let bytes = self.get_bytes(file.cd()).await?;

@ -0,0 +1,31 @@
use crate::dao::job::JobDao;
use crate::dao::DaoProvider;
use mediarepo_core::error::RepoResult;
use mediarepo_core::futures;
use mediarepo_core::thumbnailer::ThumbnailSize;
impl JobDao {
/// Generates thumbnails for files that are still missing some
#[tracing::instrument(level = "debug", skip(self))]
pub async fn generate_missing_thumbnails(&self) -> RepoResult<()> {
let file_dao = self.file();
let files = file_dao.all().await?;
let mut missing_thumbnails = Vec::new();
for file in files {
if file_dao.thumbnails(file.encoded_cd()).await?.is_empty() {
missing_thumbnails.push(file);
}
}
futures::future::join_all(missing_thumbnails.into_iter().map(|f| async {
let file = f;
file_dao
.create_thumbnails(&file, vec![ThumbnailSize::Medium])
.await
}))
.await;
Ok(())
}
}

@ -6,7 +6,7 @@ use mediarepo_core::error::RepoResult;
use mediarepo_database::entities::content_descriptor;
use sea_orm::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::ConnectionTrait;
use sea_orm::TransactionTrait;
impl JobDao {
#[tracing::instrument(level = "debug", skip(self))]

@ -1,5 +1,6 @@
use crate::dao_provider;
pub mod generate_missing_thumbnails;
pub mod migrate_content_descriptors;
pub mod sqlite_operations;

@ -5,8 +5,8 @@ use mediarepo_database::entities::{sort_key, sorting_preset, sorting_preset_key}
use sea_orm::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::{
Condition, ConnectionTrait, DatabaseTransaction, DbBackend, FromQueryResult, JoinType,
QuerySelect, Statement,
Condition, DatabaseTransaction, DbBackend, FromQueryResult, JoinType, QuerySelect, Statement,
TransactionTrait,
};
#[allow(unused_imports)]

@ -4,7 +4,7 @@ use mediarepo_core::error::RepoResult;
use mediarepo_database::entities::{namespace, tag};
use sea_orm::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::{Condition, ConnectionTrait, DatabaseTransaction};
use sea_orm::{Condition, DatabaseTransaction, TransactionTrait};
use std::collections::HashMap;
use std::iter::FromIterator;

@ -1,6 +1,6 @@
use sea_orm::{ConnectionTrait, DatabaseTransaction};
use sea_orm::ActiveValue::Set;
use sea_orm::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::{DatabaseTransaction, TransactionTrait};
use mediarepo_core::error::RepoResult;
use mediarepo_database::entities::content_descriptor_tag;
@ -28,11 +28,13 @@ impl TagDao {
})
.collect();
if !active_models.is_empty() {
content_descriptor_tag::Entity::insert_many(active_models)
.exec(&trx)
.await?;
trx.commit().await?;
}
Ok(())
}
@ -60,7 +62,7 @@ async fn get_existing_mappings(
.all(trx)
.await?
.into_iter()
.map(|model: content_descriptor_tag::Model| (model.tag_id, model.cd_id))
.map(|model: content_descriptor_tag::Model| (model.cd_id, model.tag_id))
.collect();
Ok(existing_mappings)
}

@ -7,7 +7,7 @@ pub struct NamespaceDto {
impl NamespaceDto {
pub(crate) fn new(model: namespace::Model) -> Self {
Self {model}
Self { model }
}
pub fn id(&self) -> i64 {

@ -23,6 +23,7 @@ impl FromModel<FileMetadataDto> for FileMetadataResponse {
creation_time: model.creation_time().to_owned(),
change_time: model.change_time().to_owned(),
import_time: model.import_time().to_owned(),
size: model.size() as u64,
}
}
}

@ -96,18 +96,17 @@ fn get_builder<L: AsyncStreamProtocolListener>(address: L::AddressType) -> IPCBu
}
#[tracing::instrument(skip_all)]
async fn info(ctx: &Context, _: Event) -> IPCResult<()> {
async fn info(ctx: &Context, _: Event) -> IPCResult<Response> {
let response = InfoResponse::new(
env!("CARGO_PKG_NAME").to_string(),
env!("CARGO_PKG_VERSION").to_string(),
);
ctx.emit("info", response).await?;
Ok(())
ctx.response(response)
}
#[tracing::instrument(skip_all)]
async fn shutdown(ctx: &Context, _: Event) -> IPCResult<()> {
async fn shutdown(ctx: &Context, _: Event) -> IPCResult<Response> {
ctx.clone().stop().await?;
{
let data = ctx.data.read().await;
@ -115,7 +114,6 @@ async fn shutdown(ctx: &Context, _: Event) -> IPCResult<()> {
subsystem.request_shutdown();
subsystem.on_shutdown_requested().await;
}
ctx.emit("shutdown", ()).await?;
Ok(())
Ok(Response::empty())
}

@ -54,7 +54,7 @@ impl NamespaceProvider for FilesNamespace {
impl FilesNamespace {
/// Returns a list of all files
#[tracing::instrument(skip_all)]
async fn all_files(ctx: &Context, _event: Event) -> IPCResult<()> {
async fn all_files(ctx: &Context, _event: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let files = repo.file().all().await?;
@ -63,26 +63,23 @@ impl FilesNamespace {
.map(FileBasicDataResponse::from_model)
.collect();
ctx.emit_to(Self::name(), "all_files", responses).await?;
Ok(())
ctx.response(responses)
}
/// Returns a file by id
#[tracing::instrument(skip_all)]
async fn get_file(ctx: &Context, event: Event) -> IPCResult<()> {
async fn get_file(ctx: &Context, event: Event) -> IPCResult<Response> {
let id = event.payload::<FileIdentifier>()?;
let repo = get_repo_from_context(ctx).await;
let file = file_by_identifier(id, &repo).await?;
let response = FileBasicDataResponse::from_model(file);
ctx.emit_to(Self::name(), "get_file", response).await?;
Ok(())
ctx.response(response)
}
/// Returns metadata for a given file
#[tracing::instrument(skip_all)]
async fn get_file_metadata(ctx: &Context, event: Event) -> IPCResult<()> {
async fn get_file_metadata(ctx: &Context, event: Event) -> IPCResult<Response> {
let id = event.payload::<FileIdentifier>()?;
let repo = get_repo_from_context(ctx).await;
let file = file_by_identifier(id, &repo).await?;
@ -97,19 +94,12 @@ impl FilesNamespace {
.ok_or_else(|| RepoError::from("file metadata not found"))?
};
ctx.emit_to(
Self::name(),
"get_file_metadata",
FileMetadataResponse::from_model(metadata),
)
.await?;
Ok(())
ctx.response(FileMetadataResponse::from_model(metadata))
}
/// Returns a list of files by identifier
#[tracing::instrument(skip_all)]
async fn get_files(ctx: &Context, event: Event) -> IPCResult<()> {
async fn get_files(ctx: &Context, event: Event) -> IPCResult<Response> {
let ids = event.payload::<Vec<FileIdentifier>>()?;
let repo = get_repo_from_context(ctx).await;
let mut responses = Vec::new();
@ -121,14 +111,13 @@ impl FilesNamespace {
.map(FileBasicDataResponse::from_model)?,
);
}
ctx.emit_to(Self::name(), "get_files", responses).await?;
Ok(())
ctx.response(responses)
}
/// Searches for files by tags
#[tracing::instrument(skip_all)]
async fn find_files(ctx: &Context, event: Event) -> IPCResult<()> {
async fn find_files(ctx: &Context, event: Event) -> IPCResult<Response> {
let req = event.payload::<FindFilesRequest>()?;
let repo = get_repo_from_context(ctx).await;
@ -139,13 +128,13 @@ impl FilesNamespace {
.into_iter()
.map(FileBasicDataResponse::from_model)
.collect();
ctx.emit_to(Self::name(), "find_files", responses).await?;
Ok(())
ctx.response(responses)
}
/// Adds a file to the repository
#[tracing::instrument(skip_all)]
async fn add_file(ctx: &Context, event: Event) -> IPCResult<()> {
async fn add_file(ctx: &Context, event: Event) -> IPCResult<Response> {
let (request, bytes) = event
.payload::<TandemPayload<AddFileRequestHeader, BytePayload>>()?
.into_inner();
@ -184,18 +173,11 @@ impl FilesNamespace {
.upsert_mappings(vec![file.cd_id()], tag_ids)
.await?;
ctx.emit_to(
Self::name(),
"add_file",
FileBasicDataResponse::from_model(file),
)
.await?;
Ok(())
ctx.response(FileBasicDataResponse::from_model(file))
}
#[tracing::instrument(skip_all)]
async fn update_status(ctx: &Context, event: Event) -> IPCResult<()> {
async fn update_status(ctx: &Context, event: Event) -> IPCResult<Response> {
let request = event.payload::<UpdateFileStatusRequest>()?;
let repo = get_repo_from_context(ctx).await;
let mut file = file_by_identifier(request.file_id, &repo).await?;
@ -207,46 +189,35 @@ impl FilesNamespace {
..Default::default()
})
.await?;
ctx.emit_to(
Self::name(),
"update_file_status",
FileBasicDataResponse::from_model(file),
)
.await?;
Ok(())
ctx.response(FileBasicDataResponse::from_model(file))
}
/// Reads the binary contents of a file
#[tracing::instrument(skip_all)]
async fn read_file(ctx: &Context, event: Event) -> IPCResult<()> {
async fn read_file(ctx: &Context, event: Event) -> IPCResult<Response> {
let request = event.payload::<ReadFileRequest>()?;
let repo = get_repo_from_context(ctx).await;
let file = file_by_identifier(request.id, &repo).await?;
let bytes = repo.file().get_bytes(file.cd()).await?;
ctx.emit_to(Self::name(), "read_file", BytePayload::new(bytes))
.await?;
Ok(())
ctx.response(BytePayload::new(bytes))
}
/// Deletes a file
#[tracing::instrument(skip_all)]
async fn delete_file(ctx: &Context, event: Event) -> IPCResult<()> {
async fn delete_file(ctx: &Context, event: Event) -> IPCResult<Response> {
let id = event.payload::<FileIdentifier>()?;
let repo = get_repo_from_context(ctx).await;
let file = file_by_identifier(id, &repo).await?;
repo.file().delete(file).await?;
ctx.emit_to(Self::name(), "delete_file", ()).await?;
Ok(())
Ok(Response::empty())
}
/// Returns a list of available thumbnails of a file
#[tracing::instrument(skip_all)]
async fn thumbnails(ctx: &Context, event: Event) -> IPCResult<()> {
async fn thumbnails(ctx: &Context, event: Event) -> IPCResult<Response> {
let request = event.payload::<GetFileThumbnailsRequest>()?;
let repo = get_repo_from_context(ctx).await;
let file_cd = cd_by_identifier(request.id.clone(), &repo).await?;
@ -260,7 +231,7 @@ impl FilesNamespace {
let file = file_by_identifier(request.id, &repo).await?;
thumbnails = repo
.file()
.create_thumbnails(file, vec![ThumbnailSize::Medium])
.create_thumbnails(&file, vec![ThumbnailSize::Medium])
.await?;
tracing::debug!("Thumbnails for file created.");
}
@ -269,15 +240,13 @@ impl FilesNamespace {
.into_iter()
.map(ThumbnailMetadataResponse::from_model)
.collect();
ctx.emit_to(Self::name(), "get_thumbnails", thumb_responses)
.await?;
Ok(())
ctx.response(thumb_responses)
}
/// Returns a thumbnail that is within the range of the requested sizes
#[tracing::instrument(skip_all)]
async fn get_thumbnail_of_size(ctx: &Context, event: Event) -> IPCResult<()> {
async fn get_thumbnail_of_size(ctx: &Context, event: Event) -> IPCResult<Response> {
let request = event.payload::<GetFileThumbnailOfSizeRequest>()?;
let repo = get_repo_from_context(ctx).await;
let file_cd = cd_by_identifier(request.id.clone(), &repo).await?;
@ -302,7 +271,7 @@ impl FilesNamespace {
let middle_size = ((max_size.0 + min_size.0) / 2, (max_size.1 + min_size.1) / 2);
let thumbnail = repo
.file()
.create_thumbnails(file, vec![ThumbnailSize::Custom(middle_size)])
.create_thumbnails(&file, vec![ThumbnailSize::Custom(middle_size)])
.await?;
thumbnail
@ -314,19 +283,13 @@ impl FilesNamespace {
thumbnail.get_reader().await?.read_to_end(&mut buf).await?;
let byte_payload = BytePayload::new(buf);
let thumb_payload = ThumbnailMetadataResponse::from_model(thumbnail);
ctx.emit_to(
Self::name(),
"get_thumbnail_of_size",
TandemPayload::new(thumb_payload, byte_payload),
)
.await?;
Ok(())
ctx.response(TandemPayload::new(thumb_payload, byte_payload))
}
/// Updates the name of a file
#[tracing::instrument(skip_all)]
async fn update_file_name(ctx: &Context, event: Event) -> IPCResult<()> {
async fn update_file_name(ctx: &Context, event: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let request = event.payload::<UpdateFileNameRequest>()?;
let file = file_by_identifier(request.file_id, &repo).await?;
@ -340,19 +303,12 @@ impl FilesNamespace {
})
.await?;
ctx.emit_to(
Self::name(),
"update_file_name",
FileMetadataResponse::from_model(metadata),
)
.await?;
Ok(())
ctx.response(FileMetadataResponse::from_model(metadata))
}
/// Deletes all thumbnails of a file
#[tracing::instrument(skip_all)]
async fn delete_thumbnails(ctx: &Context, event: Event) -> IPCResult<()> {
async fn delete_thumbnails(ctx: &Context, event: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let id = event.payload::<FileIdentifier>()?;
let file = file_by_identifier(id, &repo).await?;
@ -362,6 +318,6 @@ impl FilesNamespace {
thumb.delete().await?;
}
Ok(())
Ok(Response::empty())
}
}

@ -23,20 +23,24 @@ impl NamespaceProvider for JobsNamespace {
impl JobsNamespace {
#[tracing::instrument(skip_all)]
pub async fn run_job(ctx: &Context, event: Event) -> IPCResult<()> {
pub async fn run_job(ctx: &Context, event: Event) -> IPCResult<Response> {
let run_request = event.payload::<RunJobRequest>()?;
let job_dao = get_repo_from_context(ctx).await.job();
if !run_request.sync {
// early response to indicate that the job will be run
ctx.emit_to(Self::name(), "run_job", ()).await?;
}
match run_request.job_type {
JobType::MigrateContentDescriptors => job_dao.migrate_content_descriptors().await?,
JobType::CalculateSizes => calculate_all_sizes(ctx).await?,
JobType::CheckIntegrity => job_dao.check_integrity().await?,
JobType::Vacuum => job_dao.vacuum().await?,
JobType::GenerateThumbnails => job_dao.generate_missing_thumbnails().await?,
}
ctx.emit_to(Self::name(), "run_job", ()).await?;
Ok(())
Ok(Response::empty())
}
}

@ -23,7 +23,7 @@ impl NamespaceProvider for PresetsNamespace {
impl PresetsNamespace {
#[tracing::instrument(skip_all)]
pub async fn all_sorting_presets(ctx: &Context, _: Event) -> IPCResult<()> {
pub async fn all_sorting_presets(ctx: &Context, _: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let sorting_presets: Vec<SortingPreset> = repo
.sorting_preset()
@ -32,14 +32,12 @@ impl PresetsNamespace {
.into_iter()
.map(SortingPreset::from_model)
.collect();
ctx.emit_to(Self::name(), "all_sorting_presets", sorting_presets)
.await?;
Ok(())
ctx.response(sorting_presets)
}
#[tracing::instrument(skip_all)]
pub async fn add_sorting_preset(ctx: &Context, event: Event) -> IPCResult<()> {
pub async fn add_sorting_preset(ctx: &Context, event: Event) -> IPCResult<Response> {
let keys = event
.payload::<Vec<SortKey>>()?
.into_iter()
@ -50,25 +48,17 @@ impl PresetsNamespace {
.sorting_preset()
.add(AddSortingPresetDto { keys })
.await?;
ctx.emit_to(
Self::name(),
"add_sorting_preset",
SortingPreset::from_model(preset),
)
.await?;
Ok(())
ctx.response(SortingPreset::from_model(preset))
}
#[tracing::instrument(skip_all)]
pub async fn delete_sorting_preset(ctx: &Context, event: Event) -> IPCResult<()> {
pub async fn delete_sorting_preset(ctx: &Context, event: Event) -> IPCResult<Response> {
let id = event.payload::<i32>()?;
let repo = get_repo_from_context(ctx).await;
repo.sorting_preset().delete(id).await?;
ctx.emit_to(Self::name(), "delete_sorting_preset", ())
.await?;
Ok(())
Ok(Response::empty())
}
}

@ -29,7 +29,7 @@ impl NamespaceProvider for RepoNamespace {
impl RepoNamespace {
#[tracing::instrument(skip_all)]
async fn get_metadata(ctx: &Context, _: Event) -> IPCResult<()> {
async fn get_metadata(ctx: &Context, _: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let counts = repo.get_counts().await?;
@ -43,14 +43,12 @@ impl RepoNamespace {
};
tracing::debug!("metadata = {:?}", metadata);
ctx.emit_to(Self::name(), "repository_metadata", metadata)
.await?;
Ok(())
ctx.response(metadata)
}
#[tracing::instrument(skip_all)]
async fn get_size_metadata(ctx: &Context, event: Event) -> IPCResult<()> {
async fn get_size_metadata(ctx: &Context, event: Event) -> IPCResult<Response> {
let size_type = event.payload::<SizeType>()?;
let data = ctx.data.read().await;
let size_cache = data.get::<SizeMetadataKey>().unwrap();
@ -61,38 +59,25 @@ impl RepoNamespace {
calculate_size(&size_type, ctx).await?
};
ctx.emit_to(
Self::name(),
"size_metadata",
SizeMetadata { size, size_type },
)
.await?;
Ok(())
ctx.response(SizeMetadata { size, size_type })
}
#[tracing::instrument(skip_all)]
async fn frontend_state(ctx: &Context, _: Event) -> IPCResult<()> {
async fn frontend_state(ctx: &Context, _: Event) -> IPCResult<Response> {
let path = get_frontend_state_path(ctx).await?;
let state_string = if path.exists() {
Some(fs::read_to_string(path).await?)
} else {
None
};
ctx.emit_to(
Self::name(),
"frontend_state",
FrontendState {
state: state_string,
},
)
.await?;
Ok(())
ctx.response(FrontendState {
state: state_string,
})
}
#[tracing::instrument(skip_all)]
async fn set_frontend_state(ctx: &Context, event: Event) -> IPCResult<()> {
async fn set_frontend_state(ctx: &Context, event: Event) -> IPCResult<Response> {
let path = get_frontend_state_path(ctx).await?;
let state = event.payload::<FrontendState>()?.state;
if let Some(state_string) = state {
@ -101,7 +86,7 @@ impl RepoNamespace {
fs::remove_file(path).await?;
}
Ok(())
Ok(Response::empty())
}
}

@ -39,7 +39,7 @@ impl NamespaceProvider for TagsNamespace {
impl TagsNamespace {
/// Returns a list of all tags in the database
#[tracing::instrument(skip_all)]
async fn all_tags(ctx: &Context, _event: Event) -> IPCResult<()> {
async fn all_tags(ctx: &Context, _event: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let tags: Vec<TagResponse> = repo
.tag()
@ -48,14 +48,13 @@ impl TagsNamespace {
.into_iter()
.map(TagResponse::from_model)
.collect();
ctx.emit_to(Self::name(), "all_tags", tags).await?;
Ok(())
ctx.response(tags)
}
/// Returns a list of all namespaces from the database
#[tracing::instrument(skip_all)]
async fn all_namespaces(ctx: &Context, _event: Event) -> IPCResult<()> {
async fn all_namespaces(ctx: &Context, _event: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let namespaces: Vec<NamespaceResponse> = repo
.tag()
@ -64,30 +63,25 @@ impl TagsNamespace {
.into_iter()
.map(NamespaceResponse::from_model)
.collect();
ctx.emit_to(Self::name(), "all_namespaces", namespaces)
.await?;
Ok(())
ctx.response(namespaces)
}
/// Returns all tags for a single file
#[tracing::instrument(skip_all)]
async fn tags_for_file(ctx: &Context, event: Event) -> IPCResult<()> {
async fn tags_for_file(ctx: &Context, event: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let request = event.payload::<GetFileTagsRequest>()?;
let file = file_by_identifier(request.id, &repo).await?;
let tags = repo.tag().tags_for_cd(file.cd_id()).await?;
let responses: Vec<TagResponse> = tags.into_iter().map(TagResponse::from_model).collect();
ctx.emit_to(Self::name(), "tags_for_file", responses)
.await?;
Ok(())
ctx.response(responses)
}
/// Returns all tags for a given list of file hashes
#[tracing::instrument(skip_all)]
async fn tags_for_files(ctx: &Context, event: Event) -> IPCResult<()> {
async fn tags_for_files(ctx: &Context, event: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let request = event.payload::<GetFilesTagsRequest>()?;
let tag_responses: Vec<TagResponse> = repo
@ -103,15 +97,13 @@ impl TagsNamespace {
.into_iter()
.map(TagResponse::from_model)
.collect();
ctx.emit_to(Self::name(), "tags_for_files", tag_responses)
.await?;
Ok(())
ctx.response(tag_responses)
}
/// Returns a map of content descriptors to assigned tags
#[tracing::instrument(skip_all)]
async fn tag_cd_map_for_files(ctx: &Context, event: Event) -> IPCResult<()> {
async fn tag_cd_map_for_files(ctx: &Context, event: Event) -> IPCResult<Response> {
let request = event.payload::<GetFileTagMapRequest>()?;
let repo = get_repo_from_context(ctx).await;
let cds = request
@ -136,14 +128,12 @@ impl TagsNamespace {
})
.collect::<HashMap<String, Vec<TagResponse>>>();
ctx.emit_to(Self::name(), "file_tag_map", mappings).await?;
Ok(())
ctx.response(mappings)
}
/// Creates all tags given as input or returns the existing tags
#[tracing::instrument(skip_all)]
async fn create_tags(ctx: &Context, event: Event) -> IPCResult<()> {
async fn create_tags(ctx: &Context, event: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let tags = event.payload::<Vec<String>>()?;
let created_tags = repo
@ -160,15 +150,14 @@ impl TagsNamespace {
.into_iter()
.map(TagResponse::from_model)
.collect();
ctx.emit_to(Self::name(), "create_tags", responses).await?;
Ok(())
ctx.response(responses)
}
/// Changes tags of a file
/// it removes the tags from the removed list and adds the one from the add list
#[tracing::instrument(skip_all)]
async fn change_file_tags(ctx: &Context, event: Event) -> IPCResult<()> {
async fn change_file_tags(ctx: &Context, event: Event) -> IPCResult<Response> {
let repo = get_repo_from_context(ctx).await;
let request = event.payload::<ChangeFileTagsRequest>()?;
let file = file_by_identifier(request.file_id, &repo).await?;
@ -191,9 +180,7 @@ impl TagsNamespace {
.into_iter()
.map(TagResponse::from_model)
.collect();
ctx.emit_to(Self::name(), "change_file_tags", responses)
.await?;
Ok(())
ctx.response(responses)
}
}

@ -55,7 +55,14 @@ fn main() -> RepoResult<()> {
let settings = if opt.repo.exists() {
opt.repo = opt.repo.canonicalize().unwrap();
load_settings(&opt.repo)?
match load_settings(&opt.repo) {
Ok(s) => s,
Err(e) => {
log::warn!("failed to read settings {}", e);
Settings::default()
}
}
} else {
Settings::default()
};

@ -11,7 +11,6 @@
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

@ -37,7 +37,8 @@
],
"styles": [
"./node_modules/@angular/material/prebuilt-themes/purple-green.css",
"src/styles.scss"
"src/styles.scss",
"src/material-theme-correction.scss"
],
"scripts": []
},
@ -46,13 +47,13 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "10mb"
"maximumWarning": "20mb",
"maximumError": "400mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "100kb"
"maximumWarning": "100kb",
"maximumError": "500kb"
}
],
"fileReplacements": [
@ -110,7 +111,8 @@
],
"styles": [
"./node_modules/@angular/material/prebuilt-themes/purple-green.css",
"src/styles.scss"
"src/styles.scss",
"src/material-theme-correction.scss"
],
"scripts": []
}

@ -1,6 +1,6 @@
{
"name": "mediarepo-ui",
"version": "0.13.4",
"version": "1.0.0-rc.1",
"scripts": {
"ng": "ng",
"start": "ng serve",

@ -40,7 +40,7 @@ checksum = "94a45b455c14666b85fc40a019e8ab9eb75e3a124e05494f5397122bc9eb06e0"
[[package]]
name = "app"
version = "0.13.4"
version = "1.0.0-rc.1"
dependencies = [
"mediarepo-api",
"serde",
@ -170,15 +170,16 @@ dependencies = [
[[package]]
name = "bromine"
version = "0.17.1"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6774171be092328d9631f246d3122f196a50ddaad224214ca274acec2a1db65d"
checksum = "5dd7887995490657bf3ec578f39e747ef7b5355a8dc6c99b3d5be59ca70dc4d5"
dependencies = [
"async-trait",
"bincode",
"byteorder",
"futures",
"futures-core",
"lazy_static",
"num_enum",
"serde",
"thiserror",
"tokio",
@ -809,9 +810,9 @@ dependencies = [
[[package]]
name = "futures"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28560757fe2bb34e79f907794bb6b22ae8b0e5c669b638a1132f2592b19035b4"
checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e"
dependencies = [
"futures-channel",
"futures-core",
@ -824,9 +825,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3dda0b6588335f360afc675d0564c17a77a2bda81ca178a4b6081bd86c7f0b"
checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010"
dependencies = [
"futures-core",
"futures-sink",
@ -834,15 +835,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0c8ff0461b82559810cdccfde3215c3f373807f5e5232b71479bff7bb2583d7"
checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
[[package]]
name = "futures-executor"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29d6d2ff5bb10fb95c85b8ce46538a2e5f5e7fdc755623a7d4529ab8a4ed9d2a"
checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6"
dependencies = [
"futures-core",
"futures-task",
@ -851,9 +852,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9d34af5a1aac6fb380f735fe510746c38067c5bf16c7fd250280503c971b2"
checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
[[package]]
name = "futures-lite"
@ -872,9 +873,9 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbd947adfffb0efc70599b3ddcf7b5597bb5fa9e245eb99f62b3a5f7bb8bd3c"
checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512"
dependencies = [
"proc-macro2 1.0.36",
"quote 1.0.15",
@ -883,21 +884,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3055baccb68d74ff6480350f8d6eb8fcfa3aa11bdc1a1ae3afdd0514617d508"
checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868"
[[package]]
name = "futures-task"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ee7c6485c30167ce4dfb83ac568a849fe53274c831081476ee13e0dce1aad72"
checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a"
[[package]]
name = "futures-util"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b5cf40b47a271f77a8b1bec03ca09044d99d2372c0de244e66430761127164"
checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a"
dependencies = [
"futures-channel",
"futures-core",
@ -1499,7 +1500,7 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "mediarepo-api"
version = "0.28.1"
version = "0.31.0"
dependencies = [
"async-trait",
"bromine",
@ -1875,7 +1876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58"
dependencies = [
"lock_api",
"parking_lot_core 0.9.0",
"parking_lot_core 0.9.1",
]
[[package]]
@ -1894,9 +1895,9 @@ dependencies = [
[[package]]
name = "parking_lot_core"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f4f894f3865f6c0e02810fc597300f34dc2510f66400da262d8ae10e75767d"
checksum = "28141e0cc4143da2443301914478dc976a61ffdb3f043058310c70df2fed8954"
dependencies = [
"cfg-if 1.0.0",
"libc",
@ -3097,9 +3098,21 @@ dependencies = [
"once_cell",
"pin-project-lite",
"signal-hook-registry",
"tokio-macros",
"winapi",
]
[[package]]
name = "tokio-macros"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7"
dependencies = [
"proc-macro2 1.0.36",
"quote 1.0.15",
"syn 1.0.86",
]
[[package]]
name = "toml"
version = "0.5.8"
@ -3508,9 +3521,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.29.0"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ceb069ac8b2117d36924190469735767f0990833935ab430155e71a44bafe148"
checksum = "3df6e476185f92a12c072be4a189a0210dcdcf512a1891d6dff9edb874deadc6"
dependencies = [
"windows_aarch64_msvc",
"windows_i686_gnu",
@ -3521,33 +3534,33 @@ dependencies = [
[[package]]
name = "windows_aarch64_msvc"
version = "0.29.0"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d027175d00b01e0cbeb97d6ab6ebe03b12330a35786cbaca5252b1c4bf5d9b"
checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5"
[[package]]
name = "windows_i686_gnu"
version = "0.29.0"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8793f59f7b8e8b01eda1a652b2697d87b93097198ae85f823b969ca5b89bba58"
checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615"
[[package]]
name = "windows_i686_msvc"
version = "0.29.0"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8602f6c418b67024be2996c512f5f995de3ba417f4c75af68401ab8756796ae4"
checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172"
[[package]]
name = "windows_x86_64_gnu"
version = "0.29.0"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3d615f419543e0bd7d2b3323af0d86ff19cbc4f816e6453f36a2c2ce889c354"
checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc"
[[package]]
name = "windows_x86_64_msvc"
version = "0.29.0"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d95421d9ed3672c280884da53201a5c46b7b2765ca6faf34b0d71cf34a3561"
checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316"
[[package]]
name = "winres"

@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.13.4"
version = "1.0.0-rc.1"
description = "The UI for the mediarepo media management tool"
authors = ["you"]
license = ""

@ -1,10 +1,11 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use tracing_subscriber::fmt::format::FmtSpan;
use tauri::{LogicalSize, Size};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::format::FmtSpan;
fn main() {
tracing_subscriber::fmt::SubscriberBuilder::default()
@ -14,6 +15,10 @@ fn main() {
.compact()
.init();
mediarepo_api::tauri_plugin::register_plugin(tauri::Builder::default())
.on_page_load(|window, _| {
window.set_title(format!("mediarepo {}", env!("CARGO_PKG_VERSION")).as_str()).expect("failed to set window title");
window.set_min_size(Some(Size::Logical(LogicalSize { width: 1000.0, height: 750.0 }))).expect("failed to set minimal size");
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

@ -1,7 +1,7 @@
{
"package": {
"productName": "mediarepo-ui",
"version": "0.13.4"
"version": "1.0.0-rc.1"
},
"build": {
"distDir": "../dist/mediarepo-ui",

@ -68,9 +68,10 @@ export type FileMetadata = {
file_id: number,
name?: string,
comment?: string,
creation_time: Date,
change_time: Date,
import_time: Date,
creation_time: string,
change_time: string,
import_time: string,
size: number,
};
export type FileOsMetadata = {

@ -1,4 +1,5 @@
export type JobType = "MigrateContentDescriptors"
| "CalculateSizes"
| "CheckIntegrity"
| "Vacuum";
| "Vacuum"
| "GenerateThumbnails";

@ -107,6 +107,7 @@ export type SetFrontendStateRequest = {
export type RunJobRequest = {
jobType: JobType,
sync: boolean,
};
export type AddSortingPresetRequest = {

@ -1,4 +1,5 @@
import {FileStatus, FilterExpression, FilterQuery, PropertyQuery, ValueComparator} from "../api-types/files";
import {normalizeTag} from "../../app/utils/tag-utils";
export type Comparator = "Less" | "Equal" | "Greater" | "Between";
export type PropertyType =
@ -14,7 +15,7 @@ export type PropertyType =
export class FilterQueryBuilder {
public static tag(tag: string, negate: boolean): FilterQuery {
return { Tag: { tag, negate } };
return { Tag: { tag: normalizeTag(tag), negate } };
}
public static status(status: FileStatus): FilterQuery {
@ -74,7 +75,7 @@ export class FilterQueryBuilder {
}
public static buildFilterFromString(filterStr: string): FilterQuery | undefined {
filterStr = filterStr.trim();
filterStr = filterStr.trim().toLowerCase();
if (filterStr.startsWith(".")) {
const cleanFilter = filterStr.replace(/^\./, "");

@ -1,40 +0,0 @@
@use 'sass:map';
@use '@angular/material' as mat;
@mixin color($theme) {
$color-config: mat.get-color-config($theme);
$primary-palette: map.get($color-config, 'primary');
$warn-palette: map.get($color-config, 'warn');
body {
background-color: darken(#303030, 5);
color: white
}
.warn {
background-color: mat.get-color-from-palette($warn-palette);
color: white
}
}
@mixin typography($theme) {
// Get the typography config from the theme.
$typography-config: mat.get-typography-config($theme);
body {
font-family: mat.font-family($typography-config);
}
}
@mixin theme($theme) {
$color-config: mat.get-color-config($theme);
@if $color-config != null {
@include color($theme);
}
$typography-config: mat.get-typography-config($theme);
@if $typography-config != null {
@include typography($theme);
}
}

@ -1,5 +1,16 @@
@import "src/colors";
:host, ::ng-deep body {
font-family: $font-family;
background-color: $background;
color: white
}
::ng-deep .warn {
background-color: $warn;
color: white
}
::ng-deep .mat-button-wrapper > ng-icon {
font-size: 26px;
}
@ -16,3 +27,4 @@
background-color: $warn;
color: $text;
}

@ -14,20 +14,16 @@
</div>
</ng-template>
<ng-template matTabContent>
<app-files-tab *ngIf="tab.category === 'Files'" [state]="tab"></app-files-tab>
<app-import-tab *ngIf="tab.category === 'Import'" [state]="tab"></app-import-tab>
<app-files-tab *ngIf="tab.category === 'Files'" [state]="tab.filesTab()"></app-files-tab>
<app-import-tab *ngIf="tab.category === 'Import'" [state]="tab.importTab()"></app-import-tab>
</ng-template>
</mat-tab>
<mat-tab *ngIf="this.newTab" label="New Tab">
<div class="new-tab-content">
Select the tab type
<button (click)="this.addFilesTab()" color="primary" mat-flat-button>Files</button>
<button (click)="this.addImportTab()" color="primary" mat-flat-button>Import</button>
</div>
<app-empty-tab (tabCategorySelect)="this.addTab($event)"></app-empty-tab>
</mat-tab>
<mat-tab *ngIf="this.selectedRepository" disabled>
<ng-template mat-tab-label>
<button (click)="this.addTab()" class="new-tab-button" mat-icon-button>
<button (click)="this.addEmptyTab()" class="new-tab-button" mat-icon-button>
<ng-icon name="mat-plus"></ng-icon>
</button>
</ng-template>

@ -4,10 +4,10 @@ import {RepositoryService} from "../../services/repository/repository.service";
import {MatTabChangeEvent, MatTabGroup} from "@angular/material/tabs";
import {TagService} from "../../services/tag/tag.service";
import {TabService} from "../../services/tab/tab.service";
import {TabCategory} from "../../models/TabCategory";
import {TabState} from "../../models/TabState";
import {AppState} from "../../models/AppState";
import {TabCategory} from "../../models/state/TabCategory";
import {AppState} from "../../models/state/AppState";
import {StateService} from "../../services/state/state.service";
import {TabState} from "../../models/state/TabState";
@Component({
selector: "app-core",
@ -45,7 +45,7 @@ export class CoreComponent {
this.stateService.state.subscribe(state => {
this.appState = state;
if (this.appState.tabs.value.length === 0) {
this.addTab();
this.addEmptyTab();
} else {
this.tabGroup.selectedIndex = 1;
}
@ -58,7 +58,7 @@ export class CoreComponent {
}
if (this.tabs.length === 0) {
this.addTab();
this.addEmptyTab();
}
});
});
@ -76,19 +76,7 @@ export class CoreComponent {
}
}
public addFilesTab(): void {
this.appState.addTab(TabCategory.Files);
this.tabGroup.selectedIndex = this.tabs.length;
this.newTab = false;
}
public addImportTab(): void {
this.appState.addTab(TabCategory.Import);
this.tabGroup.selectedIndex = this.tabs.length;
this.newTab = false;
}
public addTab(): void {
public addEmptyTab(): void {
if (this.tabGroup) {
this.newTab = true;
this.tabGroup.selectedIndex = this.tabs.length + 1;
@ -119,4 +107,10 @@ export class CoreComponent {
public trackByTabId(index: number, item: TabState) {
return item.uuid;
}
public addTab(category: TabCategory): void {
this.appState.addTab(category);
this.tabGroup.selectedIndex = this.tabs.length;
this.newTab = false;
}
}

@ -39,6 +39,7 @@ import {MatToolbarModule} from "@angular/material/toolbar";
import {
RepositoryDetailsViewComponent
} from "./repositories-tab/repository-details-view/repository-details-view.component";
import { EmptyTabComponent } from './empty-tab/empty-tab.component';
@NgModule({
@ -52,6 +53,7 @@ import {
RepositoryCardComponent,
DownloadDaemonDialogComponent,
RepositoryDetailsViewComponent,
EmptyTabComponent,
],
exports: [
CoreComponent,

@ -0,0 +1,7 @@
<app-middle-centered>
<h1>What kind of tab do you want to open?</h1>
<div class="button-container">
<button (click)="this.addTab('files')" color="primary" mat-flat-button>Files</button>
<button (click)="this.addTab('import')" color="primary" mat-flat-button>Import</button>
</div>
</app-middle-centered>

@ -0,0 +1,31 @@
@import "src/colors";
:host {
height: 100%;
width: 100%;
display: block;
background: radial-gradient(circle, $background-darker-05 80%, $primary 200%);
}
.button-container {
height: 6em;
display: block;
width: 100%;
position: relative;
}
button {
padding: 0.5em 1em;
font-size: 1.5em;
margin: 1em;
border-radius: 0.5em;
transition-duration: 0.25s;
&:hover {
scale: 1.25;
}
&:active {
scale: 1;
}
}

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {EmptyTabComponent} from "./empty-tab.component";
describe("EmptyTabComponent", () => {
let component: EmptyTabComponent;
let fixture: ComponentFixture<EmptyTabComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [EmptyTabComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(EmptyTabComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,29 @@
import {ChangeDetectionStrategy, Component, EventEmitter, Output} from "@angular/core";
import {TabCategory} from "../../../models/state/TabCategory";
type TabCategoryName = "files" | "import";
@Component({
selector: "app-empty-tab",
templateUrl: "./empty-tab.component.html",
styleUrls: ["./empty-tab.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EmptyTabComponent {
@Output() tabCategorySelect = new EventEmitter<TabCategory>();
constructor() {
}
public addTab(category: TabCategoryName) {
switch (category) {
case "files":
this.tabCategorySelect.emit(TabCategory.Files);
break;
case "import":
this.tabCategorySelect.emit(TabCategory.Import);
break;
}
}
}

@ -5,7 +5,7 @@ import {File} from "../../../../../api/models/File";
import {FileSearchComponent} from "../../../shared/sidebar/file-search/file-search.component";
import {RepositoryService} from "../../../../services/repository/repository.service";
import {TagEditComponent} from "../../../shared/sidebar/tag-edit/tag-edit.component";
import {TabState} from "../../../../models/TabState";
import {FilesTabState} from "../../../../models/state/FilesTabState";
@Component({
selector: "app-files-tab-sidebar",
@ -14,7 +14,7 @@ import {TabState} from "../../../../models/TabState";
})
export class FilesTabSidebarComponent implements OnInit, OnChanges {
@Input() state!: TabState;
@Input() state!: FilesTabState;
@Input() selectedFiles: File[] = [];
@Output() searchStartEvent = new EventEmitter<void>();
@Output() searchEndEvent = new EventEmitter<void>();

@ -1,12 +1,15 @@
<mat-drawer-container (keyDownEvent)="this.onKeydown($event)" appInputReceiver autosize>
<mat-drawer disableClose mode="side" opened>
<app-drawer-page (keyDownEvent)="this.onKeydown($event)" appInputReceiver>
<app-drawer-page-side>
<app-files-tab-sidebar (searchEndEvent)="this.contentLoading = false;"
(searchStartEvent)="this.contentLoading = true;"
[selectedFiles]="this.selectedFiles"
[state]="this.state"></app-files-tab-sidebar>
</mat-drawer>
<mat-drawer-content>
<app-busy-indicator [blurBackground]="true" [busy]="contentLoading" [darkenBackground]="true">
</app-drawer-page-side>
<app-drawer-page-content>
<app-busy-indicator *ngIf="!this.metadata || this.metadata.file_count > 0"
[blurBackground]="true"
[busy]="contentLoading"
[darkenBackground]="true">
<app-file-multiview (fileSelectEvent)="this.onFileSelect($event)"
(modeChangeEvent)="state.mode.next($event)"
[files]="this.files"
@ -14,5 +17,9 @@
[preselectedFile]="this.getStateSelectedFile()"
[tabState]="this.state"></app-file-multiview>
</app-busy-indicator>
</mat-drawer-content>
</mat-drawer-container>
<app-middle-centered *ngIf="this.metadata && this.metadata.file_count === 0" class="import-prompt">
<div><h1>There are no files in this repository.</h1></div>
<button (click)="this.onImportFiles()" color="primary" mat-flat-button>Import files</button>
</app-middle-centered>
</app-drawer-page-content>
</app-drawer-page>

@ -2,24 +2,6 @@ mat-selection-list {
height: 100%;
}
mat-drawer {
height: 100%;
width: 25%;
overflow: hidden;
}
mat-drawer-content {
overflow-x: hidden;
overflow-y: auto;
height: 100%;
}
mat-drawer-container {
height: 100%;
width: 100%;
overflow: hidden;
}
app-file-multiview {
padding: 0;
height: 100%;
@ -43,3 +25,11 @@ app-file-multiview {
margin: auto;
}
}
.import-prompt {
button {
font-size: 1.5em;
padding: 0.5em 1em;
border-radius: 0.5em;
}
}

@ -1,6 +1,9 @@
import {Component, Input, OnInit} from "@angular/core";
import {File} from "../../../../api/models/File";
import {TabState} from "../../../models/TabState";
import {FilesTabState} from "../../../models/state/FilesTabState";
import {RepositoryMetadata} from "../../../../api/api-types/repo";
import {RepositoryService} from "../../../services/repository/repository.service";
import {TabCategory} from "../../../models/state/TabCategory";
@Component({
selector: "app-files-tab",
@ -9,13 +12,17 @@ import {TabState} from "../../../models/TabState";
})
export class FilesTabComponent implements OnInit {
@Input() state!: TabState;
@Input() state!: FilesTabState;
files: File[] = [];
contentLoading = false;
selectedFiles: File[] = [];
public metadata?: RepositoryMetadata;
constructor() {
constructor(
repoService: RepositoryService,
) {
repoService.metadata.subscribe(m => this.metadata = m);
}
async ngOnInit() {
@ -50,4 +57,8 @@ export class FilesTabComponent implements OnInit {
break;
}
}
public onImportFiles(): void {
this.state.category = TabCategory.Import;
}
}

@ -1,8 +1,8 @@
<div class="import-tab-inner">
<mat-tab-group headerPosition="below">
<mat-tab label="Import">
<app-file-import (fileImported)="fileImported.emit($event)"
(importFinished)="importFinished.emit($event)"></app-file-import>
<app-file-import
[state]="this.state"></app-file-import>
</mat-tab>
<mat-tab *ngIf="selectedFiles.length > 0" label="Edit Tags">
<app-tag-edit [files]="selectedFiles"></app-tag-edit>

@ -1,13 +1,16 @@
import {Component, EventEmitter, Input, Output} from "@angular/core";
import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {ImportTabState} from "../../../../models/state/ImportTabState";
@Component({
selector: "app-import-tab-sidebar",
templateUrl: "./import-tab-sidebar.component.html",
styleUrls: ["./import-tab-sidebar.component.scss"]
styleUrls: ["./import-tab-sidebar.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ImportTabSidebarComponent {
@Input() state!: ImportTabState;
@Input() selectedFiles: File[] = [];
@Output() fileImported = new EventEmitter<File>();
@Output() importFinished = new EventEmitter<void>();

@ -1,14 +1,14 @@
<mat-drawer-container autosize>
<mat-drawer disableClose="true" mode="side" opened>
<app-import-tab-sidebar (fileImported)="this.addFileFromImport($event)"
(importFinished)="this.refreshFileView()"
[selectedFiles]="selectedFiles"></app-import-tab-sidebar>
</mat-drawer>
<mat-drawer-content>
<app-drawer-page>
<app-drawer-page-side>
<app-import-tab-sidebar
[selectedFiles]="selectedFiles"
[state]="this.state"></app-import-tab-sidebar>
</app-drawer-page-side>
<app-drawer-page-content>
<app-file-multiview (fileSelectEvent)="this.onFileSelect($event)"
(modeChangeEvent)="this.state.mode.next($event)"
[files]="this.files"
[mode]="this.state.mode.value"
[preselectedFile]="this.getSelectedFileFromState()"></app-file-multiview>
</mat-drawer-content>
</mat-drawer-container>
</app-drawer-page-content>
</app-drawer-page>

@ -1,21 +1,3 @@
mat-drawer-container {
height: 100%;
width: 100%;
margin: 0;
overflow: hidden;
}
mat-drawer-content {
overflow-x: hidden;
overflow-y: auto;
height: 100%;
}
mat-drawer {
height: 100%;
width: 25%;
}
app-import-tab-sidebar, app-file-multiview {
height: 100%;
width: 100%;

@ -1,48 +1,27 @@
import {Component, Input, OnInit} from "@angular/core";
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit} from "@angular/core";
import {File} from "../../../../api/models/File";
import {TabState} from "../../../models/TabState";
import {ImportTabState} from "../../../models/state/ImportTabState";
@Component({
selector: "app-import-tab",
templateUrl: "./import-tab.component.html",
styleUrls: ["./import-tab.component.scss"]
styleUrls: ["./import-tab.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ImportTabComponent implements OnInit {
@Input() state!: TabState;
@Input() state!: ImportTabState;
public files: File[] = [];
public selectedFiles: File[] = [];
private newFiles: File[] = [];
constructor() {
constructor(private changeDetector: ChangeDetectorRef) {
}
public ngOnInit(): void {
this.state.files.subscribe(files => files ? this.files = files : undefined);
}
/**
* Adds an imported file to the list of imported files
* @param {File} file
* @returns {Promise<void>}
*/
public async addFileFromImport(file: File) {
this.newFiles.push(file);
if (this.newFiles.length % 50 === 0) { // refresh every 50 pictures
this.refreshFileView();
}
}
/**
* Refreshes the file view
* @returns {Promise<void>}
*/
public refreshFileView() {
this.state.files.next([...this.state.files.value, ...this.newFiles]);
this.newFiles = [];
}
public onFileSelect(files: File[]) {
this.selectedFiles = files;

@ -7,6 +7,10 @@
<app-repository-card (openEvent)="this.onOpenRepository($event)"
[repository]="repository"></app-repository-card>
</div>
<app-middle-centered *ngIf="this.repositories.length === 0" class="add-repository-prompt">
<h1>There are no repositories yet. You can create a repository or add an existing one.</h1>
<button (click)="this.openAddRepositoryDialog()" color="primary" mat-flat-button>Add Repository</button>
</app-middle-centered>
</div>
</div>
<div *ngIf="selectedRepository" class="repo-details">

@ -31,3 +31,11 @@ app-repository-card {
app-repository-details-view, .repo-details {
height: 100%;
}
.add-repository-prompt {
button {
font-size: 1.5em;
padding: 0.5em 1em;
border-radius: 0.5em;
}
}

@ -119,7 +119,9 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit {
"Migrating content descriptors to new format...");
await this.jobService.runJob("MigrateContentDescriptors");
dialogContext.message.next("Calculating repository sizes...");
await this.jobService.runJob("CalculateSizes");
await this.jobService.runJob("CalculateSizes", false);
dialogContext.message.next("Generating missing thumbnails...");
await this.jobService.runJob("GenerateThumbnails");
dialogContext.message.next("Finished repository startup");
}
@ -128,7 +130,7 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit {
"Opening repository...");
let dialog = this.dialog.open(BusyDialogComponent, {
data: {
title: `Opening repository ${repository.name}`,
title: `Opening repository '${repository.name}'`,
message: dialogMessage,
allowCancel: true,
}, disableClose: true,

@ -4,6 +4,7 @@ import {BusyIndicatorComponent} from "./busy-indicator/busy-indicator.component"
import {ContextMenuComponent} from "./context-menu/context-menu.component";
import {CommonModule} from "@angular/common";
import {NgIconsModule} from "@ng-icons/core";
import {MatChevronLeft, MatChevronRight} from "@ng-icons/material-icons";
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
import {MatButtonModule} from "@angular/material/button";
import {MatDialogModule} from "@angular/material/dialog";
@ -15,6 +16,15 @@ import {BusyDialogComponent} from "./busy-dialog/busy-dialog.component";
import {SelectableComponent} from "./selectable/selectable.component";
import {MatProgressBarModule} from "@angular/material/progress-bar";
import {HasPropertyPipe} from "./pipes/has-property.pipe";
import {DrawerPageComponent} from "./drawer-page/drawer-page.component";
import {MatSidenavModule} from "@angular/material/sidenav";
import {DrawerPageSideComponent} from "./drawer-page/drawer-page-side/drawer-page-side.component";
import {DrawerPageContentComponent} from "./drawer-page/drawer-page-content/drawer-page-content.component";
import {FlexLayoutModule} from "@angular/flex-layout";
import {MatRippleModule} from "@angular/material/core";
import {FlapButtonComponent} from "./flap-button/flap-button.component";
import {MiddleCenteredComponent} from "./middle-centered/middle-centered.component";
import {FormatBytesPipe} from "./pipes/format-bytes.pipe";
@NgModule({
@ -28,6 +38,12 @@ import {HasPropertyPipe} from "./pipes/has-property.pipe";
BusyDialogComponent,
SelectableComponent,
HasPropertyPipe,
DrawerPageComponent,
DrawerPageSideComponent,
DrawerPageContentComponent,
FlapButtonComponent,
MiddleCenteredComponent,
FormatBytesPipe,
],
exports: [
ConfirmDialogComponent,
@ -38,15 +54,24 @@ import {HasPropertyPipe} from "./pipes/has-property.pipe";
MetadataEntryComponent,
SelectableComponent,
HasPropertyPipe,
DrawerPageComponent,
DrawerPageSideComponent,
DrawerPageContentComponent,
FlapButtonComponent,
MiddleCenteredComponent,
FormatBytesPipe,
],
imports: [
CommonModule,
NgIconsModule.withIcons({}),
NgIconsModule.withIcons({ MatChevronLeft, MatChevronRight }),
MatProgressSpinnerModule,
MatButtonModule,
MatDialogModule,
MatMenuModule,
MatProgressBarModule
MatProgressBarModule,
MatSidenavModule,
FlexLayoutModule,
MatRippleModule
]
})
export class AppCommonModule {

@ -6,7 +6,7 @@
{{message}}
</div>
<div *ngIf="this.allowCancel" class="busy-dialog-actions" mat-dialog-actions>
<button (click)="this.dialogRef.close(false)" mat-flat-button>
<button (click)="this.dialogRef.close(false)" color="primary" mat-stroked-button>
Cancel
</button>
</div>

@ -1,5 +1,14 @@
<ng-content></ng-content>
<div *ngIf="this.busy" [class.blur]="this.blurBackground" [class.darken]="this.darkenBackground"
class="busy-indicator-overlay">
<mat-progress-spinner *ngIf="this.busy" [mode]="mode" [value]="value" color="primary"></mat-progress-spinner>
<mat-progress-spinner *ngIf="indicatorType === 'spinner' && this.busy"
[mode]="mode"
[value]="value"
color="primary"></mat-progress-spinner>
<app-middle-centered *ngIf="indicatorType === 'pulse' && this.busy">
<div class="loading-indicator-pulse-outer">
<div class="loading-indicator-pulse-inner"></div>
</div>
</app-middle-centered>
</div>

@ -1,3 +1,13 @@
@import "src/colors";
:host {
display: block;
position: relative;
height: 100%;
width: 100%;
margin: 0;
}
.busy-indicator-overlay {
position: absolute;
top: 0;
@ -5,12 +15,16 @@
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
display: block;
z-index: 998;
mat-progress-spinner {
z-index: 999;
margin: auto;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
}
}
@ -26,9 +40,80 @@ hidden {
display: none;
}
::ng-deep app-busy-indicator {
width: 100%;
height: 100%;
position: relative;
.loading-indicator-pulse-outer {
display: flex;
background-color: $primary;
animation-name: pulse-outer;
animation-duration: 2.5s;
border-radius: 1em;
width: 2em;
height: 2em;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
}
.loading-indicator-pulse-inner {
display: block;
margin: auto;
background-color: $primary-lighter-10;
animation-name: pulse-inner;
animation-duration: 2.5s;
border-radius: 0.5em;
width: 1em;
height: 1em;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
}
@keyframes pulse-outer {
2% {
border-radius: 1em;
width: 2em;
height: 2em;
}
48% {
border-radius: 2em;
width: 4em;
height: 4em;
}
52% {
border-radius: 2em;
width: 4em;
height: 4em;
}
98% {
border-radius: 1em;
width: 2em;
height: 2em;
}
}
@keyframes pulse-inner {
15% {
border-radius: 0.5em;
width: 1em;
height: 1em;
}
55% {
border-radius: 1.75em;
width: 2.5em;
height: 2.5em;
}
65% {
border-radius: 1.75em;
width: 2.5em;
height: 2.5em;
}
100% {
border-radius: 0.5em;
width: 1em;
height: 1em;
}
}

@ -12,6 +12,7 @@ export class BusyIndicatorComponent implements OnChanges {
@Input() blurBackground: boolean = false;
@Input() darkenBackground: boolean = false;
@Input() mode: ProgressSpinnerMode = "indeterminate";
@Input() indicatorType: "spinner" | "pulse" = "spinner";
@Input() value: number | undefined;
constructor(private changeDetector: ChangeDetectorRef) {

@ -1,5 +1,6 @@
<div #imageContainer (window:resize)="this.adjustSize(image, imageContainer)" class="image-container">
<img #image (load)="this.adjustSize(image, imageContainer)" [class.scale-height]="(!scaleWidth) && maximizeHeight"
[class.scale-width]="scaleWidth && maximizeWidth" [src]="this.imageSrc"
<img #image (error)="this.onImageLoadError($event, image)" (load)="this.onImageLoad(image, imageContainer)"
[class.scale-height]="(!scaleWidth) && maximizeHeight" [class.scale-width]="scaleWidth && maximizeWidth"
[src]="this.imageSrc"
[style]="{borderRadius: this.borderRadius}" alt="">
</div>

@ -1,30 +1,71 @@
import {Component, ElementRef, Input, OnInit, ViewChild} from "@angular/core";
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DoCheck,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild
} from "@angular/core";
import {SafeResourceUrl} from "@angular/platform-browser";
@Component({
selector: "app-content-aware-image",
templateUrl: "./content-aware-image.component.html",
styleUrls: ["./content-aware-image.component.scss"]
styleUrls: ["./content-aware-image.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContentAwareImageComponent implements OnInit {
export class ContentAwareImageComponent implements OnInit, DoCheck, OnDestroy {
@Input() imageSrc!: string | SafeResourceUrl;
@Input() maximizeHeight: boolean = true;
@Input() maximizeWidth: boolean = true;
@Input() borderRadius: string | undefined;
@Input() decoding: "async" | "sync" | "auto" = "auto";
@ViewChild("image") image: ElementRef<HTMLImageElement> | undefined;
@Input() maxRetry = 3;
@Input() retryDelay = 200;
@ViewChild("image") imageElement?: ElementRef<HTMLImageElement>;
@ViewChild("imageContainer") imageContainer?: ElementRef<HTMLDivElement>;
@Output() appLoadEnd = new EventEmitter<void>();
@Output() appLoadError = new EventEmitter<void>();
scaleWidth = false;
private previousHeight = 0;
private previousWidth = 0;
private retryCount = 0;
private readonly checkInterval?: number;
constructor() {
constructor(private changeDetector: ChangeDetectorRef) {
this.checkInterval = setInterval(() => this.checkSize(), 1000);
}
public ngOnInit(): void {
if (this.image) {
this.image.nativeElement.decoding = this.decoding;
if (this.imageElement) {
this.imageElement.nativeElement.decoding = this.decoding;
this.changeDetector.detach();
}
}
public ngOnDestroy(): void {
clearInterval(this.checkInterval);
}
public ngDoCheck(): void {
this.checkSize();
}
public checkSize(): void {
if (this.imageElement?.nativeElement && this.imageContainer?.nativeElement) {
this.adjustSize(this.imageElement.nativeElement, this.imageContainer.nativeElement);
}
}
public onImageLoad(image: HTMLImageElement, imageContainer: HTMLDivElement): void {
this.adjustSize(image, imageContainer);
this.appLoadEnd.emit();
}
/**
@ -35,8 +76,31 @@ export class ContentAwareImageComponent implements OnInit {
public adjustSize(image: HTMLImageElement, imageContainer: HTMLDivElement): void {
const containerHeight = Math.abs(imageContainer.clientHeight);
const containerWidth = Math.abs(imageContainer.clientWidth);
if (this.previousWidth != containerWidth || this.previousHeight != containerHeight) {
this.previousHeight = containerHeight;
this.previousWidth = containerWidth;
const imageRelativeHeight = image.naturalHeight / containerHeight;
const imageRelativeWidth = image.naturalWidth / containerWidth;
this.scaleWidth = imageRelativeWidth > imageRelativeHeight;
const scaleWidth = imageRelativeWidth > imageRelativeHeight;
if (scaleWidth != this.scaleWidth) {
this.scaleWidth = scaleWidth;
this.changeDetector.detectChanges();
}
}
}
public onImageLoadError(error: ErrorEvent, image: HTMLImageElement): void {
const imageSrc = image.src;
if (this.retryCount < this.maxRetry) {
this.retryCount++;
setTimeout(() => {
image.src = imageSrc;
}, this.retryDelay * this.retryCount);
} else {
this.appLoadError.emit();
}
}
}

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {DrawerPageContentComponent} from "./drawer-page-content.component";
describe("DrawerPageContentComponent", () => {
let component: DrawerPageContentComponent;
let fixture: ComponentFixture<DrawerPageContentComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DrawerPageContentComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DrawerPageContentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,13 @@
import {ChangeDetectionStrategy, Component} from "@angular/core";
@Component({
selector: "app-drawer-page-content",
templateUrl: "./drawer-page-content.component.html",
styleUrls: ["./drawer-page-content.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DrawerPageContentComponent {
constructor() {
}
}

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {DrawerPageSideComponent} from "./drawer-page-side.component";
describe("DrawerPageSideComponent", () => {
let component: DrawerPageSideComponent;
let fixture: ComponentFixture<DrawerPageSideComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DrawerPageSideComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DrawerPageSideComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,13 @@
import {ChangeDetectionStrategy, Component} from "@angular/core";
@Component({
selector: "app-drawer-page-side",
templateUrl: "./drawer-page-side.component.html",
styleUrls: ["./drawer-page-side.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DrawerPageSideComponent {
constructor() {
}
}

@ -0,0 +1,13 @@
<mat-drawer-container autosize>
<mat-drawer [opened]="this.drawerOpened" disableClose mode="side">
<ng-content select="app-drawer-page-side"></ng-content>
</mat-drawer>
<mat-drawer-content>
<ng-content select="app-drawer-page-content"></ng-content>
<app-flap-button (appClick)="this.toggleDrawer()" align="center" attach="left">
<ng-icon *ngIf="!this.drawerOpened" name="mat-chevron-right"></ng-icon>
<ng-icon *ngIf="this.drawerOpened" name="mat-chevron-left"></ng-icon>
</app-flap-button>
</mat-drawer-content>
</mat-drawer-container>

@ -0,0 +1,39 @@
@import "src/colors";
mat-drawer {
height: 100%;
width: 25%;
overflow: hidden;
}
mat-drawer-content {
overflow-x: hidden;
overflow-y: auto;
height: 100%;
}
mat-drawer-container {
height: 100%;
width: 100%;
overflow: hidden;
}
.drawer-side-inner {
width: 100%;
height: 100%;
display: block;
margin: 0;
}
.drawer-side-top-bar {
background: $background;
}
.collapse-button {
height: 2em;
float: right;
ng-icon {
margin-top: -0.5em;
}
}

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {DrawerPageComponent} from "./drawer-page.component";
describe("DrawerPageComponent", () => {
let component: DrawerPageComponent;
let fixture: ComponentFixture<DrawerPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DrawerPageComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DrawerPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,23 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Output} from "@angular/core";
@Component({
selector: "app-drawer-page",
templateUrl: "./drawer-page.component.html",
styleUrls: ["./drawer-page.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DrawerPageComponent {
public drawerOpened = true;
@Output() appSizeChange = new EventEmitter<void>();
constructor(private changeDetecter: ChangeDetectorRef) {
}
public toggleDrawer(): void {
this.drawerOpened = !this.drawerOpened;
this.appSizeChange.emit();
this.changeDetecter.markForCheck();
}
}

@ -0,0 +1,5 @@
<div (click)="this.appClick.emit($event)"
[className]="'flap-' + this.attach + ' flap-' + this.align + ' flap-button'"
matRipple>
<ng-content></ng-content>
</div>

@ -0,0 +1,167 @@
@import "src/colors";
:host {
position: absolute;
transition-duration: 0.5s;
&:hover {
& > .flap-top, & > .flap-bottom {
height: 1.5em;
}
& > .flap-left, & > .flap-right {
width: 1.5em;
}
}
&[attach='left'], &[attach='right'] {
top: calc(50% - 5em);
height: 10em;
width: 2.5em;
}
&[attach='top'], &[attach='bottom'] {
left: calc(50% - 5em);
width: 10em;
height: 2.5em;
}
&[attach='left'] {
left: 0;
}
&[attach='right'] {
right: 0;
}
&[attach='top'] {
top: 0;
}
&[attach='bottom'] {
bottom: 0;
}
&[attach='left'], &[attach='top'][align='start'], &[attach='bottom'][align='start'] {
.flap-button {
left: 0;
}
}
&[attach='right'], &[attach='top'][align='end'], &[attach='bottom'][align='end'] {
.flap-button {
right: 0;
}
}
&[attach='top'], &[attach='left'][align='start'], &[attach='right'][align='start'] {
.flap-button {
top: 0;
}
}
&[attach='bottom'], &[attach='left'][align='end'], &[attach='right'][align='end'] {
.flap-button {
bottom: 0;
}
}
&[attach='left'][align='center'], &[attach='right'][align='center'] {
.flap-button {
top: 50%;
transform: translate(0, -50%);
}
}
&[attach='top'][align='center'], &[attach='bottom'][align='center'] {
.flap-button {
left: 50%;
transform: translate(-50%, 0);
}
}
}
.flap-button {
position: absolute;
display: flex;
opacity: 0.7;
background: $accent-darker-10;
text-align: center;
transition-duration: 0.1s;
overflow: hidden;
&:hover {
background: $accent;
opacity: 0.9;
cursor: pointer;
transition: 0.5s;
}
::ng-deep ng-icon {
margin: auto;
}
}
.flap-top, .flap-bottom {
width: 4em;
height: 2px;
}
.flap-button.flap-top:hover, .flap-button.flap-bottom:hover {
width: 10em;
height: 2em;
}
.flap-left, .flap-right {
width: 2px;
height: 4em;
}
.flap-button.flap-left:hover, .flap-button.flap-right:hover {
width: 2em;
height: 10em;
}
$flap-border-radius: 2em;
.flap-start.flap-left, .flap-start.flap-top {
border-bottom-right-radius: $flap-border-radius;
}
.flap-start.flap-right, .flap-end.flap-top {
border-bottom-left-radius: $flap-border-radius;
}
.flap-end.flap-left, .flap-start.flap-bottom {
border-top-right-radius: $flap-border-radius;
}
.flap-end.flap-right, .flap-end.flap-bottom {
border-top-left-radius: $flap-border-radius;
}
.flap-center {
&.flap-left {
border-top-right-radius: $flap-border-radius;
border-bottom-right-radius: $flap-border-radius;
}
&.flap-right {
border-top-left-radius: $flap-border-radius;
border-bottom-left-radius: $flap-border-radius;
}
&.flap-top {
border-bottom-right-radius: $flap-border-radius;
border-bottom-left-radius: $flap-border-radius;
}
&.flap-bottom {
border-top-right-radius: $flap-border-radius;
border-top-left-radius: $flap-border-radius;
}
}

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {FlapButtonComponent} from "./flap-button.component";
describe("FlapButtonComponent", () => {
let component: FlapButtonComponent;
let fixture: ComponentFixture<FlapButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [FlapButtonComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(FlapButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,20 @@
import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from "@angular/core";
export type Attachment = "top" | "bottom" | "left" | "right";
export type Alignment = "start" | "center" | "end";
@Component({
selector: "app-flap-button",
templateUrl: "./flap-button.component.html",
styleUrls: ["./flap-button.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlapButtonComponent {
@Input() attach: Attachment = "top";
@Input() align: Alignment = "center";
@Output() appClick = new EventEmitter<MouseEvent>();
constructor() {
}
}

@ -0,0 +1,7 @@
:host {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {MiddleCenteredComponent} from "./middle-centered.component";
describe("MiddleCenteredComponent", () => {
let component: MiddleCenteredComponent;
let fixture: ComponentFixture<MiddleCenteredComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MiddleCenteredComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MiddleCenteredComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,13 @@
import {ChangeDetectionStrategy, Component} from "@angular/core";
@Component({
selector: "app-middle-centered",
templateUrl: "./middle-centered.component.html",
styleUrls: ["./middle-centered.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MiddleCenteredComponent {
constructor() {
}
}

@ -0,0 +1,8 @@
import {FormatBytesPipe} from "./format-bytes.pipe";
describe("FormatBytesPipe", () => {
it("create an instance", () => {
const pipe = new FormatBytesPipe();
expect(pipe).toBeTruthy();
});
});

@ -0,0 +1,24 @@
import {Pipe, PipeTransform} from "@angular/core";
@Pipe({
name: "formatBytes"
})
export class FormatBytesPipe implements PipeTransform {
static round(number: number, decimals: number) {
return Math.round(number * (10 ** decimals)) / (10 ** decimals);
}
transform(value: number): string {
const units = ["B", "KiB", "MiB", "GiB"];
let formattedValue = value;
for (const unit of units) {
if (formattedValue < 1000) {
return `${formattedValue} ${unit}`;
}
formattedValue = FormatBytesPipe.round(formattedValue / 1024, 2);
}
return formattedValue + " GiB";
}
}

@ -1,5 +1,7 @@
@import "src/colors";
.selectable.selected {
background-color: #5c5c5c;
background-color: $background-lighter-10;
}
body {

@ -10,7 +10,14 @@
<div (cdkDragMoved)="this.onDragMoved($event)"
[cdkDragFreeDragPosition]="this.imagePosition" cdkDrag class="image-drag-container">
<div [style]="{scale: this.imageZoom}" class="image-scale-container">
<app-content-aware-image [imageSrc]="this.imageUrl" decoding="sync"></app-content-aware-image>
<app-busy-indicator [busy]="this.loading" indicatorType="pulse">
<app-content-aware-image (appLoadEnd)="this.loading = false" (appLoadError)="this.loading = false"
[class.hidden]="this.loading"
[imageSrc]="this.imageUrl"
[maxRetry]="3"
[retryDelay]="500"
decoding="sync"></app-content-aware-image>
</app-busy-indicator>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save