mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 09:39:31 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb584ff926 | ||
|
|
b7c8151d28 |
Generated
+8
@@ -3876,6 +3876,13 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error_utils"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errors"
|
||||
version = "0.0.1"
|
||||
@@ -11234,6 +11241,7 @@ dependencies = [
|
||||
"elasticsearch",
|
||||
"elasticsearch_schema",
|
||||
"enums",
|
||||
"error_utils",
|
||||
"errors",
|
||||
"fal_client",
|
||||
"ffmpeg_utils",
|
||||
|
||||
@@ -59,6 +59,7 @@ members = [
|
||||
"crates/lib/data/primitives",
|
||||
"crates/lib/data/web_base64",
|
||||
"crates/lib/easyenv", # NB: Vendored version of https://github.com/echelon/easyenv.rs that we'll improve.
|
||||
"crates/lib/error_utils",
|
||||
"crates/lib/errors",
|
||||
"crates/lib/ffmpeg_utils",
|
||||
"crates/lib/files/file_to_base64_url",
|
||||
@@ -236,6 +237,7 @@ cookie_store = { path = "crates/lib/cookie_store" }
|
||||
crockford = { path = "crates/lib/data/crockford" }
|
||||
easyenv = { path = "crates/lib/easyenv" }
|
||||
enums = { path = "crates/schema/public/enums" }
|
||||
error_utils = { path = "crates/lib/error_utils" }
|
||||
errors = { path = "crates/lib/errors" }
|
||||
fal_client = { path = "crates/api_clients/fal_client" }
|
||||
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "error_utils"
|
||||
edition = "2021"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "error_utils"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
regex.workspace = true
|
||||
@@ -0,0 +1 @@
|
||||
pub mod try_error_name;
|
||||
@@ -0,0 +1,157 @@
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Regex that matches CamelCase type names with an optional `::Variant` suffix.
|
||||
/// E.g. `CommonWebError::InvalidRequest`, `EnvError`, `KinoviError::RequestError`.
|
||||
static ERROR_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*(?:::[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*)?")
|
||||
.expect("error name regex should compile")
|
||||
});
|
||||
|
||||
/// Try to extract a human-readable error title from the causal error's Debug
|
||||
/// representation. Looks for CamelCase type names like `EnvError::NotUnicode`.
|
||||
pub fn try_error_name(error: &dyn std::error::Error) -> Option<String> {
|
||||
let debug_str = format!("{:?}", error);
|
||||
try_extract_error_name(&debug_str)
|
||||
}
|
||||
|
||||
/// Extract the leading CamelCase error name (with optional `::Variant`) from
|
||||
/// a Debug-formatted error string. Returns `None` if no match is found.
|
||||
///
|
||||
/// ```text
|
||||
/// "CommonWebError::InvalidRequest(\"bad\")" → Some("CommonWebError::InvalidRequest")
|
||||
/// "EnvError::ParseError { reason: \"foo\" }" → Some("EnvError::ParseError")
|
||||
/// "just a plain string" → None
|
||||
/// ```
|
||||
pub (crate) fn try_extract_error_name(debug_str: &str) -> Option<String> {
|
||||
ERROR_NAME_RE.find(debug_str)
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- try_extract_error_name tests ---
|
||||
|
||||
#[test]
|
||||
fn extract_just_error_name() {
|
||||
assert_eq!(
|
||||
try_extract_error_name("JustErrorName"),
|
||||
Some("JustErrorName".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_variant_and_string_payload() {
|
||||
assert_eq!(
|
||||
try_extract_error_name(r#"CommonWebError::InvalidRequest("Invalid request")"#),
|
||||
Some("CommonWebError::InvalidRequest".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_unit_variant() {
|
||||
assert_eq!(
|
||||
try_extract_error_name("EnvError::NotUnicode"),
|
||||
Some("EnvError::NotUnicode".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_struct_payload() {
|
||||
assert_eq!(
|
||||
try_extract_error_name(r#"EnvError::ParseError { reason: "foo" }"#),
|
||||
Some("EnvError::ParseError".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_tuple_payload() {
|
||||
assert_eq!(
|
||||
try_extract_error_name("KinoviError::RequestError(asdf)"),
|
||||
Some("KinoviError::RequestError".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_nested_errors() {
|
||||
assert_eq!(
|
||||
try_extract_error_name(
|
||||
"ComplexError::SomeError(SomeError::SomeVariant(SomeInnerError::SomeInnerVariant))"
|
||||
),
|
||||
Some("ComplexError::SomeError".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_struct_containing_nested() {
|
||||
assert_eq!(
|
||||
try_extract_error_name(
|
||||
"WrappedError::Variant { inner: SomeInnerError::SomeInnerVariant }"
|
||||
),
|
||||
Some("WrappedError::Variant".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match_for_lowercase_string() {
|
||||
assert_eq!(
|
||||
try_extract_error_name("just a plain error message with no type name"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match_for_empty_string() {
|
||||
assert_eq!(try_extract_error_name(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_word_camel_case() {
|
||||
assert_eq!(
|
||||
try_extract_error_name("Error"),
|
||||
Some("Error".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// --- try_error_title tests (with real error types) ---
|
||||
|
||||
#[test]
|
||||
fn title_from_io_error() {
|
||||
let err: Box<dyn std::error::Error> = Box::new(
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
|
||||
);
|
||||
let title = try_error_name(err.as_ref());
|
||||
assert!(title.is_some());
|
||||
}
|
||||
|
||||
/// A simple custom error for testing.
|
||||
#[derive(Debug)]
|
||||
enum TestAppError {
|
||||
DatabaseTimeout,
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TestAppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TestAppError {}
|
||||
|
||||
#[test]
|
||||
fn title_from_custom_unit_variant() {
|
||||
let err = TestAppError::DatabaseTimeout;
|
||||
let title = try_error_name(&err);
|
||||
assert_eq!(title, Some("DatabaseTimeout".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_from_custom_tuple_variant() {
|
||||
let err = TestAppError::InvalidInput("bad data".to_string());
|
||||
let title = try_error_name(&err);
|
||||
assert_eq!(title, Some("InvalidInput".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ datetimes = { path = "../../../lib/data/datetimes" }
|
||||
easyenv.workspace = true
|
||||
elasticsearch_schema = { path = "../../../schema/database/elasticsearch_schema" }
|
||||
enums.workspace = true
|
||||
error_utils.workspace = true
|
||||
errors.workspace = true
|
||||
fal_client.workspace = true
|
||||
filesys.workspace = true
|
||||
|
||||
+2
-174
@@ -1,6 +1,5 @@
|
||||
use error_utils::try_error_name::try_error_name;
|
||||
use log::{debug, warn};
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use pager::client::pager::Pager;
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
@@ -24,7 +23,7 @@ pub(crate) fn check_advanced_common_web_error(
|
||||
}
|
||||
|
||||
let maybe_error_name = error.cause()
|
||||
.and_then(|cause| try_error_title(cause));
|
||||
.and_then(|cause| try_error_name(cause));
|
||||
|
||||
let title = match maybe_error_name {
|
||||
Some(name) => format!("{} on {} {}", name, method, path),
|
||||
@@ -61,174 +60,3 @@ pub(crate) fn check_advanced_common_web_error(
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Try to extract a human-readable error title from the causal error's Debug
|
||||
/// representation. Looks for CamelCase type names like `EnvError::NotUnicode`.
|
||||
fn try_error_title(error: &dyn std::error::Error) -> Option<String> {
|
||||
let debug_str = format!("{:?}", error);
|
||||
try_extract_error_name(&debug_str)
|
||||
}
|
||||
|
||||
/// Regex that matches CamelCase type names with an optional `::Variant` suffix.
|
||||
/// E.g. `CommonWebError::InvalidRequest`, `EnvError`, `KinoviError::RequestError`.
|
||||
static ERROR_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*(?:::[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*)?")
|
||||
.expect("error name regex should compile")
|
||||
});
|
||||
|
||||
/// Extract the leading CamelCase error name (with optional `::Variant`) from
|
||||
/// a Debug-formatted error string. Returns `None` if no match is found.
|
||||
///
|
||||
/// ```text
|
||||
/// "CommonWebError::InvalidRequest(\"bad\")" → Some("CommonWebError::InvalidRequest")
|
||||
/// "EnvError::ParseError { reason: \"foo\" }" → Some("EnvError::ParseError")
|
||||
/// "just a plain string" → None
|
||||
/// ```
|
||||
fn try_extract_error_name(debug_str: &str) -> Option<String> {
|
||||
ERROR_NAME_RE.find(debug_str)
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- try_extract_error_name tests ---
|
||||
|
||||
#[test]
|
||||
fn extract_just_error_name() {
|
||||
assert_eq!(
|
||||
try_extract_error_name("JustErrorName"),
|
||||
Some("JustErrorName".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_variant_and_string_payload() {
|
||||
assert_eq!(
|
||||
try_extract_error_name(r#"CommonWebError::InvalidRequest("Invalid request")"#),
|
||||
Some("CommonWebError::InvalidRequest".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_unit_variant() {
|
||||
assert_eq!(
|
||||
try_extract_error_name("EnvError::NotUnicode"),
|
||||
Some("EnvError::NotUnicode".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_struct_payload() {
|
||||
assert_eq!(
|
||||
try_extract_error_name(r#"EnvError::ParseError { reason: "foo" }"#),
|
||||
Some("EnvError::ParseError".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_tuple_payload() {
|
||||
assert_eq!(
|
||||
try_extract_error_name("KinoviError::RequestError(asdf)"),
|
||||
Some("KinoviError::RequestError".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_nested_errors() {
|
||||
// Should only grab the outermost error name.
|
||||
assert_eq!(
|
||||
try_extract_error_name(
|
||||
"ComplexError::SomeError(SomeError::SomeVariant(SomeInnerError::SomeInnerVariant))"
|
||||
),
|
||||
Some("ComplexError::SomeError".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_with_struct_containing_nested() {
|
||||
assert_eq!(
|
||||
try_extract_error_name(
|
||||
"WrappedError::Variant { inner: SomeInnerError::SomeInnerVariant }"
|
||||
),
|
||||
Some("WrappedError::Variant".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match_for_lowercase_string() {
|
||||
assert_eq!(
|
||||
try_extract_error_name("just a plain error message with no type name"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match_for_empty_string() {
|
||||
assert_eq!(try_extract_error_name(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_word_camel_case() {
|
||||
// A single CamelCase word like "Error" should not match because
|
||||
// the regex requires at least two segments (e.g. "Ab" = A + b).
|
||||
// "Error" = E + rror — "rror" has 4 lowercase chars which is fine.
|
||||
assert_eq!(
|
||||
try_extract_error_name("Error"),
|
||||
Some("Error".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// --- try_error_title tests (with real error types) ---
|
||||
|
||||
#[test]
|
||||
fn title_from_io_error() {
|
||||
let err: Box<dyn std::error::Error> = Box::new(
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
|
||||
);
|
||||
// io::Error Debug looks like: `Custom { kind: NotFound, error: "file not found" }`
|
||||
// "Custom" doesn't match CamelCase with 2+ segments... but we get what we get.
|
||||
let title = try_error_title(err.as_ref());
|
||||
// The exact output depends on Debug formatting. Just verify it doesn't panic.
|
||||
// Custom has one uppercase + lowercase sequence = "Custom" which matches [A-Z][0-9a-z]+
|
||||
assert!(title.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_from_serde_json_error() {
|
||||
let err: serde_json::Error = serde_json::from_str::<String>("not json").unwrap_err();
|
||||
let title = try_error_title(&err);
|
||||
// serde_json::Error Debug typically starts with "Error(...)"
|
||||
assert!(title.is_some());
|
||||
}
|
||||
|
||||
/// A simple custom error for testing.
|
||||
#[derive(Debug)]
|
||||
enum TestAppError {
|
||||
DatabaseTimeout,
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TestAppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TestAppError {}
|
||||
|
||||
#[test]
|
||||
fn title_from_custom_unit_variant() {
|
||||
let err = TestAppError::DatabaseTimeout;
|
||||
let title = try_error_title(&err);
|
||||
assert_eq!(title, Some("DatabaseTimeout".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_from_custom_tuple_variant() {
|
||||
let err = TestAppError::InvalidInput("bad data".to_string());
|
||||
let title = try_error_title(&err);
|
||||
assert_eq!(title, Some("InvalidInput".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,9 @@ export const GalleryModal = React.memo(
|
||||
// Track whether this is the very first load (no cache, no items yet)
|
||||
const [initialLoading, setInitialLoading] = useState(false);
|
||||
|
||||
// Error message from failed listUserMediaFiles call (null = no error)
|
||||
const [itemsLoadError, setItemsLoadError] = useState<string | null>(null);
|
||||
|
||||
// Bulk selection state (view mode only)
|
||||
const [bulkSelectedIds, setBulkSelectedIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
@@ -545,6 +548,7 @@ export const GalleryModal = React.memo(
|
||||
const response = await api.listUserMediaFiles(query);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setItemsLoadError(null);
|
||||
const newItems = response.data
|
||||
.filter(
|
||||
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
|
||||
@@ -589,9 +593,13 @@ export const GalleryModal = React.memo(
|
||||
});
|
||||
return latest;
|
||||
});
|
||||
} else {
|
||||
setItemsLoadError(response.errorMessage || "Request failed (unknown error)");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch library items:", error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to fetch library items:", msg);
|
||||
setItemsLoadError(msg);
|
||||
}
|
||||
setLoading(false);
|
||||
setPaginationLoading(false);
|
||||
@@ -603,6 +611,7 @@ export const GalleryModal = React.memo(
|
||||
|
||||
// refresh logic — shows cached items immediately, then background-refreshes
|
||||
const refreshGallery = useCallback(() => {
|
||||
setItemsLoadError(null);
|
||||
const cacheKey = `gallery_${activeFilterRef.current}`;
|
||||
const cached = galleryCacheMap.get(cacheKey);
|
||||
|
||||
@@ -1139,6 +1148,27 @@ export const GalleryModal = React.memo(
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : itemsLoadError && allItems.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-sm max-w-xs text-center">
|
||||
<div className="text-base-fg/60">Failed to load gallery.</div>
|
||||
<div className="text-xs text-base-fg/40 font-mono break-all">{itemsLoadError}</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="text-xs text-blue-400 hover:text-blue-300 underline"
|
||||
onClick={() => navigator.clipboard?.writeText(itemsLoadError!)}
|
||||
>
|
||||
Copy error
|
||||
</button>
|
||||
<button
|
||||
className="text-xs text-blue-400 hover:text-blue-300 underline"
|
||||
onClick={() => refreshGallery()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (initialLoading || !username) && allItems.length === 0 ? (
|
||||
<SkeletonGrid columns={gridColumns} />
|
||||
) : allItems.length === 0 && !loading ? (
|
||||
|
||||
Reference in New Issue
Block a user