mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 21:30:14 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20a6a1c954 | ||
|
|
a415c457e9 | ||
|
|
28eb9baa02 | ||
|
|
4b43693e29 | ||
|
|
2c84587387 |
@@ -0,0 +1,37 @@
|
||||
package stirling.software.common.annotations.api;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
* Combined annotation for Invite management controllers.
|
||||
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invite")
|
||||
@Tag(
|
||||
name = "Invite",
|
||||
description =
|
||||
"""
|
||||
Invite-link generation and acceptance endpoints for onboarding new users.
|
||||
|
||||
Provides the ability to issue invitation tokens, send optional email invites,
|
||||
validate and accept invite links, and manage pending invitations for teams.
|
||||
|
||||
Typical use cases include:
|
||||
• Admin workflows for issuing time-limited invitations to external users
|
||||
• Self-service invite acceptance and team assignment
|
||||
• License limit enforcement when provisioning new accounts
|
||||
|
||||
Target users: administrators and automation scripts orchestrating user onboarding.
|
||||
""")
|
||||
public @interface InviteApi {}
|
||||
+41
-49
@@ -129,61 +129,53 @@ public class SecurityConfiguration {
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
// Read CORS allowed origins from settings
|
||||
if (applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
|
||||
List<String> configuredOrigins = null;
|
||||
if (applicationProperties.getSystem() != null) {
|
||||
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
||||
}
|
||||
|
||||
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
||||
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
|
||||
// Use setAllowedOriginPatterns for better wildcard and port support
|
||||
cfg.setAllowedOriginPatterns(allowedOrigins);
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
|
||||
cfg.setAllowedOriginPatterns(configuredOrigins);
|
||||
log.debug(
|
||||
"CORS configured with allowed origin patterns from settings.yml: {}",
|
||||
allowedOrigins);
|
||||
|
||||
// Set allowed methods explicitly (including OPTIONS for preflight)
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
|
||||
// Set allowed headers explicitly
|
||||
cfg.setAllowedHeaders(
|
||||
List.of(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN"));
|
||||
|
||||
// Set exposed headers (headers that the browser can access)
|
||||
cfg.setExposedHeaders(
|
||||
List.of(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type"));
|
||||
|
||||
// Allow credentials (cookies, authorization headers)
|
||||
cfg.setAllowCredentials(true);
|
||||
|
||||
// Set max age for preflight cache
|
||||
cfg.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
configuredOrigins);
|
||||
} else {
|
||||
// No CORS origins configured - return null to disable CORS processing entirely
|
||||
// This avoids empty CORS policy that unexpectedly rejects preflights
|
||||
// Default to allowing all origins when nothing is configured
|
||||
cfg.setAllowedOriginPatterns(List.of("*"));
|
||||
log.info(
|
||||
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
|
||||
return null;
|
||||
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
|
||||
}
|
||||
|
||||
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
|
||||
cfg.setAllowedHeaders(
|
||||
List.of(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN"));
|
||||
|
||||
cfg.setExposedHeaders(
|
||||
List.of(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type"));
|
||||
|
||||
cfg.setAllowCredentials(true);
|
||||
cfg.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+2
-4
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.common.annotations.api.InviteApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
@@ -26,11 +26,9 @@ import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@UserApi
|
||||
@InviteApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invite")
|
||||
public class InviteLinkController {
|
||||
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
|
||||
@@ -41,6 +41,30 @@
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Extract"
|
||||
},
|
||||
"defaultApp": {
|
||||
"title": "Set as Default PDF App",
|
||||
"message": "Would you like to set Stirling PDF as your default PDF editor?",
|
||||
"description": "You can change this later in your system settings.",
|
||||
"notNow": "Not Now",
|
||||
"setDefault": "Set Default",
|
||||
"dismiss": "Dismiss",
|
||||
"prompt": {
|
||||
"title": "Set as Default PDF Editor",
|
||||
"message": "Make Stirling PDF your default application for opening PDF files."
|
||||
},
|
||||
"success": {
|
||||
"title": "Default App Set",
|
||||
"message": "Stirling PDF is now your default PDF editor"
|
||||
},
|
||||
"settingsOpened": {
|
||||
"title": "Settings Opened",
|
||||
"message": "Please select Stirling PDF in your system settings"
|
||||
},
|
||||
"error": {
|
||||
"title": "Error",
|
||||
"message": "Failed to set default PDF handler"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
},
|
||||
@@ -329,10 +353,18 @@
|
||||
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
|
||||
"defaultToolPickerMode": "Default tool picker mode",
|
||||
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
|
||||
"rememberInputs": "Remember tool inputs",
|
||||
"rememberInputsDescription": "Store the last settings you used for each tool on this device",
|
||||
"mode": {
|
||||
"fullscreen": "Fullscreen",
|
||||
"sidebar": "Sidebar"
|
||||
}
|
||||
},
|
||||
"defaultPdfEditor": "Default PDF editor",
|
||||
"defaultPdfEditorActive": "Stirling PDF is your default PDF editor",
|
||||
"defaultPdfEditorInactive": "Another application is set as default",
|
||||
"defaultPdfEditorChecking": "Checking...",
|
||||
"defaultPdfEditorSet": "Already Default",
|
||||
"setAsDefault": "Set as Default"
|
||||
},
|
||||
"hotkeys": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
|
||||
@@ -323,6 +323,8 @@
|
||||
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
|
||||
"defaultToolPickerMode": "Default tool picker mode",
|
||||
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
|
||||
"rememberInputs": "Remember tool inputs",
|
||||
"rememberInputsDescription": "Store the last settings you used for each tool on this device",
|
||||
"mode": {
|
||||
"fullscreen": "Fullscreen",
|
||||
"sidebar": "Sidebar"
|
||||
|
||||
Generated
+11
@@ -643,6 +643,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-services"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0aa845ab21b847ee46954be761815f18f16469b29ef3ba250241b1b8bab659a"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -3941,6 +3950,8 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
name = "stirling-pdf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-services",
|
||||
"log",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
|
||||
@@ -31,3 +31,7 @@ tauri-plugin-fs = "2.4.4"
|
||||
tauri-plugin-single-instance = "2.0.1"
|
||||
tokio = { version = "1.0", features = ["time"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
core-services = "1.0"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
use crate::utils::add_log;
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use std::process::Command;
|
||||
|
||||
/// Check if Stirling PDF is the default PDF handler
|
||||
#[tauri::command]
|
||||
pub fn is_default_pdf_handler() -> Result<bool, String> {
|
||||
add_log("🔍 Checking if app is default PDF handler".to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
check_default_windows()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
check_default_macos()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
check_default_linux()
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to set/prompt for Stirling PDF as default PDF handler
|
||||
#[tauri::command]
|
||||
pub fn set_as_default_pdf_handler() -> Result<String, String> {
|
||||
add_log("⚙️ Attempting to set as default PDF handler".to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
set_default_windows()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
set_default_macos()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
set_default_linux()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Windows Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_default_windows() -> Result<bool, String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
// Query the default handler for .pdf extension
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", "assoc .pdf"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check default app: {}", e))?;
|
||||
|
||||
let assoc = String::from_utf8_lossy(&output.stdout);
|
||||
add_log(format!("Windows PDF association: {}", assoc.trim()));
|
||||
|
||||
// Get the ProgID for .pdf files
|
||||
if let Some(prog_id) = assoc.trim().strip_prefix(".pdf=") {
|
||||
// Query what application handles this ProgID
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", &format!("ftype {}", prog_id)])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to query file type: {}", e))?;
|
||||
|
||||
let ftype = String::from_utf8_lossy(&output.stdout);
|
||||
add_log(format!("Windows file type: {}", ftype.trim()));
|
||||
|
||||
// Check if it contains "Stirling" or our app name
|
||||
let is_default = ftype.to_lowercase().contains("stirling");
|
||||
Ok(is_default)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_default_windows() -> Result<String, String> {
|
||||
// On Windows 10+, we need to open the Default Apps settings
|
||||
// as programmatic setting requires a signed installer
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "ms-settings:defaultapps"])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open default apps settings: {}", e))?;
|
||||
|
||||
add_log("Opened Windows Default Apps settings".to_string());
|
||||
Ok("opened_settings".to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// macOS Implementation (using LaunchServices framework)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn check_default_macos() -> Result<bool, String> {
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
use std::os::raw::c_int;
|
||||
|
||||
// Define the LSCopyDefaultRoleHandlerForContentType function
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
extern "C" {
|
||||
fn LSCopyDefaultRoleHandlerForContentType(
|
||||
content_type: CFStringRef,
|
||||
role: c_int,
|
||||
) -> CFStringRef;
|
||||
}
|
||||
|
||||
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
|
||||
|
||||
unsafe {
|
||||
// Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier)
|
||||
let pdf_uti = CFString::new("com.adobe.pdf");
|
||||
let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL);
|
||||
|
||||
if handler_ref.is_null() {
|
||||
add_log("No default PDF handler found".to_string());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let handler = CFString::wrap_under_create_rule(handler_ref);
|
||||
let handler_str = handler.to_string();
|
||||
add_log(format!("macOS PDF handler: {}", handler_str));
|
||||
|
||||
// Check if it's our bundle identifier
|
||||
let is_default = handler_str == "stirling.pdf.dev";
|
||||
Ok(is_default)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_default_macos() -> Result<String, String> {
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
use std::os::raw::c_int;
|
||||
|
||||
// Define the LSSetDefaultRoleHandlerForContentType function
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
extern "C" {
|
||||
fn LSSetDefaultRoleHandlerForContentType(
|
||||
content_type: CFStringRef,
|
||||
role: c_int,
|
||||
handler_bundle_id: CFStringRef,
|
||||
) -> c_int; // OSStatus
|
||||
}
|
||||
|
||||
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
|
||||
|
||||
unsafe {
|
||||
// Set our app as the default handler for PDF files
|
||||
let pdf_uti = CFString::new("com.adobe.pdf");
|
||||
let our_bundle_id = CFString::new("stirling.pdf.dev");
|
||||
|
||||
let status = LSSetDefaultRoleHandlerForContentType(
|
||||
pdf_uti.as_concrete_TypeRef(),
|
||||
K_LS_ROLES_ALL,
|
||||
our_bundle_id.as_concrete_TypeRef(),
|
||||
);
|
||||
|
||||
if status == 0 {
|
||||
add_log("Successfully triggered default app dialog".to_string());
|
||||
Ok("set_successfully".to_string())
|
||||
} else {
|
||||
let error_msg = format!("LaunchServices returned status: {}", status);
|
||||
add_log(error_msg.clone());
|
||||
Err(error_msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Linux Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn check_default_linux() -> Result<bool, String> {
|
||||
// Use xdg-mime to check the default application for PDF files
|
||||
let output = Command::new("xdg-mime")
|
||||
.args(["query", "default", "application/pdf"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check default app: {}", e))?;
|
||||
|
||||
let handler = String::from_utf8_lossy(&output.stdout);
|
||||
add_log(format!("Linux PDF handler: {}", handler.trim()));
|
||||
|
||||
// Check if it's our .desktop file
|
||||
let is_default = handler.trim() == "stirling-pdf.desktop";
|
||||
Ok(is_default)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn set_default_linux() -> Result<String, String> {
|
||||
// Use xdg-mime to set the default application for PDF files
|
||||
let result = Command::new("xdg-mime")
|
||||
.args(["default", "stirling-pdf.desktop", "application/pdf"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to set default app: {}", e))?;
|
||||
|
||||
if result.status.success() {
|
||||
add_log("Set as default PDF handler on Linux".to_string());
|
||||
Ok("set_successfully".to_string())
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
add_log(format!("Failed to set default: {}", error));
|
||||
Err(format!("Failed to set as default: {}", error))
|
||||
}
|
||||
}
|
||||
@@ -14,23 +14,11 @@ pub fn add_opened_file(file_path: String) {
|
||||
// Command to get opened file paths (if app was launched with files)
|
||||
#[tauri::command]
|
||||
pub async fn get_opened_files() -> Result<Vec<String>, String> {
|
||||
let mut all_files: Vec<String> = Vec::new();
|
||||
|
||||
// Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour)
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let pdf_files: Vec<String> = args.iter()
|
||||
.skip(1)
|
||||
.filter(|arg| std::path::Path::new(arg).exists())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
all_files.extend(pdf_files);
|
||||
|
||||
// Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files)
|
||||
{
|
||||
let opened_files = OPENED_FILES.lock().unwrap();
|
||||
all_files.extend(opened_files.clone());
|
||||
}
|
||||
// Get all files from the OPENED_FILES store
|
||||
// Command line args are processed in setup() callback and added to this store
|
||||
// Additional files from second instances or events are also added here
|
||||
let opened_files = OPENED_FILES.lock().unwrap();
|
||||
let all_files = opened_files.clone();
|
||||
|
||||
add_log(format!("📂 Returning {} opened file(s)", all_files.len()));
|
||||
Ok(all_files)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod backend;
|
||||
pub mod health;
|
||||
pub mod files;
|
||||
pub mod default_app;
|
||||
|
||||
pub use backend::{start_backend, cleanup_backend};
|
||||
pub use health::check_backend_health;
|
||||
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
|
||||
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
|
||||
|
||||
@@ -3,7 +3,16 @@ use tauri::{RunEvent, WindowEvent, Emitter, Manager};
|
||||
mod utils;
|
||||
mod commands;
|
||||
|
||||
use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file};
|
||||
use commands::{
|
||||
start_backend,
|
||||
check_backend_health,
|
||||
get_opened_files,
|
||||
clear_opened_files,
|
||||
cleanup_backend,
|
||||
add_opened_file,
|
||||
is_default_pdf_handler,
|
||||
set_as_default_pdf_handler,
|
||||
};
|
||||
use utils::{add_log, get_tauri_logs};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -23,9 +32,6 @@ pub fn run() {
|
||||
// Store file for later retrieval (in case frontend isn't ready yet)
|
||||
add_opened_file(arg.clone());
|
||||
|
||||
// Also emit event for immediate handling if frontend is ready
|
||||
let _ = app.emit("file-opened", arg.clone());
|
||||
|
||||
// Bring the existing window to front
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
@@ -33,13 +39,34 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
||||
let _ = app.emit("files-changed", ());
|
||||
}))
|
||||
.setup(|_app| {
|
||||
add_log("🚀 Tauri app setup started".to_string());
|
||||
|
||||
// Process command line arguments on first launch
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
for arg in args.iter().skip(1) {
|
||||
if std::path::Path::new(arg).exists() {
|
||||
add_log(format!("📂 Initial file from command line: {}", arg));
|
||||
add_opened_file(arg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
add_log("🔍 DEBUG: Setup completed".to_string());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_backend,
|
||||
check_backend_health,
|
||||
get_opened_files,
|
||||
clear_opened_files,
|
||||
get_tauri_logs,
|
||||
is_default_pdf_handler,
|
||||
set_as_default_pdf_handler,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
@@ -58,6 +85,7 @@ pub fn run() {
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Opened { urls } => {
|
||||
add_log(format!("📂 Tauri file opened event: {:?}", urls));
|
||||
let mut added_files = false;
|
||||
for url in urls {
|
||||
let url_str = url.as_str();
|
||||
if url_str.starts_with("file://") {
|
||||
@@ -65,11 +93,14 @@ pub fn run() {
|
||||
if file_path.ends_with(".pdf") {
|
||||
add_log(format!("📂 Processing opened PDF: {}", file_path));
|
||||
add_opened_file(file_path.to_string());
|
||||
// Use unified event name for consistency across platforms
|
||||
let _ = app_handle.emit("file-opened", file_path.to_string());
|
||||
added_files = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
||||
if added_files {
|
||||
let _ = app_handle.emit("files-changed", ());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Only log unhandled events in debug mode to reduce noise
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
@@ -16,8 +17,10 @@ export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useBanner } from '@app/contexts/BannerContext';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* App layout wrapper that handles banner rendering and viewport sizing
|
||||
* Automatically adjusts child components to fit remaining space after banner
|
||||
*/
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const { banner } = useBanner();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.h-screen,
|
||||
.right-rail {
|
||||
height: 100% !important;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{banner}
|
||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
import { BannerProvider } from "@app/contexts/BannerContext";
|
||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
@@ -50,22 +51,23 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<OnboardingProvider>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<BannerProvider>
|
||||
<OnboardingProvider>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
@@ -76,16 +78,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
</BannerProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface InfoBannerProps {
|
||||
icon: string;
|
||||
message: string;
|
||||
buttonText: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick: () => void;
|
||||
onDismiss: () => void;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic info banner component for displaying dismissible messages at the top of the app
|
||||
*/
|
||||
export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
icon,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = 'check-circle-rounded',
|
||||
onButtonClick,
|
||||
onDismiss,
|
||||
loading = false,
|
||||
show = true,
|
||||
}) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="sm"
|
||||
radius={0}
|
||||
style={{
|
||||
background: 'var(--mantine-color-blue-0)',
|
||||
borderBottom: '1px solid var(--mantine-color-blue-2)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<LocalIcon icon={icon} width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
|
||||
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
|
||||
{message}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
style={{ position: 'absolute', top: '50%', right: '0.5rem', transform: 'translateY(-50%)' }}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -114,6 +114,23 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.rememberInputs', 'Remember tool inputs')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
'settings.general.rememberInputsDescription',
|
||||
'Store the last settings you used for each tool on this device'
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.rememberFormInputs}
|
||||
onChange={(event) => updatePreference('rememberFormInputs', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
|
||||
multiline
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface BannerContextType {
|
||||
banner: ReactNode;
|
||||
setBanner: (banner: ReactNode) => void;
|
||||
}
|
||||
|
||||
const BannerContext = createContext<BannerContextType | undefined>(undefined);
|
||||
|
||||
export function BannerProvider({ children }: { children: ReactNode }) {
|
||||
const [banner, setBanner] = useState<ReactNode>(null);
|
||||
|
||||
return (
|
||||
<BannerContext.Provider value={{ banner, setBanner }}>
|
||||
{children}
|
||||
</BannerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBanner() {
|
||||
const context = useContext(BannerContext);
|
||||
if (!context) {
|
||||
throw new Error('useBanner must be used within BannerProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -2,10 +2,13 @@ import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAddPasswordParameters, defaultParameters, AddPasswordParametersHook } from '@app/hooks/tools/addPassword/useAddPasswordParameters';
|
||||
import { defaultParameters as defaultChangePermissionsParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderAddPasswordHook = () => renderHook(() => useAddPasswordParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useAddPasswordParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useAddPasswordParameters());
|
||||
const { result } = renderAddPasswordHook();
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
@@ -15,7 +18,7 @@ describe('useAddPasswordParameters', () => {
|
||||
{ paramName: 'ownerPassword' as const, value: 'owner-password' },
|
||||
{ paramName: 'keyLength' as const, value: 256 }
|
||||
])('should update parameter $paramName', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useAddPasswordParameters());
|
||||
const { result } = renderAddPasswordHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
@@ -28,7 +31,7 @@ describe('useAddPasswordParameters', () => {
|
||||
{ paramName: 'preventAssembly' as const },
|
||||
{ paramName: 'preventPrinting' as const }
|
||||
])('should update boolean permission parameter $paramName', ({ paramName }) => {
|
||||
const { result } = renderHook(() => useAddPasswordParameters());
|
||||
const { result } = renderAddPasswordHook();
|
||||
|
||||
act(() => {
|
||||
result.current.permissions.updateParameter(paramName, true);
|
||||
@@ -38,7 +41,7 @@ describe('useAddPasswordParameters', () => {
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useAddPasswordParameters());
|
||||
const { result } = renderAddPasswordHook();
|
||||
|
||||
// First, change some parameters
|
||||
act(() => {
|
||||
@@ -60,7 +63,7 @@ describe('useAddPasswordParameters', () => {
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useAddPasswordParameters());
|
||||
const { result } = renderAddPasswordHook();
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('add-password');
|
||||
});
|
||||
@@ -112,7 +115,7 @@ describe('useAddPasswordParameters', () => {
|
||||
}
|
||||
}
|
||||
])('should validate parameters correctly $description', ({ setup }) => {
|
||||
const { result } = renderHook(() => useAddPasswordParameters());
|
||||
const { result } = renderAddPasswordHook();
|
||||
|
||||
// Default state should be valid
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
@@ -126,7 +129,7 @@ describe('useAddPasswordParameters', () => {
|
||||
});
|
||||
|
||||
test.each(Object.keys(defaultChangePermissionsParameters) as Array<keyof ChangePermissionsParameters>)('should handle boolean restriction parameter %s', (param) => {
|
||||
const { result } = renderHook(() => useAddPasswordParameters());
|
||||
const { result } = renderAddPasswordHook();
|
||||
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
@@ -137,7 +140,7 @@ describe('useAddPasswordParameters', () => {
|
||||
});
|
||||
|
||||
test('should handle mixed parameter types in updateParameter', () => {
|
||||
const { result } = renderHook(() => useAddPasswordParameters());
|
||||
const { result } = renderAddPasswordHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('password', 'test-string');
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAdjustPageScaleParameters, defaultParameters, PageSize, AdjustPageScaleParametersHook } from '@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderAdjustPageScaleHook = () => renderHook(() => useAdjustPageScaleParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useAdjustPageScaleParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
const { result } = renderAdjustPageScaleHook();
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
expect(result.current.parameters.scaleFactor).toBe(1.0);
|
||||
@@ -19,7 +22,7 @@ describe('useAdjustPageScaleParameters', () => {
|
||||
{ paramName: 'pageSize' as const, value: PageSize.LETTER },
|
||||
{ paramName: 'pageSize' as const, value: PageSize.LEGAL },
|
||||
])('should update parameter $paramName to $value', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
const { result } = renderAdjustPageScaleHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
@@ -29,7 +32,7 @@ describe('useAdjustPageScaleParameters', () => {
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
const { result } = renderAdjustPageScaleHook();
|
||||
|
||||
// First, change some parameters
|
||||
act(() => {
|
||||
@@ -49,7 +52,7 @@ describe('useAdjustPageScaleParameters', () => {
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
const { result } = renderAdjustPageScaleHook();
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('scale-pages');
|
||||
});
|
||||
@@ -96,7 +99,7 @@ describe('useAdjustPageScaleParameters', () => {
|
||||
expected: false
|
||||
}
|
||||
])('should validate parameters correctly $description', ({ setup, expected }) => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
const { result } = renderAdjustPageScaleHook();
|
||||
|
||||
act(() => {
|
||||
setup(result.current);
|
||||
@@ -106,7 +109,7 @@ describe('useAdjustPageScaleParameters', () => {
|
||||
});
|
||||
|
||||
test('should handle all PageSize enum values', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
const { result } = renderAdjustPageScaleHook();
|
||||
|
||||
Object.values(PageSize).forEach(pageSize => {
|
||||
act(() => {
|
||||
@@ -119,7 +122,7 @@ describe('useAdjustPageScaleParameters', () => {
|
||||
});
|
||||
|
||||
test('should handle scale factor edge cases', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
const { result } = renderAdjustPageScaleHook();
|
||||
|
||||
// Test very small valid scale factor
|
||||
act(() => {
|
||||
|
||||
@@ -2,10 +2,13 @@ import { renderHook, act } from '@testing-library/react';
|
||||
import { defaultParameters, useChangeMetadataParameters } from '@app/hooks/tools/changeMetadata/useChangeMetadataParameters';
|
||||
import { TrappedStatus } from '@app/types/metadata';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderChangeMetadataHook = () => renderHook(() => useChangeMetadataParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useChangeMetadataParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
@@ -23,7 +26,7 @@ describe('useChangeMetadataParameters', () => {
|
||||
{ paramName: 'trapped', value: TrappedStatus.TRUE },
|
||||
{ paramName: 'deleteAll', value: true },
|
||||
] as const)('should update $paramName parameter', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
@@ -48,7 +51,7 @@ describe('useChangeMetadataParameters', () => {
|
||||
{ description: 'no meaningful content', updates: {}, expected: false },
|
||||
{ description: 'whitespace only', updates: { title: ' ', author: ' ' }, expected: false },
|
||||
])('should validate correctly when $description', ({ updates, expected }) => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
act(() => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
@@ -64,7 +67,7 @@ describe('useChangeMetadataParameters', () => {
|
||||
{ description: 'valid modification date', updates: { title: 'Test', modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
|
||||
{ description: 'empty dates are valid', updates: { title: 'Test', creationDate: null, modificationDate: null }, expected: true },
|
||||
])('should validate dates correctly with $description', ({ updates, expected }) => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
act(() => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
@@ -78,7 +81,7 @@ describe('useChangeMetadataParameters', () => {
|
||||
|
||||
describe('custom metadata', () => {
|
||||
test('should add custom metadata with sequential IDs', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
act(() => {
|
||||
result.current.addCustomMetadata();
|
||||
@@ -93,7 +96,7 @@ describe('useChangeMetadataParameters', () => {
|
||||
});
|
||||
|
||||
test('should remove custom metadata by ID', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
act(() => {
|
||||
result.current.addCustomMetadata();
|
||||
@@ -109,7 +112,7 @@ describe('useChangeMetadataParameters', () => {
|
||||
});
|
||||
|
||||
test('should update custom metadata by ID', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
act(() => {
|
||||
result.current.addCustomMetadata();
|
||||
@@ -129,7 +132,7 @@ describe('useChangeMetadataParameters', () => {
|
||||
});
|
||||
|
||||
test('should validate with custom metadata', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
act(() => {
|
||||
result.current.addCustomMetadata();
|
||||
@@ -145,7 +148,7 @@ describe('useChangeMetadataParameters', () => {
|
||||
});
|
||||
|
||||
test('should generate unique IDs for multiple custom entries', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
act(() => {
|
||||
@@ -161,7 +164,7 @@ describe('useChangeMetadataParameters', () => {
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useChangeMetadataParameters());
|
||||
const { result } = renderChangeMetadataHook();
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('update-metadata');
|
||||
});
|
||||
|
||||
+9
-6
@@ -1,16 +1,19 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useChangePermissionsParameters, defaultParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderChangePermissionsHook = () => renderHook(() => useChangePermissionsParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useChangePermissionsParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useChangePermissionsParameters());
|
||||
const { result } = renderChangePermissionsHook();
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
test('should update individual boolean parameters', () => {
|
||||
const { result } = renderHook(() => useChangePermissionsParameters());
|
||||
const { result } = renderChangePermissionsHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('preventAssembly', true);
|
||||
@@ -28,7 +31,7 @@ describe('useChangePermissionsParameters', () => {
|
||||
});
|
||||
|
||||
test('should update all permission parameters', () => {
|
||||
const { result } = renderHook(() => useChangePermissionsParameters());
|
||||
const { result } = renderChangePermissionsHook();
|
||||
|
||||
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
|
||||
|
||||
@@ -56,7 +59,7 @@ describe('useChangePermissionsParameters', () => {
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useChangePermissionsParameters());
|
||||
const { result } = renderChangePermissionsHook();
|
||||
|
||||
// First, change some parameters
|
||||
act(() => {
|
||||
@@ -78,13 +81,13 @@ describe('useChangePermissionsParameters', () => {
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useChangePermissionsParameters());
|
||||
const { result } = renderChangePermissionsHook();
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('add-password');
|
||||
});
|
||||
|
||||
test('should always validate as true', () => {
|
||||
const { result } = renderHook(() => useChangePermissionsParameters());
|
||||
const { result } = renderChangePermissionsHook();
|
||||
|
||||
// Default state should be valid
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
|
||||
@@ -6,13 +6,16 @@ import { describe, test, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
|
||||
import { FIT_OPTIONS } from '@app/constants/convertConstants';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderConvertHook = () => renderHook(() => useConvertParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useConvertParameters', () => {
|
||||
|
||||
describe('Parameter Management', () => {
|
||||
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('');
|
||||
expect(result.current.parameters.toExtension).toBe('');
|
||||
@@ -28,7 +31,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should update individual parameters', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
@@ -39,7 +42,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should update nested image options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('imageOptions', {
|
||||
@@ -61,7 +64,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should update nested HTML options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('htmlOptions', {
|
||||
@@ -73,7 +76,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should update nested email options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('emailOptions', {
|
||||
@@ -91,7 +94,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should update nested PDF/A options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('pdfaOptions', {
|
||||
@@ -103,7 +106,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
@@ -124,7 +127,7 @@ describe('useConvertParameters', () => {
|
||||
describe('Parameter Validation', () => {
|
||||
|
||||
test('should validate parameters correctly', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
// No parameters - should be invalid
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
@@ -143,7 +146,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should validate unsupported conversions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
@@ -158,7 +161,7 @@ describe('useConvertParameters', () => {
|
||||
describe('Endpoint Generation', () => {
|
||||
|
||||
test('should generate correct endpoint names', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
@@ -170,7 +173,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should generate correct endpoint URLs', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
@@ -182,7 +185,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should return empty strings for invalid conversions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'invalid');
|
||||
@@ -197,7 +200,7 @@ describe('useConvertParameters', () => {
|
||||
describe('Available Extensions', () => {
|
||||
|
||||
test('should return available extensions for valid source format', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const availableExtensions = result.current.getAvailableToExtensions('pdf');
|
||||
|
||||
@@ -207,7 +210,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should return empty array for invalid source format', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const availableExtensions = result.current.getAvailableToExtensions('invalid');
|
||||
|
||||
@@ -219,7 +222,7 @@ describe('useConvertParameters', () => {
|
||||
});
|
||||
|
||||
test('should return empty array for empty source format', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const availableExtensions = result.current.getAvailableToExtensions('');
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
||||
defaultParameters,
|
||||
endpointName: getEndpointName,
|
||||
validateFn: validateParameters,
|
||||
storageKey: 'convert',
|
||||
}), []);
|
||||
|
||||
const baseHook = useBaseParameters(config);
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderConvertHook = () => renderHook(() => useConvertParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
describe('Single File Detection', () => {
|
||||
|
||||
test('should detect single file extension and set auto-target', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const pdfFile = [{ name: 'document.pdf' }];
|
||||
|
||||
@@ -27,7 +30,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should handle unknown file types with file-to-pdf fallback', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const unknownFile = [{ name: 'document.xyz' }, { name: 'image.jpggg' }];
|
||||
|
||||
@@ -41,7 +44,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should handle files without extensions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const noExtFile = [{ name: 'document' }];
|
||||
|
||||
@@ -59,7 +62,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
describe('Multiple Identical Files', () => {
|
||||
|
||||
test('should detect multiple PDF files and set auto-target', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const pdfFiles = [
|
||||
{ name: 'doc1.pdf' },
|
||||
@@ -78,7 +81,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should handle multiple unknown file types with fallback', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const unknownFiles = [
|
||||
{ name: 'file1.xyz' },
|
||||
@@ -98,7 +101,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
describe('Smart Detection - All Images', () => {
|
||||
|
||||
test('should detect all image files and enable smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const imageFiles = [
|
||||
{ name: 'photo1.jpg' },
|
||||
@@ -117,7 +120,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should handle mixed case image extensions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const imageFiles = [
|
||||
{ name: 'photo1.JPG' },
|
||||
@@ -136,7 +139,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
describe('Smart Detection - All Web Files', () => {
|
||||
|
||||
test('should detect all web files and enable web smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const webFiles = [
|
||||
{ name: 'page1.html' },
|
||||
@@ -154,7 +157,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should handle mixed case web extensions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const webFiles = [
|
||||
{ name: 'page1.HTML' },
|
||||
@@ -170,7 +173,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should detect multiple web files and enable web smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const zipFiles = [
|
||||
{ name: 'site1.zip' },
|
||||
@@ -191,7 +194,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
describe('Smart Detection - Mixed File Types', () => {
|
||||
|
||||
test('should detect mixed file types and enable smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'document.pdf' },
|
||||
@@ -210,7 +213,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should detect mixed images and documents as mixed type', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'photo.jpg' },
|
||||
@@ -227,7 +230,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should handle mixed with unknown file types', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'document.pdf' },
|
||||
@@ -247,7 +250,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
describe('Smart Detection Endpoint Resolution', () => {
|
||||
|
||||
test('should return correct endpoint for image smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const imageFiles = [
|
||||
{ name: 'photo1.jpg' },
|
||||
@@ -263,7 +266,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should return correct endpoint for web smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const webFiles = [
|
||||
{ name: 'page1.html' },
|
||||
@@ -279,7 +282,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should return correct endpoint for mixed smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'document.pdf' },
|
||||
@@ -298,7 +301,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
describe('Auto-Target Selection Logic', () => {
|
||||
|
||||
test('should select single available target automatically', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
// Markdown has only one conversion target (PDF)
|
||||
const mdFile = [{ name: 'readme.md' }];
|
||||
@@ -312,7 +315,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should not auto-select when multiple targets available', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
// PDF has multiple conversion targets, so no auto-selection
|
||||
const pdfFile = [{ name: 'document.pdf' }];
|
||||
@@ -330,7 +333,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
describe('Edge Cases', () => {
|
||||
|
||||
test('should handle empty file names', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const emptyFiles = [{ name: '' }];
|
||||
|
||||
@@ -343,7 +346,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
});
|
||||
|
||||
test('should handle malformed file objects', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
const { result } = renderConvertHook();
|
||||
|
||||
const malformedFiles: Array<{name: string}> = [
|
||||
{ name: 'valid.pdf' },
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useMergeParameters, defaultParameters } from '@app/hooks/tools/merge/useMergeParameters';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderMergeHook = () => renderHook(() => useMergeParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useMergeParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
const { result } = renderMergeHook();
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
@@ -15,7 +18,7 @@ describe('useMergeParameters', () => {
|
||||
{ paramName: 'generateTableOfContents' as const, value: true },
|
||||
{ paramName: 'generateTableOfContents' as const, value: false }
|
||||
])('should update parameter $paramName to $value', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
const { result } = renderMergeHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
@@ -25,7 +28,7 @@ describe('useMergeParameters', () => {
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
const { result } = renderMergeHook();
|
||||
|
||||
// First, change some parameters
|
||||
act(() => {
|
||||
@@ -45,7 +48,7 @@ describe('useMergeParameters', () => {
|
||||
});
|
||||
|
||||
test('should validate parameters correctly - always returns true', () => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
const { result } = renderMergeHook();
|
||||
|
||||
// Default state should be valid
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useRedactParameters, defaultParameters } from '@app/hooks/tools/redact/useRedactParameters';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderRedactHook = () => renderHook(() => useRedactParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useRedactParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
const { result } = renderRedactHook();
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
@@ -18,7 +21,7 @@ describe('useRedactParameters', () => {
|
||||
{ paramName: 'customPadding' as const, value: 0.5 },
|
||||
{ paramName: 'convertPDFToImage' as const, value: false }
|
||||
])('should update parameter $paramName', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
const { result } = renderRedactHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
@@ -28,7 +31,7 @@ describe('useRedactParameters', () => {
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
const { result } = renderRedactHook();
|
||||
|
||||
// Modify some parameters
|
||||
act(() => {
|
||||
@@ -52,7 +55,7 @@ describe('useRedactParameters', () => {
|
||||
{ description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
|
||||
{ description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
|
||||
])('should $description', ({ wordsToRedact, expected }) => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
const { result } = renderRedactHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('mode', 'automatic');
|
||||
@@ -63,7 +66,7 @@ describe('useRedactParameters', () => {
|
||||
});
|
||||
|
||||
test('should not validate in manual mode (not implemented)', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
const { result } = renderRedactHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('mode', 'manual');
|
||||
@@ -75,7 +78,7 @@ describe('useRedactParameters', () => {
|
||||
|
||||
describe('endpoint handling', () => {
|
||||
test('should return correct endpoint for automatic mode', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
const { result } = renderRedactHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('mode', 'automatic');
|
||||
@@ -85,7 +88,7 @@ describe('useRedactParameters', () => {
|
||||
});
|
||||
|
||||
test('should throw error for manual mode (not implemented)', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
const { result } = renderRedactHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('mode', 'manual');
|
||||
@@ -96,7 +99,7 @@ describe('useRedactParameters', () => {
|
||||
});
|
||||
|
||||
test('should maintain parameter state across updates', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
const { result } = renderRedactHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('redactColor', '#FF0000');
|
||||
@@ -117,7 +120,7 @@ describe('useRedactParameters', () => {
|
||||
});
|
||||
|
||||
test('should handle array parameter updates correctly', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
const { result } = renderRedactHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('wordsToRedact', ['initial']);
|
||||
|
||||
@@ -43,6 +43,7 @@ export const useRedactParameters = (): RedactParametersHook => {
|
||||
}
|
||||
// Manual mode validation would go here when implemented
|
||||
return false;
|
||||
}
|
||||
},
|
||||
storageKey: 'redact'
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useRemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderRemovePasswordHook = () => renderHook(() => useRemovePasswordParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useRemovePasswordParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useRemovePasswordParameters());
|
||||
const { result } = renderRemovePasswordHook();
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
test('should update password parameter', () => {
|
||||
const { result } = renderHook(() => useRemovePasswordParameters());
|
||||
const { result } = renderRemovePasswordHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('password', 'test-password');
|
||||
@@ -20,7 +23,7 @@ describe('useRemovePasswordParameters', () => {
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useRemovePasswordParameters());
|
||||
const { result } = renderRemovePasswordHook();
|
||||
|
||||
// First, change the password
|
||||
act(() => {
|
||||
@@ -38,7 +41,7 @@ describe('useRemovePasswordParameters', () => {
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useRemovePasswordParameters());
|
||||
const { result } = renderRemovePasswordHook();
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('remove-password');
|
||||
});
|
||||
@@ -70,7 +73,7 @@ describe('useRemovePasswordParameters', () => {
|
||||
expectedValid: true
|
||||
}
|
||||
])('should validate parameters correctly $description', ({ password, expectedValid }) => {
|
||||
const { result } = renderHook(() => useRemovePasswordParameters());
|
||||
const { result } = renderRemovePasswordHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('password', password);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useRotateParameters, defaultParameters, normalizeAngle } from '@app/hooks/tools/rotate/useRotateParameters';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderRotateHook = () => renderHook(() => useRotateParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useRotateParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
const { result } = renderRotateHook();
|
||||
|
||||
expect(result.current.parameters).toEqual(defaultParameters);
|
||||
expect(result.current.parameters.angle).toBe(0);
|
||||
@@ -12,7 +15,7 @@ describe('useRotateParameters', () => {
|
||||
});
|
||||
|
||||
test('should validate parameters correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
const { result } = renderRotateHook();
|
||||
|
||||
// Default should be valid
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
@@ -31,7 +34,7 @@ describe('useRotateParameters', () => {
|
||||
});
|
||||
|
||||
test('should rotate clockwise correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
const { result } = renderRotateHook();
|
||||
|
||||
act(() => {
|
||||
result.current.rotateClockwise();
|
||||
@@ -58,7 +61,7 @@ describe('useRotateParameters', () => {
|
||||
});
|
||||
|
||||
test('should rotate anticlockwise correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
const { result } = renderRotateHook();
|
||||
|
||||
act(() => {
|
||||
result.current.rotateAnticlockwise();
|
||||
@@ -85,7 +88,7 @@ describe('useRotateParameters', () => {
|
||||
});
|
||||
|
||||
test('should normalize angles correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
const { result } = renderRotateHook();
|
||||
|
||||
expect(result.current.normalizeAngle(360)).toBe(0);
|
||||
expect(result.current.normalizeAngle(450)).toBe(90);
|
||||
@@ -94,7 +97,7 @@ describe('useRotateParameters', () => {
|
||||
});
|
||||
|
||||
test('should reset parameters correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
const { result } = renderRotateHook();
|
||||
|
||||
// Set some rotation
|
||||
act(() => {
|
||||
@@ -116,7 +119,7 @@ describe('useRotateParameters', () => {
|
||||
});
|
||||
|
||||
test('should update parameters', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
const { result } = renderRotateHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('angle', 450);
|
||||
@@ -132,13 +135,13 @@ describe('useRotateParameters', () => {
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
const { result } = renderRotateHook();
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('rotate-pdf');
|
||||
});
|
||||
|
||||
test('should detect rotation state correctly', () => {
|
||||
const { result } = renderHook(() => useRotateParameters());
|
||||
const { result } = renderRotateHook();
|
||||
|
||||
// Initially no rotation
|
||||
expect(result.current.hasRotation).toBe(false);
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { defaultParameters, useSanitizeParameters } from '@app/hooks/tools/sanitize/useSanitizeParameters';
|
||||
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
|
||||
|
||||
const renderSanitizeHook = () => renderHook(() => useSanitizeParameters(), { wrapper: PreferencesTestWrapper });
|
||||
|
||||
describe('useSanitizeParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useSanitizeParameters());
|
||||
const { result } = renderSanitizeHook();
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
test('should update individual parameters', () => {
|
||||
const { result } = renderHook(() => useSanitizeParameters());
|
||||
const { result } = renderSanitizeHook();
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('removeXMPMetadata', true);
|
||||
@@ -23,7 +26,7 @@ describe('useSanitizeParameters', () => {
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useSanitizeParameters());
|
||||
const { result } = renderSanitizeHook();
|
||||
|
||||
// First, change some parameters
|
||||
act(() => {
|
||||
@@ -43,13 +46,13 @@ describe('useSanitizeParameters', () => {
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useSanitizeParameters());
|
||||
const { result } = renderSanitizeHook();
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('sanitize-pdf');
|
||||
});
|
||||
|
||||
test('should validate parameters correctly', () => {
|
||||
const { result } = renderHook(() => useSanitizeParameters());
|
||||
const { result } = renderSanitizeHook();
|
||||
|
||||
// Default state should be valid (has removeJavaScript and removeEmbeddedFiles enabled)
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
@@ -71,7 +74,7 @@ describe('useSanitizeParameters', () => {
|
||||
});
|
||||
|
||||
test('should handle all parameter types correctly', () => {
|
||||
const { result } = renderHook(() => useSanitizeParameters());
|
||||
const { result } = renderSanitizeHook();
|
||||
|
||||
const allParameters = Object.keys(defaultParameters) as (keyof typeof defaultParameters)[];
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useCallback, Dispatch, SetStateAction } from 'react';
|
||||
import { useState, useCallback, Dispatch, SetStateAction, useRef, useEffect } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { loadToolParameters, saveToolParameters } from '@app/services/toolParameterStorage';
|
||||
|
||||
export interface BaseParametersHook<T> {
|
||||
parameters: T;
|
||||
@@ -13,10 +15,31 @@ export interface BaseParametersConfig<T> {
|
||||
defaultParameters: T;
|
||||
endpointName: string | ((params: T) => string);
|
||||
validateFn?: (params: T) => boolean;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParametersHook<T> {
|
||||
const [parameters, setParameters] = useState<T>(config.defaultParameters);
|
||||
const { preferences } = usePreferences();
|
||||
const storageKey = config.storageKey ?? (typeof config.endpointName === 'string' ? config.endpointName : undefined);
|
||||
|
||||
const shouldPersist = Boolean(storageKey && preferences.rememberFormInputs);
|
||||
const hasHydratedFromStorage = useRef(false);
|
||||
|
||||
const mergeWithDefaults = useCallback((values?: Partial<T>): T => ({
|
||||
...(config.defaultParameters as T),
|
||||
...(values ?? {}),
|
||||
}), [config.defaultParameters]);
|
||||
|
||||
const [parameters, setParameters] = useState<T>(() => {
|
||||
if (shouldPersist && storageKey) {
|
||||
const stored = loadToolParameters<T>(storageKey);
|
||||
if (stored) {
|
||||
hasHydratedFromStorage.current = true;
|
||||
return mergeWithDefaults(stored);
|
||||
}
|
||||
}
|
||||
return mergeWithDefaults();
|
||||
});
|
||||
|
||||
const updateParameter = useCallback(<K extends keyof T>(parameter: K, value: T[K]) => {
|
||||
setParameters(prev => ({
|
||||
@@ -33,6 +56,34 @@ export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParam
|
||||
return config.validateFn ? config.validateFn(parameters) : true;
|
||||
}, [parameters, config.validateFn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preferences.rememberFormInputs) {
|
||||
hasHydratedFromStorage.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedFromStorage.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = loadToolParameters<T>(storageKey);
|
||||
if (stored) {
|
||||
setParameters(mergeWithDefaults(stored));
|
||||
}
|
||||
hasHydratedFromStorage.current = true;
|
||||
}, [mergeWithDefaults, preferences.rememberFormInputs, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageKey || !preferences.rememberFormInputs) {
|
||||
return;
|
||||
}
|
||||
saveToolParameters(storageKey, parameters);
|
||||
}, [parameters, preferences.rememberFormInputs, storageKey]);
|
||||
|
||||
const endpointName = config.endpointName;
|
||||
let getEndpointName: () => string;
|
||||
if (typeof endpointName === "string") {
|
||||
|
||||
@@ -57,5 +57,6 @@ export const useSplitParameters = (): SplitParametersHook => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
storageKey: 'split',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig';
|
||||
const apiClient = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
responseType: 'json',
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Setup interceptors (core does nothing, proprietary adds JWT auth)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface UserPreferences {
|
||||
toolPanelModePromptSeen: boolean;
|
||||
showLegacyToolDescriptions: boolean;
|
||||
hasCompletedOnboarding: boolean;
|
||||
rememberFormInputs: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -19,6 +20,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
toolPanelModePromptSeen: false,
|
||||
showLegacyToolDescriptions: false,
|
||||
hasCompletedOnboarding: false,
|
||||
rememberFormInputs: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf_preferences';
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
const STORAGE_KEY = 'stirlingpdf_tool_parameters';
|
||||
|
||||
type SerializableValue = string | number | boolean | null | SerializableValue[] | { [key: string]: SerializableValue };
|
||||
|
||||
const isFile = (value: unknown): value is File => typeof File !== 'undefined' && value instanceof File;
|
||||
const isBlob = (value: unknown): value is Blob => typeof Blob !== 'undefined' && value instanceof Blob;
|
||||
|
||||
function sanitizeValue(value: unknown): SerializableValue | undefined {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueType = typeof value;
|
||||
|
||||
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
|
||||
if (valueType === 'number' && Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as SerializableValue;
|
||||
}
|
||||
|
||||
if (valueType === 'undefined' || valueType === 'function' || valueType === 'symbol' || valueType === 'bigint') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const sanitizedArray = value
|
||||
.map((item) => sanitizeValue(item))
|
||||
.filter((item) => item !== undefined) as SerializableValue[];
|
||||
return sanitizedArray;
|
||||
}
|
||||
|
||||
if (isFile(value) || isBlob(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (valueType === 'object' && value !== null) {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
const sanitizedEntries: Record<string, SerializableValue> = {};
|
||||
|
||||
for (const [key, entryValue] of entries) {
|
||||
const sanitized = sanitizeValue(entryValue);
|
||||
if (sanitized !== undefined) {
|
||||
sanitizedEntries[key] = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizedEntries;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readStorage(): Record<string, SerializableValue> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return parsed as Record<string, SerializableValue>;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[toolParameterStorage] Failed to read stored parameters', error);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function writeStorage(map: Record<string, SerializableValue>): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||
} catch (error) {
|
||||
console.error('[toolParameterStorage] Failed to write stored parameters', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadToolParameters<T>(toolKey: string): Partial<T> | null {
|
||||
const storage = readStorage();
|
||||
const stored = storage[toolKey];
|
||||
|
||||
if (!stored || typeof stored !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored as Partial<T>;
|
||||
}
|
||||
|
||||
export function saveToolParameters<T>(toolKey: string, parameters: T): void {
|
||||
const sanitized = sanitizeValue(parameters);
|
||||
if (sanitized === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = readStorage();
|
||||
storage[toolKey] = sanitized;
|
||||
writeStorage(storage);
|
||||
}
|
||||
|
||||
export function clearToolParameters(toolKey: string): void {
|
||||
const storage = readStorage();
|
||||
if (Object.prototype.hasOwnProperty.call(storage, toolKey)) {
|
||||
delete storage[toolKey];
|
||||
writeStorage(storage);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllToolParameters(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error('[toolParameterStorage] Failed to clear stored parameters', error);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
||||
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
||||
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
||||
|
||||
/**
|
||||
@@ -22,6 +23,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
{children}
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useBanner } from '@app/contexts/BannerContext';
|
||||
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
|
||||
|
||||
export function DesktopBannerInitializer() {
|
||||
const { setBanner } = useBanner();
|
||||
|
||||
useEffect(() => {
|
||||
setBanner(<DefaultAppBanner />);
|
||||
}, [setBanner]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
||||
import { useDefaultApp } from '@app/hooks/useDefaultApp';
|
||||
|
||||
export const DefaultAppBanner: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
const handleDismissPrompt = () => {
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
icon="picture-as-pdf-rounded"
|
||||
message={t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')}
|
||||
buttonText={t('defaultApp.setDefault', 'Set Default')}
|
||||
buttonIcon="check-circle-rounded"
|
||||
onButtonClick={handleSetDefault}
|
||||
onDismiss={handleDismissPrompt}
|
||||
loading={isLoading}
|
||||
show={!dismissed && isDefault === false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Paper, Text, Button, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDefaultApp } from '@app/hooks/useDefaultApp';
|
||||
|
||||
export const DefaultAppSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.defaultPdfEditor', 'Default PDF editor')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{isDefault === true
|
||||
? t('settings.general.defaultPdfEditorActive', 'Stirling PDF is your default PDF editor')
|
||||
: isDefault === false
|
||||
? t('settings.general.defaultPdfEditorInactive', 'Another application is set as default')
|
||||
: t('settings.general.defaultPdfEditorChecking', 'Checking...')}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant={isDefault ? 'light' : 'filled'}
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={handleSetDefault}
|
||||
loading={isLoading}
|
||||
disabled={isDefault === true}
|
||||
>
|
||||
{isDefault
|
||||
? t('settings.general.defaultPdfEditorSet', 'Already Default')
|
||||
: t('settings.general.setAsDefault', 'Set as Default')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
|
||||
import { DefaultAppSettings } from '@app/components/shared/config/configSections/DefaultAppSettings';
|
||||
|
||||
/**
|
||||
* Desktop extension of GeneralSection that adds default PDF editor settings
|
||||
*/
|
||||
const GeneralSection: React.FC = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<DefaultAppSettings />
|
||||
<CoreGeneralSection />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSection;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
||||
import { fileOpenService } from '@app/services/fileOpenService';
|
||||
@@ -20,75 +20,42 @@ export function useAppInitialization(): void {
|
||||
// Handle files opened with app (Tauri mode)
|
||||
const { openedFilePaths, loading: openedFileLoading } = useOpenedFile();
|
||||
|
||||
// Track if we've already loaded the initial files to prevent duplicate loads
|
||||
const initialFilesLoadedRef = useRef(false);
|
||||
|
||||
// Load opened files and add directly to FileContext
|
||||
useEffect(() => {
|
||||
if (openedFilePaths.length > 0 && !openedFileLoading && !initialFilesLoadedRef.current) {
|
||||
initialFilesLoadedRef.current = true;
|
||||
|
||||
const loadOpenedFiles = async () => {
|
||||
try {
|
||||
const filesArray: File[] = [];
|
||||
|
||||
// Load all files in parallel
|
||||
await Promise.all(
|
||||
openedFilePaths.map(async (filePath) => {
|
||||
try {
|
||||
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
|
||||
if (fileData) {
|
||||
const file = new File([fileData.arrayBuffer], fileData.fileName, {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
filesArray.push(file);
|
||||
console.log('[Desktop] Loaded file:', fileData.fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load file:', filePath, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (filesArray.length > 0) {
|
||||
// Add all files to FileContext at once
|
||||
await addFiles(filesArray);
|
||||
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load opened files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadOpenedFiles();
|
||||
if (openedFilePaths.length === 0 || openedFileLoading) {
|
||||
return;
|
||||
}
|
||||
}, [openedFilePaths, openedFileLoading, addFiles]);
|
||||
|
||||
// Listen for runtime file-opened events (from second instances on Windows/Linux)
|
||||
useEffect(() => {
|
||||
const handleRuntimeFileOpen = async (filePath: string) => {
|
||||
const loadOpenedFiles = async () => {
|
||||
try {
|
||||
console.log('[Desktop] Runtime file-opened event received:', filePath);
|
||||
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
|
||||
if (fileData) {
|
||||
// Create a File object from the ArrayBuffer
|
||||
const file = new File([fileData.arrayBuffer], fileData.fileName, {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const filesArray: File[] = [];
|
||||
|
||||
// Add directly to FileContext
|
||||
await addFiles([file]);
|
||||
console.log('[Desktop] Runtime opened file added to FileContext:', fileData.fileName);
|
||||
await Promise.all(
|
||||
openedFilePaths.map(async (filePath) => {
|
||||
try {
|
||||
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
|
||||
if (fileData) {
|
||||
const file = new File([fileData.arrayBuffer], fileData.fileName, {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
filesArray.push(file);
|
||||
console.log('[Desktop] Loaded file:', fileData.fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load file:', filePath, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (filesArray.length > 0) {
|
||||
await addFiles(filesArray);
|
||||
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load runtime opened file:', error);
|
||||
console.error('[Desktop] Failed to load opened files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up event listener and get cleanup function
|
||||
const unlisten = fileOpenService.onFileOpened(handleRuntimeFileOpen);
|
||||
|
||||
// Clean up listener on unmount
|
||||
return unlisten;
|
||||
}, [addFiles]);
|
||||
loadOpenedFiles();
|
||||
}, [openedFilePaths, openedFileLoading, addFiles]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { defaultAppService } from '@app/services/defaultAppService';
|
||||
import { alert } from '@app/components/toast';
|
||||
|
||||
export const useDefaultApp = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isDefault, setIsDefault] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkDefaultStatus();
|
||||
}, []);
|
||||
|
||||
const checkDefaultStatus = async () => {
|
||||
try {
|
||||
const status = await defaultAppService.isDefaultPdfHandler();
|
||||
setIsDefault(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to check default status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await defaultAppService.setAsDefaultPdfHandler();
|
||||
|
||||
if (result === 'set_successfully') {
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('defaultApp.success.title', 'Default App Set'),
|
||||
body: t('defaultApp.success.message', 'Stirling PDF is now your default PDF editor'),
|
||||
});
|
||||
setIsDefault(true);
|
||||
} else if (result === 'opened_settings') {
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: t('defaultApp.settingsOpened.title', 'Settings Opened'),
|
||||
body: t('defaultApp.settingsOpened.message', 'Please select Stirling PDF in your system settings'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set default:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('defaultApp.error.title', 'Error'),
|
||||
body: t('defaultApp.error.message', 'Failed to set default PDF handler'),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDefault,
|
||||
isLoading,
|
||||
checkDefaultStatus,
|
||||
handleSetDefault,
|
||||
};
|
||||
};
|
||||
@@ -1,45 +1,46 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fileOpenService } from '@app/services/fileOpenService';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
export function useOpenedFile() {
|
||||
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkForOpenedFile = async () => {
|
||||
console.log('🔍 Checking for opened file(s)...');
|
||||
// Function to read and process files from storage
|
||||
const readFilesFromStorage = async () => {
|
||||
console.log('🔍 Reading files from storage...');
|
||||
try {
|
||||
const filePaths = await fileOpenService.getOpenedFiles();
|
||||
console.log('🔍 fileOpenService.getOpenedFiles() returned:', filePaths);
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
console.log(`✅ App opened with ${filePaths.length} file(s):`, filePaths);
|
||||
console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths);
|
||||
setOpenedFilePaths(filePaths);
|
||||
|
||||
// Clear the files from service state after consuming them
|
||||
await fileOpenService.clearOpenedFiles();
|
||||
} else {
|
||||
console.log('ℹ️ No files were opened with the app');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to check for opened files:', error);
|
||||
console.error('❌ Failed to read files from storage:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkForOpenedFile();
|
||||
// Read files on mount
|
||||
readFilesFromStorage();
|
||||
|
||||
// Listen for runtime file open events (abstracted through service)
|
||||
const unlistenRuntimeEvents = fileOpenService.onFileOpened((filePath: string) => {
|
||||
console.log('📂 Runtime file open event:', filePath);
|
||||
setOpenedFilePaths(prev => [...prev, filePath]);
|
||||
// Listen for files-changed events (when new files are added to storage)
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('files-changed', async () => {
|
||||
console.log('📂 files-changed event received, re-reading storage...');
|
||||
await readFilesFromStorage();
|
||||
}).then(unlistenFn => {
|
||||
unlisten = unlistenFn;
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
unlistenRuntimeEvents();
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* Service for managing default PDF handler settings
|
||||
* Note: Uses localStorage for machine-specific preferences (not synced to server)
|
||||
*/
|
||||
export const defaultAppService = {
|
||||
/**
|
||||
* Check if Stirling PDF is the default PDF handler
|
||||
*/
|
||||
async isDefaultPdfHandler(): Promise<boolean> {
|
||||
try {
|
||||
const result = await invoke<boolean>('is_default_pdf_handler');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[DefaultApp] Failed to check default handler:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set or prompt to set Stirling PDF as default PDF handler
|
||||
* Returns a status string indicating what happened
|
||||
*/
|
||||
async setAsDefaultPdfHandler(): Promise<'set_successfully' | 'opened_settings' | 'error'> {
|
||||
try {
|
||||
const result = await invoke<string>('set_as_default_pdf_handler');
|
||||
return result as 'set_successfully' | 'opened_settings';
|
||||
} catch (error) {
|
||||
console.error('[DefaultApp] Failed to set default handler:', error);
|
||||
return 'error';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user has dismissed the default app prompt (machine-specific)
|
||||
*/
|
||||
hasUserDismissedPrompt(): boolean {
|
||||
try {
|
||||
const dismissed = localStorage.getItem('stirlingpdf_default_app_prompt_dismissed');
|
||||
return dismissed === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark that user has dismissed the default app prompt (machine-specific)
|
||||
*/
|
||||
setPromptDismissed(dismissed: boolean): void {
|
||||
try {
|
||||
localStorage.setItem('stirlingpdf_default_app_prompt_dismissed', dismissed ? 'true' : 'false');
|
||||
} catch (error) {
|
||||
console.error('[DefaultApp] Failed to save prompt preference:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if we should show the default app prompt
|
||||
* Returns true if: user hasn't dismissed it AND app is not default handler
|
||||
*/
|
||||
async shouldShowPrompt(): Promise<boolean> {
|
||||
if (this.hasUserDismissedPrompt()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isDefault = await this.isDefaultPdfHandler();
|
||||
return !isDefault;
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import Landing from "@app/routes/Landing";
|
||||
import Login from "@app/routes/Login";
|
||||
@@ -22,17 +23,19 @@ export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders>
|
||||
<Routes>
|
||||
{/* Auth routes - no nested providers needed */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
{/* Auth routes - no nested providers needed */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
|
||||
{/* Main app routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<OnboardingTour />
|
||||
{/* Main app routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<OnboardingTour />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ class SpringAuthClient {
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'XSRF-TOKEN') {
|
||||
return value;
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -278,7 +278,7 @@ class SpringAuthClient {
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/auth/logout', null, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
||||
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
@@ -311,7 +311,7 @@ class SpringAuthClient {
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/auth/refresh', null, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
||||
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
@@ -9,17 +9,38 @@ function getJwtTokenFromStorage(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getXsrfToken(): string | null {
|
||||
try {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'XSRF-TOKEN') {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[API Client] Failed to read XSRF token from cookies:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Install request interceptor to add JWT token
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const jwtToken = getJwtTokenFromStorage();
|
||||
const xsrfToken = getXsrfToken();
|
||||
|
||||
if (jwtToken && !config.headers.Authorization) {
|
||||
config.headers.Authorization = `Bearer ${jwtToken}`;
|
||||
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
|
||||
}
|
||||
|
||||
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {
|
||||
config.headers['X-XSRF-TOKEN'] = xsrfToken;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
|
||||
|
||||
export const PreferencesTestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PreferencesProvider>{children}</PreferencesProvider>
|
||||
);
|
||||
@@ -4,7 +4,8 @@
|
||||
"paths": {
|
||||
"@app/*": [
|
||||
"src/core/*"
|
||||
]
|
||||
],
|
||||
"@testing/*": ["src/testing/*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
],
|
||||
"@desktop/*": ["src/desktop/*"],
|
||||
"@proprietary/*": ["src/proprietary/*"],
|
||||
"@core/*": ["src/core/*"]
|
||||
"@core/*": ["src/core/*"],
|
||||
"@testing/*": ["src/testing/*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"src/proprietary/*",
|
||||
"src/core/*"
|
||||
],
|
||||
"@testing/*": ["src/testing/*"],
|
||||
"@desktop/*": ["src/desktop/*"],
|
||||
"@proprietary/*": ["src/proprietary/*"],
|
||||
"@core/*": ["src/core/*"]
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"src/proprietary/*",
|
||||
"src/core/*"
|
||||
],
|
||||
"@core/*": ["src/core/*"]
|
||||
"@core/*": ["src/core/*"],
|
||||
"@testing/*": ["src/testing/*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
|
||||
Reference in New Issue
Block a user