From ead5d96b78252bbc667febb1843f74034fe043b5 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Thu, 1 Oct 2026 21:08:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=BB=E6=A4=8D=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=90=8E=E7=AB=AF=EF=BC=88=E7=B4=A2=E5=BC=95?= =?UTF-8?q?/=E5=88=86=E7=BB=84/=E7=BC=96=E8=BE=91=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app/src/api/files.rs | 2 +- .../src/api/{instance.rs => instance/mod.rs} | 184 +++++ .../20261002000000_screenshot-center.sql | 38 + .../src/api/{instance.rs => instance/mod.rs} | 15 + .../src/api/instance/screenshot_groups.rs | 348 ++++++++++ .../src/api/instance/screenshots/mod.rs | 10 + .../api/instance/screenshots/operations.rs | 650 ++++++++++++++++++ .../instance/screenshots/reconciliation.rs | 302 ++++++++ packages/app-lib/src/event/mod.rs | 1 + .../adapters/sqlite/instance_rows.rs | 28 + .../state/instances/adapters/sqlite/mod.rs | 1 + .../adapters/sqlite/screenshot_rows.rs | 236 +++++++ packages/app-lib/src/state/mod.rs | 15 + 13 files changed, 1829 insertions(+), 1 deletion(-) rename apps/app/src/api/{instance.rs => instance/mod.rs} (88%) create mode 100644 packages/app-lib/migrations/20261002000000_screenshot-center.sql rename packages/app-lib/src/api/{instance.rs => instance/mod.rs} (83%) create mode 100644 packages/app-lib/src/api/instance/screenshot_groups.rs create mode 100644 packages/app-lib/src/api/instance/screenshots/mod.rs create mode 100644 packages/app-lib/src/api/instance/screenshots/operations.rs create mode 100644 packages/app-lib/src/api/instance/screenshots/reconciliation.rs create mode 100644 packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs diff --git a/apps/app/src/api/files.rs b/apps/app/src/api/files.rs index e1fac14..68ac99a 100644 --- a/apps/app/src/api/files.rs +++ b/apps/app/src/api/files.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use std::sync::Mutex; use tauri::{Emitter, Runtime}; use tauri_plugin_dialog::DialogExt; -use theseus::instance::get_full_path; +use theseus::instance::{get_full_path, list}; const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed"; diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance/mod.rs similarity index 88% rename from apps/app/src/api/instance.rs rename to apps/app/src/api/instance/mod.rs index 6f48dad..4eba43e 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance/mod.rs @@ -4,6 +4,8 @@ use chrono::NaiveDate; use dashmap::DashMap; use path_util::SafeRelativeUtf8UnixPathBuf; use serde::{Deserialize, Serialize}; +use tauri::Manager; +use tauri_plugin_opener::OpenerExt; use std::collections::HashMap; use std::path::{Path, PathBuf}; use theseus::data::{ @@ -103,6 +105,20 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_edit_icon, instance_export_mrpack, instance_get_pack_export_candidates, + instance_list_screenshots, + instance_list_all_screenshots, + instance_list_synced_screenshots, + instance_save_edited_screenshot, + instance_list_screenshot_groups, + instance_create_screenshot_group, + instance_rename_screenshot_group, + instance_delete_screenshot_group, + instance_set_screenshot_group_memberships, + instance_import_screenshot_groups, + instance_delete_screenshots, + instance_export_screenshots, + instance_move_screenshots, + instance_open_screenshot, ]) .build() } @@ -1490,3 +1506,171 @@ pub async fn instance_edit_icon( theseus::instance::edit_icon(instance_id, icon_path).await?; Ok(()) } + +#[derive(Serialize, Debug, Clone)] +pub struct InstanceScreenshot { + pub id: String, + pub instance_id: String, + pub instance_name: String, + pub file_name: String, + pub created_at: chrono::DateTime, + pub modified_at: i64, + pub group_id: Option, + pub path: PathBuf, + pub url: String, +} + +fn serialize_screenshot( + app: &tauri::AppHandle, + s: theseus::instance::InstanceScreenshot, +) -> Result { + if let Some(parent) = s.path.parent() { + crate::api::files::ensure_browsable(app, parent); + } + let mut url = super::utils::tauri_convert_file_src(&s.path)?; + app.asset_protocol_scope().allow_file(&s.path)?; + app.fs_scope().allow_file(&s.path)?; + url.query_pairs_mut() + .append_pair("revision", &s.modified_at.to_string()); + Ok(InstanceScreenshot { + id: s.id, + instance_id: s.instance_id, + instance_name: s.instance_name, + file_name: s.file_name, + created_at: s.created_at, + modified_at: s.modified_at, + group_id: s.group_id, + path: s.path, + url: url.to_string(), + }) +} + +fn serialize_screenshots( + app: &tauri::AppHandle, + items: Vec, +) -> Result> { + items + .into_iter() + .map(|s| serialize_screenshot(app, s)) + .collect() +} + +#[tauri::command] +pub async fn instance_list_screenshots( + app: tauri::AppHandle, + instance_id: &str, +) -> Result> { + serialize_screenshots( + &app, + theseus::instance::list_screenshots(instance_id).await?, + ) +} + +#[tauri::command] +pub async fn instance_list_all_screenshots( + app: tauri::AppHandle, +) -> Result> { + serialize_screenshots( + &app, + theseus::instance::list_all_screenshots().await?, + ) +} + +#[tauri::command] +pub async fn instance_list_synced_screenshots( + app: tauri::AppHandle, +) -> Result> { + serialize_screenshots( + &app, + theseus::instance::list_synced_screenshots().await?, + ) +} + +#[tauri::command] +pub async fn instance_save_edited_screenshot( + app: tauri::AppHandle, + key: theseus::instance::ScreenshotKey, + bytes: Vec, + mode: theseus::instance::ScreenshotEditSaveMode, +) -> Result { + serialize_screenshot( + &app, + theseus::instance::save_edited_screenshot(key, bytes, mode).await?, + ) +} + +#[tauri::command] +pub async fn instance_list_screenshot_groups() +-> Result> { + Ok(theseus::instance::list_screenshot_groups().await?) +} + +#[tauri::command] +pub async fn instance_create_screenshot_group( + name: String, + ids: Vec, +) -> Result { + Ok(theseus::instance::create_screenshot_group(name, ids).await?) +} + +#[tauri::command] +pub async fn instance_rename_screenshot_group( + id: String, + new_name: String, +) -> Result { + Ok(theseus::instance::rename_screenshot_group(id, new_name).await?) +} + +#[tauri::command] +pub async fn instance_delete_screenshot_group(id: String) -> Result<()> { + Ok(theseus::instance::delete_screenshot_group(id).await?) +} + +#[tauri::command] +pub async fn instance_set_screenshot_group_memberships( + updates: Vec, +) -> Result<()> { + Ok(theseus::instance::set_screenshot_group_memberships(updates).await?) +} + +#[tauri::command] +pub async fn instance_import_screenshot_groups( + groups: Vec, +) -> Result<()> { + Ok(theseus::instance::import_screenshot_groups(groups).await?) +} + +#[tauri::command] +pub async fn instance_delete_screenshots( + keys: Vec, +) -> Result<()> { + Ok(theseus::instance::delete_screenshots(&keys).await?) +} + +#[tauri::command] +pub async fn instance_export_screenshots( + keys: Vec, + path: PathBuf, +) -> Result<()> { + Ok(theseus::instance::export_screenshots(&keys, path).await?) +} + +#[tauri::command] +pub async fn instance_move_screenshots( + keys: Vec, + target: &str, +) -> Result> { + Ok(theseus::instance::move_screenshots(&keys, target).await?) +} + +#[tauri::command] +pub async fn instance_open_screenshot( + app: tauri::AppHandle, + key: theseus::instance::ScreenshotKey, +) -> Result<()> { + let path = theseus::instance::get_screenshot_path(&key).await?; + app.opener() + .reveal_item_in_dir(path) + .map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(()) +} \ No newline at end of file diff --git a/packages/app-lib/migrations/20261002000000_screenshot-center.sql b/packages/app-lib/migrations/20261002000000_screenshot-center.sql new file mode 100644 index 0000000..26ceb05 --- /dev/null +++ b/packages/app-lib/migrations/20261002000000_screenshot-center.sql @@ -0,0 +1,38 @@ +-- Screenshot center: unified index of per-instance screenshots plus +-- user-defined groups. Ported from the Axolotl implementation, trimmed of +-- the instance-sync / game-options tables that Starlight does not use. + +CREATE TABLE screenshots ( + id TEXT NOT NULL PRIMARY KEY, + instance_id TEXT NOT NULL, + file_name TEXT NOT NULL, + content_hash TEXT NOT NULL, + file_size INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + editor_state TEXT, + UNIQUE (instance_id, file_name), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX screenshots_instance_id ON screenshots(instance_id); +CREATE INDEX screenshots_instance_hash ON screenshots(instance_id, content_hash, file_size); + +CREATE TABLE screenshot_groups ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + CHECK (length(trim(name)) > 0) +); + +CREATE INDEX screenshot_groups_display_order ON screenshot_groups(display_order); + +CREATE TABLE screenshot_group_memberships ( + screenshot_id TEXT NOT NULL PRIMARY KEY, + group_id TEXT NOT NULL, + FOREIGN KEY (screenshot_id) REFERENCES screenshots(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES screenshot_groups(id) ON DELETE CASCADE +); + +CREATE INDEX screenshot_group_memberships_group_id ON screenshot_group_memberships(group_id); +ALTER TABLE settings ADD COLUMN show_screenshots_tab_in_instances INTEGER NOT NULL DEFAULT FALSE CHECK (show_screenshots_tab_in_instances IN (0, 1)); \ No newline at end of file diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance/mod.rs similarity index 83% rename from packages/app-lib/src/api/instance.rs rename to packages/app-lib/src/api/instance/mod.rs index a4fc619..067bdd9 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance/mod.rs @@ -12,6 +12,8 @@ mod paths; mod planet_minecraft; mod projects; mod run; +mod screenshot_groups; +mod screenshots; mod upgrade; pub use self::content::{ @@ -77,4 +79,17 @@ pub use self::upgrade::{ resolve_custom_instance_upgrade_solution, select_instance_upgrade_solution, update_instance_upgrade_resolution, update_instance_upgrade_resolutions, }; +pub use self::screenshot_groups::{ + ScreenshotGroup, ScreenshotGroupImport, ScreenshotGroupMembershipUpdate, + create_screenshot_group, delete_screenshot_group, import_screenshot_groups, + list_screenshot_groups, rename_screenshot_group, + set_screenshot_group_memberships, +}; +pub(crate) use self::screenshots::reconcile_screenshots; +pub use self::screenshots::{ + InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey, + delete_screenshots, export_screenshots, get_screenshot_path, + list_all_screenshots, list_screenshots, list_synced_screenshots, + move_screenshots, save_edited_screenshot, +}; pub use crate::state::{DailyPlaytime, DailyPlaytimeEntry}; diff --git a/packages/app-lib/src/api/instance/screenshot_groups.rs b/packages/app-lib/src/api/instance/screenshot_groups.rs new file mode 100644 index 0000000..bf78b3e --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshot_groups.rs @@ -0,0 +1,348 @@ +use crate::State; +use crate::event::InstancePayloadType; +use crate::event::emit::emit_instance; +use crate::state::instances::adapters::sqlite::screenshot_rows; +use serde::{Deserialize, Serialize}; +use sqlx::{Sqlite, Transaction}; +use std::collections::HashSet; +use uuid::Uuid; + +const MAX_GROUP_NAME_LENGTH: usize = 256; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ScreenshotGroup { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ScreenshotGroupMembershipUpdate { + pub screenshot_id: String, + pub group_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ScreenshotGroupImport { + pub id: String, + pub name: String, + pub screenshot_ids: Vec, +} + +fn validate_group_name(name: &str) -> crate::Result<&str> { + let name = name.trim(); + if name.is_empty() { + return Err(crate::ErrorKind::InputError( + "Group name cannot be empty".to_string(), + ) + .into()); + } + if name.chars().count() > MAX_GROUP_NAME_LENGTH { + return Err(crate::ErrorKind::InputError(format!( + "Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters" + )) + .into()); + } + Ok(name) +} + +pub async fn list_screenshot_groups() -> crate::Result> { + let state = State::get().await?; + Ok(screenshot_rows::list_groups(&state.pool) + .await? + .into_iter() + .map(|group| ScreenshotGroup { + id: group.id, + name: group.name, + }) + .collect()) +} + +pub async fn create_screenshot_group( + name: String, + screenshot_ids: Vec, +) -> crate::Result { + let name = validate_group_name(&name)?; + let state = State::get().await?; + let id = Uuid::new_v4().to_string(); + let mut tx = state.pool.begin().await?; + + sqlx::query!( + "UPDATE screenshot_groups SET display_order = display_order + 1", + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)", + id, + name, + ) + .execute(&mut *tx) + .await?; + + let instance_ids = set_group_members( + &id, + normalize_screenshot_ids(screenshot_ids)?, + &mut tx, + ) + .await?; + tx.commit().await?; + emit_screenshot_updates(instance_ids).await; + + Ok(ScreenshotGroup { + id, + name: name.to_string(), + }) +} + +pub async fn rename_screenshot_group( + id: String, + new_name: String, +) -> crate::Result { + let new_name = validate_group_name(&new_name)?; + let state = State::get().await?; + let result = sqlx::query!( + "UPDATE screenshot_groups SET name = ? WHERE id = ?", + new_name, + id, + ) + .execute(&state.pool) + .await?; + if result.rows_affected() == 0 { + return Err(crate::ErrorKind::InputError(format!( + "Unknown screenshot group {id}" + )) + .into()); + } + + Ok(ScreenshotGroup { + id, + name: new_name.to_string(), + }) +} + +pub async fn delete_screenshot_group(id: String) -> crate::Result<()> { + let state = State::get().await?; + let instance_ids = sqlx::query_scalar!( + " + SELECT DISTINCT screenshots.instance_id + FROM screenshot_group_memberships memberships + INNER JOIN screenshots ON screenshots.id = memberships.screenshot_id + WHERE memberships.group_id = ? + ", + id, + ) + .fetch_all(&state.pool) + .await?; + let result = + sqlx::query!("DELETE FROM screenshot_groups WHERE id = ?", id,) + .execute(&state.pool) + .await?; + if result.rows_affected() == 0 { + return Err(crate::ErrorKind::InputError(format!( + "Unknown screenshot group {id}" + )) + .into()); + } + emit_screenshot_updates(instance_ids).await; + Ok(()) +} +pub async fn set_screenshot_group_memberships( + updates: Vec, +) -> crate::Result<()> { + let updates = normalize_membership_updates(updates)?; + if updates.is_empty() { + return Ok(()); + } + + let state = State::get().await?; + let mut tx = state.pool.begin().await?; + let group_ids = updates + .iter() + .filter_map(|update| update.group_id.as_deref()) + .collect::>(); + for group_id in group_ids { + ensure_group_exists(group_id, &mut tx).await?; + } + + let mut instance_ids = HashSet::new(); + for update in updates { + let instance_id = + screenshot_instance_id(&update.screenshot_id, &mut tx).await?; + instance_ids.insert(instance_id); + sqlx::query!( + "DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?", + update.screenshot_id, + ) + .execute(&mut *tx) + .await?; + if let Some(group_id) = update.group_id { + sqlx::query!( + "INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)", + update.screenshot_id, + group_id, + ) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + emit_screenshot_updates(instance_ids).await; + Ok(()) +} + +pub async fn import_screenshot_groups( + groups: Vec, +) -> crate::Result<()> { + if groups.is_empty() { + return Ok(()); + } + + let state = State::get().await?; + let mut tx = state.pool.begin().await?; + let base_order = sqlx::query_scalar!( + "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups", + ) + .fetch_one(&mut *tx) + .await?; + let mut group_ids = HashSet::new(); + let mut affected_instance_ids = HashSet::new(); + + for (index, group) in groups.into_iter().enumerate() { + if group.id.trim().is_empty() || !group_ids.insert(group.id.clone()) { + return Err(crate::ErrorKind::InputError( + "Screenshot group import contains an invalid group ID" + .to_string(), + ) + .into()); + } + let name = validate_group_name(&group.name)?; + let display_order = base_order + index as i64; + sqlx::query!( + " + INSERT INTO screenshot_groups (id, name, display_order) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name + ", + group.id, + name, + display_order, + ) + .execute(&mut *tx) + .await?; + affected_instance_ids.extend( + set_group_members( + &group.id, + normalize_screenshot_ids(group.screenshot_ids)?, + &mut tx, + ) + .await?, + ); + } + tx.commit().await?; + emit_screenshot_updates(affected_instance_ids).await; + Ok(()) +} + +fn normalize_screenshot_ids( + screenshot_ids: Vec, +) -> crate::Result> { + let mut unique = HashSet::new(); + if screenshot_ids + .iter() + .all(|screenshot_id| unique.insert(screenshot_id.clone())) + { + return Ok(screenshot_ids); + } + Err(crate::ErrorKind::InputError( + "Screenshot group contains duplicate screenshots".to_string(), + ) + .into()) +} + +fn normalize_membership_updates( + updates: Vec, +) -> crate::Result> { + let mut screenshot_ids = HashSet::new(); + if updates + .iter() + .all(|update| screenshot_ids.insert(update.screenshot_id.clone())) + { + return Ok(updates); + } + Err(crate::ErrorKind::InputError( + "Screenshot membership update contains duplicate screenshots".to_string(), + ) + .into()) +} + +async fn set_group_members( + group_id: &str, + screenshot_ids: Vec, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result> { + let mut instance_ids = HashSet::new(); + for screenshot_id in screenshot_ids { + instance_ids.insert(screenshot_instance_id(&screenshot_id, tx).await?); + sqlx::query!( + " + INSERT INTO screenshot_group_memberships (screenshot_id, group_id) + VALUES (?, ?) + ON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id + ", + screenshot_id, + group_id, + ) + .execute(&mut **tx) + .await?; + } + Ok(instance_ids) +} + +async fn ensure_group_exists( + group_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS "exists!: bool""#, + group_id, + ) + .fetch_one(&mut **tx) + .await?; + if !exists { + return Err(crate::ErrorKind::InputError(format!( + "Unknown screenshot group {group_id}" + )) + .into()); + } + Ok(()) +} + +async fn screenshot_instance_id( + screenshot_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result { + sqlx::query_scalar!( + "SELECT instance_id FROM screenshots WHERE id = ?", + screenshot_id, + ) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown screenshot {screenshot_id}" + )) + }) + .map_err(Into::into) +} + +async fn emit_screenshot_updates( + instance_ids: impl IntoIterator, +) { + for instance_id in instance_ids { + let _ = emit_instance( + &instance_id, + InstancePayloadType::ScreenshotsUpdated, + ) + .await; + } +} \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/screenshots/mod.rs b/packages/app-lib/src/api/instance/screenshots/mod.rs new file mode 100644 index 0000000..7ca206f --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshots/mod.rs @@ -0,0 +1,10 @@ +mod operations; +mod reconciliation; + +pub use self::operations::{ + InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey, + delete_screenshots, export_screenshots, get_screenshot_path, + list_all_screenshots, list_screenshots, list_synced_screenshots, + move_screenshots, save_edited_screenshot, +}; +pub(crate) use self::reconciliation::reconcile_screenshots; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/screenshots/operations.rs b/packages/app-lib/src/api/instance/screenshots/operations.rs new file mode 100644 index 0000000..e905ef6 --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshots/operations.rs @@ -0,0 +1,650 @@ +use chrono::{DateTime, Utc}; +use futures::stream::{self, StreamExt, TryStreamExt}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::io::Cursor; +use std::path::{Component, Path, PathBuf}; +use tokio::sync::OwnedMutexGuard; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipWriter}; + +use super::reconciliation::{ + list_source_screenshots, reconcile_source_screenshots, + scan_source_screenshots, +}; +use crate::State; +use crate::event::InstancePayloadType; +use crate::event::emit::emit_instance; +use crate::state::instances::adapters::sqlite::{ + instance_rows::{self, InstanceScreenshotSource}, + screenshot_rows, +}; +use crate::util::fetch::sha1_file_async; +use crate::util::io::{self, IOError}; + +const SCREENSHOTS_DIRECTORY: &str = "screenshots"; +const SCREENSHOT_SCAN_CONCURRENCY: usize = 8; + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct ScreenshotKey { + pub instance_id: String, + pub file_name: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct InstanceScreenshot { + pub id: String, + pub instance_id: String, + pub instance_name: String, + pub file_name: String, + pub created_at: DateTime, + pub modified_at: i64, + pub group_id: Option, + #[serde(skip)] + pub path: PathBuf, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScreenshotEditSaveMode { + CreateCopy, + ReplaceEdit, +} + +pub async fn list_screenshots( + instance_id: &str, +) -> crate::Result> { + let state = State::get().await?; + let source = + instance_rows::get_instance_screenshot_source(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + + list_source_screenshots(&state, source).await +} + +pub async fn list_synced_screenshots() -> crate::Result> +{ + list_all_screenshots().await +} + +pub async fn list_all_screenshots() -> crate::Result> { + let state = State::get().await?; + let sources = instance_rows::list_screenshot_sources(&state.pool).await?; + list_source_screenshot_sets(&state, sources).await +} + +async fn list_source_screenshot_sets( + state: &State, + sources: Vec, +) -> crate::Result> { + let mut screenshots = + stream::iter(sources.into_iter().map(|source| async move { + list_source_screenshots(state, source).await + })) + .buffer_unordered(SCREENSHOT_SCAN_CONCURRENCY) + .try_collect::>>() + .await? + .into_iter() + .flatten() + .collect::>(); + + sort_screenshots(&mut screenshots); + Ok(screenshots) +} + +async fn lock_instance_screenshots<'a>( + state: &State, + instance_ids: impl IntoIterator, +) -> Vec> { + let mut locks = Vec::new(); + for instance_id in instance_ids { + locks.push(state.lock_instance_screenshots(instance_id).await); + } + locks +} + +pub async fn delete_screenshots(keys: &[ScreenshotKey]) -> crate::Result<()> { + ensure_unique_keys(keys)?; + let state = State::get().await?; + let mut instance_ids = keys + .iter() + .map(|key| key.instance_id.as_str()) + .collect::>(); + instance_ids.sort_unstable(); + instance_ids.dedup(); + let _locks = lock_instance_screenshots(&state, instance_ids).await; + + for key in keys { + io::remove_file(get_screenshot_path(key).await?).await?; + screenshot_rows::delete_screenshot_by_key( + &key.instance_id, + &key.file_name, + &state.pool, + ) + .await?; + } + + Ok(()) +} + +pub async fn export_screenshots( + keys: &[ScreenshotKey], + export_path: PathBuf, +) -> crate::Result<()> { + ensure_unique_keys(keys)?; + if keys.is_empty() { + return Err(crate::ErrorKind::InputError( + "At least one screenshot must be selected".to_string(), + ) + .into()); + } + + let state = State::get().await?; + let mut sources = HashMap::new(); + let mut archive_folders = HashMap::::new(); + let mut used_archive_folders = HashSet::new(); + let mut screenshots = Vec::with_capacity(keys.len()); + + for key in keys { + let source = match sources.get(&key.instance_id) { + Some(source) => source, + None => { + let source = instance_rows::get_instance_screenshot_source( + &key.instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + sources.insert(key.instance_id.clone(), source); + &sources[&key.instance_id] + } + }; + let archive_folder = match archive_folders.get(&key.instance_id) { + Some(folder) => folder.clone(), + None => { + let folder = unique_archive_folder( + &sanitize_archive_component(&source.name), + &mut used_archive_folders, + ); + archive_folders.insert(key.instance_id.clone(), folder.clone()); + folder + } + }; + let archive_name = format!("{archive_folder}/{}", key.file_name); + screenshots.push((archive_name, get_screenshot_path(key).await?)); + } + + tokio::task::spawn_blocking(move || -> crate::Result<()> { + let file = std::fs::File::create(&export_path) + .map_err(|error| IOError::with_path(error, &export_path))?; + let mut writer = ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(CompressionMethod::Stored); + + for (archive_name, path) in screenshots { + writer + .start_file(archive_name, options) + .map_err(std::io::Error::from)?; + let mut source = std::fs::File::open(&path) + .map_err(|error| IOError::with_path(error, &path))?; + std::io::copy(&mut source, &mut writer).map_err(IOError::from)?; + } + + writer.finish().map_err(std::io::Error::from)?; + Ok(()) + }) + .await??; + + Ok(()) +} +pub async fn move_screenshots( + keys: &[ScreenshotKey], + target_instance_id: &str, +) -> crate::Result> { + ensure_unique_keys(keys)?; + if keys.is_empty() { + return Ok(Vec::new()); + } + + let state = State::get().await?; + let mut instance_ids = keys + .iter() + .map(|key| key.instance_id.as_str()) + .chain(std::iter::once(target_instance_id)) + .collect::>(); + instance_ids.sort_unstable(); + instance_ids.dedup(); + let _locks = lock_instance_screenshots(&state, instance_ids).await; + let target_source = instance_rows::get_instance_screenshot_source( + target_instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown target instance".to_string()) + })?; + let target_dir = source_screenshots_dir(&state, &target_source).await?; + io::create_dir_all(&target_dir).await?; + ensure_directory_is_not_symlink(&target_dir).await?; + + let mut moved_keys = Vec::with_capacity(keys.len()); + for key in keys { + if key.instance_id == target_instance_id { + continue; + } + + let source_path = get_screenshot_path(key).await?; + let target_path = + available_target_path(&target_dir, &key.file_name).await?; + io::rename_or_move(&source_path, &target_path) + .await + .map_err(|error| { + crate::ErrorKind::InputError(format!( + "Could not move screenshot: {error}" + )) + })?; + let file_name = target_path + .file_name() + .and_then(|file_name| file_name.to_str()) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Could not determine moved screenshot file name" + .to_string(), + ) + })? + .to_string(); + screenshot_rows::move_screenshot( + &key.instance_id, + &key.file_name, + target_instance_id, + &file_name, + &state.pool, + ) + .await?; + moved_keys.push(ScreenshotKey { + instance_id: target_instance_id.to_string(), + file_name, + }); + } + + Ok(moved_keys) +} + +pub async fn get_screenshot_path( + key: &ScreenshotKey, +) -> crate::Result { + validate_file_name(&key.file_name)?; + + let state = State::get().await?; + let source = instance_rows::get_instance_screenshot_source( + &key.instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let screenshots_dir = source_screenshots_dir(&state, &source).await?; + let canonical_dir = tokio::fs::canonicalize(&screenshots_dir) + .await + .map_err(|error| IOError::with_path(error, &screenshots_dir))?; + let requested_path = screenshots_dir.join(&key.file_name); + let metadata = tokio::fs::symlink_metadata(&requested_path) + .await + .map_err(|error| IOError::with_path(error, &requested_path))?; + + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(crate::ErrorKind::InputError( + "Screenshot must be a regular file".to_string(), + ) + .into()); + } + + let canonical_path = tokio::fs::canonicalize(&requested_path) + .await + .map_err(|error| IOError::with_path(error, &requested_path))?; + if !canonical_path.starts_with(&canonical_dir) { + return Err(crate::ErrorKind::InputError( + "Screenshot path is outside the instance screenshots directory" + .to_string(), + ) + .into()); + } + + Ok(canonical_path) +} + +pub async fn save_edited_screenshot( + key: ScreenshotKey, + png_bytes: Vec, + mode: ScreenshotEditSaveMode, +) -> crate::Result { + validate_file_name(&key.file_name)?; + + let state = State::get().await?; + let source = instance_rows::get_instance_screenshot_source( + &key.instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let _lock = state.lock_instance_screenshots(&source.id).await; + + let scanned = scan_source_screenshots(&state, &source).await?; + let current = + reconcile_source_screenshots(&state, &source, scanned).await?; + let source_screenshot = current + .iter() + .find(|screenshot| screenshot.file_name == key.file_name) + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown screenshot".to_string()) + })?; + let source_row = screenshot_rows::get_screenshot_by_key( + &key.instance_id, + &key.file_name, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown screenshot".to_string()) + })?; + + let png_bytes = tokio::task::spawn_blocking(move || { + validate_png_dimensions(&png_bytes)?; + Ok::<_, crate::Error>(png_bytes) + }) + .await??; + + let screenshots_dir = source_screenshots_dir(&state, &source).await?; + let (target_path, copy_group) = match mode { + ScreenshotEditSaveMode::CreateCopy => ( + available_target_path(&screenshots_dir, &source_row.file_name) + .await?, + true, + ), + ScreenshotEditSaveMode::ReplaceEdit => { + (source_screenshot.path.clone(), false) + } + }; + let target_file_name = target_path + .file_name() + .and_then(|file_name| file_name.to_str()) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Could not determine edited screenshot file name".to_string(), + ) + })? + .to_string(); + + io::write(&target_path, png_bytes).await?; + if !copy_group { + let (file_size, content_hash) = sha1_file_async(&target_path).await?; + let file_size = i64::try_from(file_size).map_err(|_| { + crate::ErrorKind::InputError( + "Screenshot is too large to index".to_string(), + ) + })?; + let mut source_row = source_row.clone(); + source_row.content_hash = content_hash; + source_row.file_size = file_size; + let mut tx = state.pool.begin().await?; + screenshot_rows::update_screenshot(&source_row, &mut tx).await?; + tx.commit().await?; + } + let scanned = scan_source_screenshots(&state, &source).await?; + let reconciled = + reconcile_source_screenshots(&state, &source, scanned).await?; + let mut saved = reconciled + .into_iter() + .find(|screenshot| screenshot.file_name == target_file_name) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Could not index edited screenshot".to_string(), + ) + })?; + + if copy_group { + let result: crate::Result<()> = async { + let mut tx = state.pool.begin().await?; + screenshot_rows::copy_group_membership( + &source_row.id, + &saved.id, + &mut tx, + ) + .await?; + tx.commit().await?; + Ok(()) + } + .await; + if let Err(error) = result { + let _ = io::remove_file(&target_path).await; + let _ = screenshot_rows::delete_screenshot_by_key( + &source.id, + &target_file_name, + &state.pool, + ) + .await; + return Err(error); + } + } + + let mut saved_row = screenshot_rows::get_screenshot_by_key( + &saved.instance_id, + &saved.file_name, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Could not load edited screenshot".to_string(), + ) + })?; + if !copy_group { + let mut tx = state.pool.begin().await?; + if saved_row.created_at != source_row.created_at { + saved_row.created_at = source_row.created_at; + screenshot_rows::update_screenshot(&saved_row, &mut tx).await?; + saved.created_at = + DateTime::from_timestamp_millis(source_row.created_at) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Screenshot creation time is out of range" + .to_string(), + ) + })?; + } + tx.commit().await?; + } + saved.modified_at = saved_row.modified_at; + saved.group_id = saved_row.group_id; + + let _ = emit_instance( + &saved.instance_id, + InstancePayloadType::ScreenshotsUpdated, + ) + .await; + + Ok(saved) +} + +pub(super) async fn source_screenshots_dir( + state: &State, + source: &InstanceScreenshotSource, +) -> crate::Result { + // Directly associated instances keep their files in the external launcher's + // resolved game directory (which may be the shared `.minecraft` root or an + // isolated version directory). Use the same runtime resolver as launching + // and content browsing so screenshots are read from the directory the game + // actually uses. + let instance_dir = + match instance_rows::get_instance_by_id(&source.id, &state.pool).await? + { + Some(instance) => crate::launcher::linked_game_dir(&instance) + .unwrap_or_else(|| { + state.directories.instances_dir().join(&source.path) + }), + None => state.directories.instances_dir().join(&source.path), + }; + let canonical_instance_dir = + tokio::fs::canonicalize(&instance_dir) + .await + .map_err(|error| IOError::with_path(error, &instance_dir))?; + let screenshots_dir = canonical_instance_dir.join(SCREENSHOTS_DIRECTORY); + + ensure_directory_is_not_symlink(&screenshots_dir).await?; + Ok(screenshots_dir) +} + +async fn ensure_directory_is_not_symlink(path: &Path) -> crate::Result<()> { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(crate::ErrorKind::InputError( + "Instance screenshots directory cannot be a symbolic link" + .to_string(), + ) + .into()) + } + Ok(metadata) if !metadata.is_dir() => { + Err(crate::ErrorKind::InputError( + "Instance screenshots path must be a directory".to_string(), + ) + .into()) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(IOError::with_path(error, path).into()), + } +} + +async fn available_target_path( + target_dir: &Path, + file_name: &str, +) -> crate::Result { + validate_file_name(file_name)?; + let original = target_dir.join(file_name); + if !tokio::fs::try_exists(&original) + .await + .map_err(|error| IOError::with_path(error, &original))? + { + return Ok(original); + } + + let path = Path::new(file_name); + let stem = path.file_stem().and_then(|value| value.to_str()).unwrap(); + let extension = path.extension().and_then(|value| value.to_str()).unwrap(); + + for suffix in 1_u32.. { + let candidate = + target_dir.join(format!("{stem} ({suffix}).{extension}")); + if !tokio::fs::try_exists(&candidate) + .await + .map_err(|error| IOError::with_path(error, &candidate))? + { + return Ok(candidate); + } + } + + unreachable!() +} + +fn validate_png_dimensions(bytes: &[u8]) -> crate::Result<(u32, u32)> { + let validate = || -> Result<_, png::DecodingError> { + let mut reader = png::Decoder::new(Cursor::new(bytes)).read_info()?; + let dimensions = (reader.info().width, reader.info().height); + while reader.next_row()?.is_some() {} + reader.finish()?; + Ok(dimensions) + }; + validate().map_err(|error| { + crate::ErrorKind::InputError(format!( + "Could not decode screenshot as PNG: {error}" + )) + .into() + }) +} + +fn ensure_unique_keys(keys: &[ScreenshotKey]) -> crate::Result<()> { + let mut unique = HashSet::new(); + if keys.iter().all(|key| unique.insert(key)) { + return Ok(()); + } + + Err(crate::ErrorKind::InputError( + "Screenshot selection contains duplicates".to_string(), + ) + .into()) +} + +fn validate_file_name(file_name: &str) -> crate::Result<()> { + let path = Path::new(file_name); + let mut components = path.components(); + let is_single_file = + matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none(); + + if !is_single_file || !has_png_extension(path) { + return Err(crate::ErrorKind::InputError( + "Invalid screenshot file name".to_string(), + ) + .into()); + } + + Ok(()) +} + +pub(super) fn has_png_extension(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("png")) +} + +pub(super) fn sort_screenshots(screenshots: &mut [InstanceScreenshot]) { + screenshots.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| left.instance_name.cmp(&right.instance_name)) + .then_with(|| left.file_name.cmp(&right.file_name)) + }); +} + +fn sanitize_archive_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|character| match character { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => character, + }) + .collect::(); + + if sanitized.is_empty() || sanitized == "." || sanitized == ".." { + "Instance".to_string() + } else { + sanitized + } +} + +fn unique_archive_folder( + base: &str, + used_folders: &mut HashSet, +) -> String { + if used_folders.insert(base.to_string()) { + return base.to_string(); + } + + for suffix in 2_u32.. { + let candidate = format!("{base} ({suffix})"); + if used_folders.insert(candidate.clone()) { + return candidate; + } + } + + unreachable!() +} \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/screenshots/reconciliation.rs b/packages/app-lib/src/api/instance/screenshots/reconciliation.rs new file mode 100644 index 0000000..e6c6d09 --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshots/reconciliation.rs @@ -0,0 +1,302 @@ +use super::operations::{ + InstanceScreenshot, has_png_extension, sort_screenshots, + source_screenshots_dir, +}; +use crate::State; +use crate::state::instances::adapters::sqlite::{ + instance_rows::{self, InstanceScreenshotSource}, + screenshot_rows::{self, ScreenshotRow}, +}; +use crate::util::fetch::sha1_file_async; +use crate::util::io::IOError; +use chrono::{DateTime, Utc}; +use futures::stream::{self, StreamExt, TryStreamExt}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +const SCREENSHOT_HASH_CONCURRENCY: usize = 8; + +pub(super) struct ScannedScreenshot { + file_name: String, + created_at: DateTime, + modified_at: i64, + file_size: i64, + path: PathBuf, +} + +struct ResolvedScreenshot { + scanned: ScannedScreenshot, + row: ScreenshotRow, + is_new: bool, + changed: bool, +} + +fn resolve_scanned_screenshot( + source: &InstanceScreenshotSource, + scanned: ScannedScreenshot, + content_hash: String, + matched: Option, +) -> ResolvedScreenshot { + let (mut row, is_new) = match matched { + Some(row) => (row, false), + None => ( + ScreenshotRow { + id: Uuid::new_v4().to_string(), + instance_id: source.id.clone(), + file_name: scanned.file_name.clone(), + content_hash: content_hash.clone(), + file_size: scanned.file_size, + modified_at: scanned.modified_at, + created_at: scanned.created_at.timestamp_millis(), + group_id: None, + }, + true, + ), + }; + let changed = !is_new + && (row.file_name != scanned.file_name + || row.content_hash != content_hash + || row.file_size != scanned.file_size + || row.modified_at != scanned.modified_at + || row.created_at != scanned.created_at.timestamp_millis()); + row.instance_id.clone_from(&source.id); + row.file_name.clone_from(&scanned.file_name); + row.content_hash = content_hash; + row.file_size = scanned.file_size; + row.modified_at = scanned.modified_at; + row.created_at = scanned.created_at.timestamp_millis(); + + ResolvedScreenshot { + scanned, + row, + is_new, + changed, + } +} + +pub(super) async fn list_source_screenshots( + state: &State, + source: InstanceScreenshotSource, +) -> crate::Result> { + let _lock = state.lock_instance_screenshots(&source.id).await; + let scanned = scan_source_screenshots(state, &source).await?; + reconcile_source_screenshots(state, &source, scanned).await +} + +pub(crate) async fn reconcile_screenshots( + instance_id: &str, +) -> crate::Result<()> { + let state = State::get().await?; + let Some(source) = + instance_rows::get_instance_screenshot_source(instance_id, &state.pool) + .await? + else { + return Ok(()); + }; + + list_source_screenshots(&state, source).await?; + Ok(()) +} + +pub(super) async fn scan_source_screenshots( + state: &State, + source: &InstanceScreenshotSource, +) -> crate::Result> { + let screenshots_dir = source_screenshots_dir(state, source).await?; + tokio::task::spawn_blocking(move || scan_screenshots_dir(&screenshots_dir)) + .await? +} + +fn scan_screenshots_dir( + screenshots_dir: &Path, +) -> crate::Result> { + let entries = match std::fs::read_dir(screenshots_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(error) => return Err(error.into()), + }; + let mut screenshots = Vec::new(); + + for entry in entries { + let entry = entry + .map_err(|error| IOError::with_path(error, screenshots_dir))?; + let file_type = entry + .file_type() + .map_err(|error| IOError::with_path(error, entry.path()))?; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + if !has_png_extension(&path) { + continue; + } + + let Some(file_name) = entry.file_name().to_str().map(str::to_owned) + else { + continue; + }; + let metadata = entry + .metadata() + .map_err(|error| IOError::with_path(error, &path))?; + let created_at = metadata + .created() + .or_else(|_| metadata.modified()) + .map(DateTime::::from) + .map_err(|error| IOError::with_path(error, &path))?; + let modified_at = metadata + .modified() + .map(DateTime::::from) + .map(|value| value.timestamp_millis()) + .map_err(|error| IOError::with_path(error, &path))?; + let file_size = i64::try_from(metadata.len()).map_err(|_| { + crate::ErrorKind::InputError( + "Screenshot is too large to index".to_string(), + ) + })?; + + screenshots.push(ScannedScreenshot { + file_name, + created_at, + modified_at, + file_size, + path, + }); + } + + Ok(screenshots) +} + +pub(super) async fn reconcile_source_screenshots( + state: &State, + source: &InstanceScreenshotSource, + scanned: Vec, +) -> crate::Result> { + let existing = + screenshot_rows::list_screenshots(&source.id, &state.pool).await?; + let mut unmatched_by_name = existing + .into_iter() + .map(|row| (row.file_name.clone(), row)) + .collect::>(); + let mut resolved = Vec::with_capacity(scanned.len()); + let mut needs_hash = Vec::new(); + + for scanned in scanned { + match unmatched_by_name.remove(&scanned.file_name) { + Some(row) + if row.file_size == scanned.file_size + && row.modified_at == scanned.modified_at + && row.created_at + == scanned.created_at.timestamp_millis() => + { + resolved.push(ResolvedScreenshot { + scanned, + row, + is_new: false, + changed: false, + }); + } + matched => needs_hash.push((scanned, matched)), + } + } + + let hashed = stream::iter(needs_hash.into_iter().map( + |(mut scanned, matched)| async move { + let (file_size, content_hash) = + sha1_file_async(&scanned.path).await?; + scanned.file_size = i64::try_from(file_size).map_err(|_| { + crate::ErrorKind::InputError( + "Screenshot is too large to index".to_string(), + ) + })?; + Ok::<_, crate::Error>((scanned, content_hash, matched)) + }, + )) + .buffer_unordered(SCREENSHOT_HASH_CONCURRENCY) + .try_collect::>() + .await?; + + let mut unmatched_by_hash = + HashMap::<(String, i64), Vec>::new(); + for row in unmatched_by_name.into_values() { + unmatched_by_hash + .entry((row.content_hash.clone(), row.file_size)) + .or_default() + .push(row); + } + + for (scanned, content_hash, matched_by_name) in hashed { + if let Some(row) = matched_by_name { + resolved.push(resolve_scanned_screenshot( + source, + scanned, + content_hash, + Some(row), + )); + continue; + } + + let hash_key = (content_hash.clone(), scanned.file_size); + let matched = unmatched_by_hash.get_mut(&hash_key).and_then(|rows| { + if rows.is_empty() { + return None; + } + let created_at = scanned.created_at.timestamp_millis(); + let index = rows + .iter() + .position(|row| { + row.modified_at == scanned.modified_at + && row.created_at == created_at + }) + .unwrap_or(rows.len() - 1); + Some(rows.swap_remove(index)) + }); + resolved.push(resolve_scanned_screenshot( + source, + scanned, + content_hash, + matched, + )); + } + + let mut tx = state.pool.begin().await?; + for row in unmatched_by_hash.into_values().flatten() { + screenshot_rows::delete_screenshot(&row.id, &mut tx).await?; + } + for screenshot in &resolved { + if !screenshot.is_new && screenshot.changed { + let mut row = screenshot.row.clone(); + row.file_name = Uuid::new_v4().to_string(); + screenshot_rows::update_screenshot(&row, &mut tx).await?; + } + } + for screenshot in &resolved { + if screenshot.is_new { + screenshot_rows::insert_screenshot(&screenshot.row, &mut tx) + .await?; + } else if screenshot.changed { + screenshot_rows::update_screenshot(&screenshot.row, &mut tx) + .await?; + } + } + tx.commit().await?; + + let mut screenshots = resolved + .into_iter() + .map(|screenshot| InstanceScreenshot { + id: screenshot.row.id, + instance_id: source.id.clone(), + instance_name: source.name.clone(), + file_name: screenshot.scanned.file_name, + created_at: screenshot.scanned.created_at, + modified_at: screenshot.row.modified_at, + group_id: screenshot.row.group_id, + path: screenshot.scanned.path, + }) + .collect::>(); + sort_screenshots(&mut screenshots); + Ok(screenshots) +} \ No newline at end of file diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index bcc6886..5532814 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -360,6 +360,7 @@ pub enum InstancePayloadType { revision: u64, }, ServersUpdated, + ScreenshotsUpdated, WorldUpdated { world: String, }, diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index e943e44..411a5c5 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -12,6 +12,34 @@ use chrono::{DateTime, TimeZone, Utc}; use serde::de::DeserializeOwned; use sqlx::{Executor, Sqlite, SqlitePool, Transaction}; use uuid::Uuid; +#[derive(Clone, Debug, sqlx::FromRow)] +pub(crate) struct InstanceScreenshotSource { + pub id: String, + pub name: String, + pub path: String, +} + +pub(crate) async fn get_instance_screenshot_source( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as::<_, InstanceScreenshotSource>( + "SELECT id, name, path FROM instances WHERE id = ?", + ) + .bind(instance_id) + .fetch_optional(pool) + .await?) +} + +pub(crate) async fn list_screenshot_sources( + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as::<_, InstanceScreenshotSource>( + "SELECT id, name, path FROM instances ORDER BY name, id", + ) + .fetch_all(pool) + .await?) +} #[derive(Debug, sqlx::FromRow)] pub(crate) struct InstanceRow { diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs b/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs index 5a9bf0a..49e16b0 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs @@ -2,3 +2,4 @@ pub(crate) mod config_sync_rows; pub(crate) mod content_rows; pub(crate) mod instance_rows; pub(crate) mod loader_component_rows; +pub(crate) mod screenshot_rows; \ No newline at end of file diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs new file mode 100644 index 0000000..2dd6a6b --- /dev/null +++ b/packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs @@ -0,0 +1,236 @@ +use sqlx::{Sqlite, SqlitePool, Transaction}; + +#[derive(Clone, Debug, sqlx::FromRow)] +pub(crate) struct ScreenshotRow { + pub id: String, + pub instance_id: String, + pub file_name: String, + pub content_hash: String, + pub file_size: i64, + pub modified_at: i64, + pub created_at: i64, + pub group_id: Option, +} + +#[derive(Clone, Debug, sqlx::FromRow)] +pub(crate) struct ScreenshotGroupRow { + pub id: String, + pub name: String, +} + +pub(crate) async fn list_screenshots( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as!( + ScreenshotRow, + r#" + SELECT + screenshots.id, + screenshots.instance_id, + screenshots.file_name, + screenshots.content_hash, + screenshots.file_size, + screenshots.modified_at, + screenshots.created_at, + memberships.group_id AS "group_id?" + FROM screenshots + LEFT JOIN screenshot_group_memberships memberships + ON memberships.screenshot_id = screenshots.id + WHERE screenshots.instance_id = ? + ORDER BY screenshots.file_name, screenshots.id + "#, + instance_id, + ) + .fetch_all(pool) + .await?) +} + +pub(crate) async fn get_screenshot_by_key( + instance_id: &str, + file_name: &str, + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as!( + ScreenshotRow, + r#" + SELECT + screenshots.id, + screenshots.instance_id, + screenshots.file_name, + screenshots.content_hash, + screenshots.file_size, + screenshots.modified_at, + screenshots.created_at, + memberships.group_id AS "group_id?" + FROM screenshots + LEFT JOIN screenshot_group_memberships memberships + ON memberships.screenshot_id = screenshots.id + WHERE screenshots.instance_id = ? AND screenshots.file_name = ? + "#, + instance_id, + file_name, + ) + .fetch_optional(pool) + .await?) +} + +pub(crate) async fn copy_group_membership( + source_id: &str, + edited_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + r#" + INSERT INTO screenshot_group_memberships (screenshot_id, group_id) + SELECT ?, group_id + FROM screenshot_group_memberships + WHERE screenshot_id = ? + ON CONFLICT (screenshot_id) DO UPDATE SET group_id = excluded.group_id + "#, + edited_id, + source_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn insert_screenshot( + row: &ScreenshotRow, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + " + INSERT INTO screenshots ( + id, + instance_id, + file_name, + content_hash, + file_size, + modified_at, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ", + row.id, + row.instance_id, + row.file_name, + row.content_hash, + row.file_size, + row.modified_at, + row.created_at, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn update_screenshot( + row: &ScreenshotRow, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + " + UPDATE screenshots + SET + instance_id = ?, + file_name = ?, + content_hash = ?, + file_size = ?, + modified_at = ?, + created_at = ? + WHERE id = ? + ", + row.instance_id, + row.file_name, + row.content_hash, + row.file_size, + row.modified_at, + row.created_at, + row.id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn delete_screenshot( + id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!("DELETE FROM screenshots WHERE id = ?", id) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn move_screenshot( + source_instance_id: &str, + source_file_name: &str, + target_instance_id: &str, + target_file_name: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + let mut tx = pool.begin().await?; + sqlx::query!( + "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?", + target_instance_id, + target_file_name, + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + " + UPDATE screenshots + SET instance_id = ?, file_name = ? + WHERE instance_id = ? AND file_name = ? + ", + target_instance_id, + target_file_name, + source_instance_id, + source_file_name, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn delete_screenshot_by_key( + instance_id: &str, + file_name: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + sqlx::query!( + "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?", + instance_id, + file_name, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub(crate) async fn list_groups( + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as!( + ScreenshotGroupRow, + " + SELECT id, name + FROM screenshot_groups + ORDER BY display_order, name, id + ", + ) + .fetch_all(pool) + .await?) +} \ No newline at end of file diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 626b26a..93666e8 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -120,6 +120,7 @@ pub struct State { pub(crate) install_job_cancellations: DashMap, pub(crate) install_job_operation_locks: DashMap>>, + pub(crate) screenshot_locks: DashMap>>, /// Process manager pub process_manager: ProcessManager, @@ -904,6 +905,7 @@ impl State { install_db_semaphore: Semaphore::new(1), install_job_cancellations: DashMap::new(), install_job_operation_locks: DashMap::new(), + screenshot_locks: DashMap::new(), process_manager, friends_socket, restart_after_pending_update: AtomicBool::new(false), @@ -931,6 +933,18 @@ impl State { ) -> InstanceLockGuard { self.instance_locks.lock_exclusive(instance_id).await } + + pub(crate) async fn lock_instance_screenshots( + &self, + instance_id: &str, + ) -> tokio::sync::OwnedMutexGuard<()> { + self.screenshot_locks + .entry(instance_id.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() + .lock_owned() + .await + } } #[cfg(test)] @@ -971,6 +985,7 @@ pub(crate) async fn test_state( install_db_semaphore: Semaphore::new(1), install_job_cancellations: DashMap::new(), install_job_operation_locks: DashMap::new(), + screenshot_locks: DashMap::new(), process_manager: ProcessManager::new(), friends_socket: FriendsSocket::new(), restart_after_pending_update: AtomicBool::new(false),