mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
Import: dropped and picked files show an Importing card (#270)
Files dropped on the window or picked in the Import dialog were probed
one after the other on the UI thread: the app froze while a video was
read, nothing said what was being imported or how many files were
left, and only the last new item was selected. People ended up
importing the same file again and again.
file.import takes `background`: the files are probed in a background
job (Window > Progress, jobs.list / jobs.cancel) that names the file
being read and how many there are ("b.png (2 of 3)"); then they are
added in one undo step, all selected (the Project panel scrolls to
them, a file already in the project arrives again as in After
Effects) and a toast reports the count ("Imported 3 items", with the
first error). Drops and the Import dialog use it, and an Importing
card at the bottom left shows the job with its progress bar and
Cancel. Agents and scripts still import at once by default. The
toast registers an automation id.
Test: background_imports_report_progress_and_select_what_arrived
(engine), dropped_files_show_import_progress_then_the_items_they_made
(ui_drag_drop).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
285297ed93
commit
3318cb0da1
+232
-121
@@ -4,7 +4,7 @@ use effectcraft_color::Label;
|
||||
use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
use super::{CommandSpec, always, b_p, bad, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn has_path(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -103,16 +103,70 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
|
||||
/// also become layers of the active comp ([`add_to_comp`]).
|
||||
///
|
||||
/// With `background` (files dropped on the window or picked in the Import dialog) the files are
|
||||
/// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows
|
||||
/// the file being read and how many are left (Window ▸ Progress, `jobs.list`), then adds them in
|
||||
/// one undo step, selects them and reports how many arrived ("Imported 3 items").
|
||||
fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = s.active_comp_id();
|
||||
let mut r = import(s, p)?;
|
||||
let errors = add_to_comp(s, &r, target, p);
|
||||
let mut pending = prepare_import(s, p)?;
|
||||
if b_p(p, "background").unwrap_or(false) && !pending.paths.is_empty() {
|
||||
let paths = std::mem::take(&mut pending.paths);
|
||||
let total = paths.len();
|
||||
let label = match paths.first() {
|
||||
Some(one) if total == 1 => format!("Importing {}", file_name(one)),
|
||||
_ => format!("Importing {total} files"),
|
||||
};
|
||||
let p = p.clone();
|
||||
return s.spawn_task("import", label, false, move |ctl| {
|
||||
let mut probed = Vec::with_capacity(total);
|
||||
for (k, path) in paths.into_iter().enumerate() {
|
||||
ctl.message(format!("{} ({} of {total})", file_name(&path), k + 1));
|
||||
if !ctl.progress(k as u64, total as u64) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
let r = pending.prober.probe(&path);
|
||||
probed.push(r);
|
||||
}
|
||||
ctl.progress(total as u64, total as u64);
|
||||
let apply: crate::jobs::Apply = Box::new(move |s: &mut Session| {
|
||||
let made = pending.comps.len() + probed.iter().filter(|r| r.is_ok()).count();
|
||||
let mut r = add_footage(s, pending, probed)?;
|
||||
finish_import(s, &mut r, target, &p);
|
||||
let mut toast = format!("Imported {made} item{}", if made == 1 { "" } else { "s" });
|
||||
let errors: Vec<&str> = r["errors"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
match errors.as_slice() {
|
||||
[] => {}
|
||||
[one] => toast = format!("{toast}. {one}"),
|
||||
[first, rest @ ..] => toast = format!("{toast}. {first} (and {} more)", rest.len()),
|
||||
}
|
||||
r["toast"] = json!(toast);
|
||||
Ok(r)
|
||||
});
|
||||
Ok(apply)
|
||||
});
|
||||
}
|
||||
let probed = pending.paths.iter().map(|path| pending.prober.probe(path)).collect();
|
||||
let mut r = add_footage(s, pending, probed)?;
|
||||
finish_import(s, &mut r, target, p);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The file name of a path, for labels.
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`;
|
||||
/// what couldn't be added joins the result's errors.
|
||||
fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Value) {
|
||||
let errors = add_to_comp(s, r, target, p);
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
{
|
||||
e.extend(errors.into_iter().map(Value::from));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// With `addToComp`, put what an import (its result `r`) made into `target`, the comp that was
|
||||
@@ -149,7 +203,115 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p:
|
||||
errors
|
||||
}
|
||||
|
||||
/// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`].
|
||||
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pending = prepare_import(s, p)?;
|
||||
let probed = pending.paths.iter().map(|path| pending.prober.probe(path)).collect();
|
||||
add_footage(s, pending, probed)
|
||||
}
|
||||
|
||||
/// An import whose layered files are already compositions: the files left to probe as footage,
|
||||
/// how, and what the import made so far.
|
||||
struct PendingImport {
|
||||
paths: Vec<String>,
|
||||
prober: Prober,
|
||||
comps: Vec<u64>,
|
||||
items: Vec<u64>,
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Everything probing a file needs, apart from the session (it can run on a background thread).
|
||||
struct Prober {
|
||||
importer: Option<std::sync::Arc<dyn crate::Importer>>,
|
||||
services: std::sync::Arc<dyn crate::Services>,
|
||||
/// Settings ▸ Import ▸ Report Missing Frames.
|
||||
report_missing_frames: bool,
|
||||
/// Settings ▸ Import ▸ Interpret Unlabeled Alpha As, for stills and for movies.
|
||||
still_alpha: Option<effectcraft_project::AlphaMode>,
|
||||
movie_alpha: Option<effectcraft_project::AlphaMode>,
|
||||
/// Settings ▸ Import ▸ Sequence Footage frames per second.
|
||||
sequence_rate: effectcraft_time::FrameRate,
|
||||
/// PDF / Illustrator page (0-based).
|
||||
page: u32,
|
||||
/// Choose Layer: one layer of a Photoshop document.
|
||||
psd_layer: Option<Value>,
|
||||
}
|
||||
|
||||
/// A file probed as footage.
|
||||
struct Probed {
|
||||
path: String,
|
||||
footage: effectcraft_project::Footage,
|
||||
/// Its alpha is unlabeled and Settings say Ask User: open Interpret Footage.
|
||||
ask_alpha: bool,
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
impl Prober {
|
||||
/// Probe `path` as footage; the error names the file.
|
||||
fn probe(&self, path: &str) -> std::result::Result<Probed, String> {
|
||||
let fail = |e: String| format!("{path}: {e}");
|
||||
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
|
||||
if let Some(f) = data_footage(self.services.as_ref(), path) {
|
||||
return f.map(|footage| Probed { path: path.to_string(), footage, ask_alpha: false, warning: None }).map_err(fail);
|
||||
}
|
||||
let importer = self.importer.as_ref().ok_or_else(|| fail("media import is not available in this build".into()))?;
|
||||
let mut f = importer.probe(path).map_err(fail)?;
|
||||
let mut warning = None;
|
||||
// Settings ▸ Import ▸ Report Missing Frames.
|
||||
if f.kind == FootageKind::Sequence
|
||||
&& self.report_missing_frames
|
||||
&& let Some(gaps) = missing_frames(&f.sequence)
|
||||
{
|
||||
warning = Some(format!("{path}: {gaps}"));
|
||||
}
|
||||
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As.
|
||||
let mut ask_alpha = false;
|
||||
if f.alpha != effectcraft_project::AlphaMode::Ignore && !alpha_is_labeled(path, &f.codec) {
|
||||
let setting = if f.kind == FootageKind::Video { self.movie_alpha } else { self.still_alpha };
|
||||
match setting {
|
||||
Some(a) => f.alpha = a,
|
||||
None => ask_alpha = true,
|
||||
}
|
||||
}
|
||||
// Settings ▸ Import ▸ Sequence Footage frames per second.
|
||||
if f.kind == FootageKind::Sequence {
|
||||
let r = self.sequence_rate;
|
||||
let frames = f.frame_rate.frame_at(f.duration);
|
||||
f.frame_rate = r;
|
||||
f.duration = r.tick_of(frames.max(1));
|
||||
}
|
||||
// Page: another page of a PDF / Illustrator file.
|
||||
if self.page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") {
|
||||
let doc = self
|
||||
.services
|
||||
.read_file(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| effectcraft_pdf::parse_page(&b, self.page as usize).map_err(|e| e.to_string()))
|
||||
.map_err(fail)?;
|
||||
(f.width, f.height) = doc.pixel_size();
|
||||
f.page = self.page;
|
||||
}
|
||||
// Choose Layer: one layer of a Photoshop document (document-sized).
|
||||
if let Some(sel) = &self.psd_layer
|
||||
&& f.codec == "PSD"
|
||||
{
|
||||
let (index, name) = self
|
||||
.services
|
||||
.read_file(path)
|
||||
.ok()
|
||||
.and_then(|b| effectcraft_psd::Psd::parse(b).ok())
|
||||
.and_then(|d| find_psd_layer(&d, sel))
|
||||
.ok_or_else(|| fail(format!("no layer {sel}")))?;
|
||||
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false });
|
||||
}
|
||||
Ok(Probed { path: path.to_string(), footage: f, ask_alpha, warning })
|
||||
}
|
||||
}
|
||||
|
||||
/// The first part of an import: read the parameters, note the files as recent footage, and
|
||||
/// import layered files (Photoshop, PDF, Illustrator, EPS) as compositions when `importAs` (or,
|
||||
/// for dropped files, Default Drag Import As) asks for that. The other files are left to probe.
|
||||
fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
||||
let paths: Vec<String> = match p.get("paths").or(p.get("path")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::String(x)) => vec![x.clone()],
|
||||
@@ -178,8 +340,8 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
};
|
||||
let mut paths = paths;
|
||||
let mut out_comps = vec![];
|
||||
let mut ids = vec![];
|
||||
let mut comps = vec![];
|
||||
let mut items = vec![];
|
||||
let mut errors = vec![];
|
||||
if let Some(retain) = retain {
|
||||
let mut rest = vec![];
|
||||
@@ -189,9 +351,9 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => {
|
||||
// PDF / Illustrator / EPS: one layer per file layer.
|
||||
match import_vector_comp(s, &path, &b, page) {
|
||||
Ok((comp, items)) => {
|
||||
out_comps.push(comp);
|
||||
ids.extend(items);
|
||||
Ok((comp, made)) => {
|
||||
comps.push(comp);
|
||||
items.extend(made);
|
||||
}
|
||||
Err(e) => errors.push(e.to_string()),
|
||||
}
|
||||
@@ -203,127 +365,76 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
match import_psd_comp(s, &path, bytes, retain) {
|
||||
Ok((comp, items, warnings)) => {
|
||||
out_comps.push(comp);
|
||||
ids.extend(items);
|
||||
Ok((comp, made, warnings)) => {
|
||||
comps.push(comp);
|
||||
items.extend(made);
|
||||
errors.extend(warnings);
|
||||
}
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
paths = rest;
|
||||
if paths.is_empty() {
|
||||
if let Some(c) = out_comps.first() {
|
||||
s.open_comp(effectcraft_project::ItemId(*c));
|
||||
}
|
||||
return Ok(json!({"items": ids, "comps": out_comps, "errors": errors}));
|
||||
}
|
||||
}
|
||||
let mut probed = vec![];
|
||||
let psd_layer = p.get("layer").cloned();
|
||||
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
|
||||
let mut ask_alpha = vec![];
|
||||
let prober = Prober {
|
||||
importer: s.importer.clone(),
|
||||
services: s.services.clone(),
|
||||
report_missing_frames: s.prefs.import.report_missing_frames,
|
||||
still_alpha: s.prefs.unlabeled_alpha(false),
|
||||
movie_alpha: s.prefs.unlabeled_alpha(true),
|
||||
sequence_rate: effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps),
|
||||
page,
|
||||
psd_layer: p.get("layer").cloned(),
|
||||
};
|
||||
Ok(PendingImport { paths, prober, comps, items, errors })
|
||||
}
|
||||
|
||||
/// The last part of an import: add the probed files to the project (one undo step) and select
|
||||
/// everything the import made, so the Project panel shows it.
|
||||
fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result::Result<Probed, String>>) -> Result<Value> {
|
||||
let PendingImport { comps, mut items, mut errors, .. } = pending;
|
||||
let mut found = vec![];
|
||||
let mut warnings = vec![];
|
||||
for path in &paths {
|
||||
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
|
||||
if let Some(f) = data_footage(s, path) {
|
||||
match f {
|
||||
Ok(f) => probed.push((path.clone(), f)),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
for r in probed {
|
||||
match r {
|
||||
Ok(f) => {
|
||||
warnings.extend(f.warning.clone());
|
||||
found.push(f);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(importer) = s.importer.clone() else {
|
||||
errors.push(format!("{path}: media import is not available in this build"));
|
||||
continue;
|
||||
};
|
||||
match importer.probe(path) {
|
||||
Ok(mut f) => {
|
||||
// Settings ▸ Import ▸ Report Missing Frames.
|
||||
if f.kind == FootageKind::Sequence
|
||||
&& s.prefs.import.report_missing_frames
|
||||
&& let Some(gaps) = missing_frames(&f.sequence)
|
||||
{
|
||||
warnings.push(format!("{path}: {gaps}"));
|
||||
}
|
||||
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As.
|
||||
if f.alpha != effectcraft_project::AlphaMode::Ignore && !alpha_is_labeled(path, &f.codec) {
|
||||
match s.prefs.unlabeled_alpha(f.kind == FootageKind::Video) {
|
||||
Some(a) => f.alpha = a,
|
||||
None => ask_alpha.push(paths.iter().position(|x| x == path).unwrap_or(0)),
|
||||
}
|
||||
}
|
||||
// Settings ▸ Import ▸ Sequence Footage frames per second.
|
||||
if f.kind == FootageKind::Sequence {
|
||||
let r = seq_rate;
|
||||
let frames = f.frame_rate.frame_at(f.duration);
|
||||
f.frame_rate = r;
|
||||
f.duration = r.tick_of(frames.max(1));
|
||||
}
|
||||
// Page: another page of a PDF / Illustrator file.
|
||||
if page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") {
|
||||
match s
|
||||
.services
|
||||
.read_file(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| effectcraft_pdf::parse_page(&b, page as usize).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(doc) => {
|
||||
(f.width, f.height) = doc.pixel_size();
|
||||
f.page = page;
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{path}: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Choose Layer: one layer of a Photoshop document (document-sized).
|
||||
if let Some(sel) = &psd_layer
|
||||
&& f.codec == "PSD"
|
||||
{
|
||||
match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) {
|
||||
Some((index, name)) => {
|
||||
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false })
|
||||
}
|
||||
None => {
|
||||
errors.push(format!("{path}: no layer {sel}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
probed.push((path.clone(), f))
|
||||
}
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
let mut asked: Vec<u64> = vec![];
|
||||
s.edit("Import", None, |proj, st| {
|
||||
for (path, f) in probed {
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
let name = match &f.layer {
|
||||
Some(l) => format!("{}/{name}", l.name),
|
||||
None if f.page > 0 => format!("{name} (Page {})", f.page + 1),
|
||||
None => name,
|
||||
};
|
||||
let label = match f.kind {
|
||||
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
|
||||
FootageKind::Audio => Label::SeaFoam,
|
||||
FootageKind::Video | FootageKind::Model => Label::Aqua,
|
||||
FootageKind::Data => Label::Sandstone,
|
||||
};
|
||||
let ask = ask_alpha.contains(&paths.iter().position(|x| *x == path).unwrap_or(usize::MAX));
|
||||
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
|
||||
ids.push(id.0);
|
||||
if ask {
|
||||
asked.push(id.0);
|
||||
let made: Vec<ItemId> = comps.iter().map(|c| ItemId(*c)).collect();
|
||||
if !found.is_empty() {
|
||||
s.edit("Import", None, |proj, st| {
|
||||
let mut selection = made;
|
||||
for f in found {
|
||||
let name = file_name(&f.path);
|
||||
let footage = f.footage;
|
||||
let name = match &footage.layer {
|
||||
Some(l) => format!("{}/{name}", l.name),
|
||||
None if footage.page > 0 => format!("{name} (Page {})", footage.page + 1),
|
||||
None => name,
|
||||
};
|
||||
let label = match footage.kind {
|
||||
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
|
||||
FootageKind::Audio => Label::SeaFoam,
|
||||
FootageKind::Video | FootageKind::Model => Label::Aqua,
|
||||
FootageKind::Data => Label::Sandstone,
|
||||
};
|
||||
let id = proj.add_item(&name, label, None, ItemKind::Footage(footage));
|
||||
items.push(id.0);
|
||||
if f.ask_alpha {
|
||||
asked.push(id.0);
|
||||
}
|
||||
selection.push(id);
|
||||
}
|
||||
st.project_selection = vec![id];
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(c) = out_comps.first() {
|
||||
s.open_comp(effectcraft_project::ItemId(*c));
|
||||
st.project_selection = selection;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if let Some(c) = comps.first() {
|
||||
s.open_comp(ItemId(*c));
|
||||
}
|
||||
for w in &warnings {
|
||||
s.events.push(crate::Event::Toast { message: w.clone(), error: true });
|
||||
@@ -332,7 +443,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(first) = asked.first() {
|
||||
s.events.push(crate::Event::Frontend { command: "file.interpretFootage".into(), params: json!({"item": first, "reason": "unlabeledAlpha"}) });
|
||||
}
|
||||
Ok(json!({"items": ids, "comps": out_comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked}))
|
||||
Ok(json!({"items": items, "comps": comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked}))
|
||||
}
|
||||
|
||||
/// Whether a file format states how its alpha is stored (PNG, GIF, WebP and PSD are straight by
|
||||
@@ -416,13 +527,13 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R
|
||||
pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
|
||||
|
||||
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
|
||||
pub(crate) fn data_footage(s: &Session, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
|
||||
pub(crate) fn data_footage(services: &dyn crate::Services, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
|
||||
return None;
|
||||
}
|
||||
Some((|| {
|
||||
let bytes = s.services.read_file(path).map_err(|e| e.to_string())?;
|
||||
let bytes = services.read_file(path).map_err(|e| e.to_string())?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| "data files must be UTF-8 text".to_string())?;
|
||||
if ext == "json" {
|
||||
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
@@ -585,7 +696,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem)}",
|
||||
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
always,
|
||||
import_cmd
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Background jobs: one list (Window ▸ Progress, `jobs.list`, `jobs.cancel`) over the render
|
||||
//! queue, the analyses (tracker, mask tracker, Warp Stabilizer, 3D Camera Tracker, Roto Brush) and
|
||||
//! the generic background tasks started here (Content-Aware Fill, Scene Edit Detection).
|
||||
//! the generic background tasks started here (Content-Aware Fill, Scene Edit Detection, footage
|
||||
//! checks and imports).
|
||||
//!
|
||||
//! A generic task runs a closure on a background thread (inline on wasm32 or when the caller
|
||||
//! waits). The closure reports progress through [`TaskCtl`] and returns an [`Apply`]: a function
|
||||
@@ -162,7 +163,8 @@ impl Session {
|
||||
let cancelled = t.ctl.cancelled();
|
||||
let (status, message, result) = match r {
|
||||
Some(Ok(apply)) if !cancelled => match apply(self) {
|
||||
Ok(v) => ("done", String::new(), v),
|
||||
// A task may say how it went (`toast`: "Imported 3 items").
|
||||
Ok(v) => ("done", v.get("toast").and_then(Value::as_str).unwrap_or_default().to_string(), v),
|
||||
Err(e) => ("failed", e.to_string(), Value::Null),
|
||||
},
|
||||
Some(Ok(_)) => ("cancelled", String::new(), Value::Null),
|
||||
@@ -180,6 +182,7 @@ impl Session {
|
||||
seconds: t.started.elapsed().as_secs_f64(),
|
||||
};
|
||||
let toast = match status {
|
||||
"done" if !message.is_empty() => message.clone(),
|
||||
"done" => format!("{} finished", t.label),
|
||||
"cancelled" => format!("{} cancelled", t.label),
|
||||
_ => format!("{} failed: {message}", t.label),
|
||||
|
||||
@@ -96,6 +96,69 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() {
|
||||
assert!(s.execute("layer.addItem", json!({"item": "a.png", "position": [1.0]})).is_err(), "position needs x and y");
|
||||
}
|
||||
|
||||
/// #270: files dropped or picked in the Import dialog (`file.import {background}`) are probed in
|
||||
/// a background job that says which file it is reading and how many are left; then they arrive
|
||||
/// in one undo step, all selected, with a count. The same file imported again arrives again.
|
||||
#[test]
|
||||
fn background_imports_report_progress_and_select_what_arrived() {
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
/// Probes file k once `allow` > k (a slow video; 5 s at most, so an import that waits for it
|
||||
/// fails the test instead of hanging it); `.xyz` files fail.
|
||||
struct Gated {
|
||||
started: AtomicUsize,
|
||||
allow: Arc<AtomicUsize>,
|
||||
}
|
||||
impl crate::Importer for Gated {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
let k = self.started.fetch_add(1, Ordering::SeqCst);
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while self.allow.load(Ordering::SeqCst) <= k && std::time::Instant::now() < until {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
if path.ends_with(".xyz") {
|
||||
return Err("unsupported file".into());
|
||||
}
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let allow = Arc::new(AtomicUsize::new(1));
|
||||
let mut s = Session { importer: Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: allow.clone() })), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
let r = s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.png", "/drop/a.png"], "background": true})).unwrap();
|
||||
let job = r["job"].as_str().unwrap().to_string();
|
||||
// The first file is through; the second is being read.
|
||||
let running = |s: &mut Session| s.execute("jobs.list", json!({})).unwrap()["running"].as_array().unwrap().clone();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while running(&mut s)[0]["done"] != json!(1) && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
let row = running(&mut s)[0].clone();
|
||||
assert_eq!(row["id"], json!(job));
|
||||
assert_eq!(row["label"], json!("Importing 3 files"));
|
||||
assert_eq!(row["message"], json!("b.png (2 of 3)"));
|
||||
assert_eq!((row["done"].as_u64(), row["total"].as_u64()), (Some(1), Some(3)));
|
||||
assert!(s.project.items.values().all(|i| i.name != "a.png"), "nothing is added before the import finishes");
|
||||
allow.store(usize::MAX, Ordering::SeqCst);
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
let names = |s: &Session, ids: &[ItemId]| ids.iter().map(|i| s.project.item(*i).unwrap().name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&s, &s.state.project_selection), ["a.png", "b.png", "a.png"], "everything imported is selected, the same file twice too");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
let rec = s.job_log.last().unwrap();
|
||||
assert_eq!((rec.status.as_str(), rec.message.as_str()), ("done", "Imported 3 items"));
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { message, .. } if message == "Imported 3 items")));
|
||||
// Dropped on the viewer: they become layers too; a file that can't be read is reported.
|
||||
s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.xyz"], "background": true, "addToComp": true, "position": [10.0, 20.0]})).unwrap();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["c.png"]);
|
||||
assert_eq!(s.job_log.last().unwrap().message, "Imported 1 item. /drop/d.xyz: unsupported file");
|
||||
// Agents and scripts import at once by default.
|
||||
let r = s.execute("file.import", json!({"paths": ["/drop/e.png"]})).unwrap();
|
||||
assert_eq!(r["items"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
|
||||
#[test]
|
||||
fn audio_lock_and_shy_keep_cached_layers() {
|
||||
|
||||
@@ -1383,7 +1383,7 @@ impl EffectcraftApp {
|
||||
self.session.poll_mask_track();
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
// Background tasks (Content-Aware Fill, Scene Edit Detection).
|
||||
// Background tasks (Content-Aware Fill, Scene Edit Detection, imports).
|
||||
if !self.session.tasks.is_empty() {
|
||||
self.session.poll_jobs();
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
@@ -1451,10 +1451,13 @@ impl EffectcraftApp {
|
||||
panels::scriptui_view::sync_panels(self);
|
||||
panels::scriptui_view::show_windows(self, &ctx);
|
||||
panels::learn::coach(self, &ctx);
|
||||
self.draw_toast(ui, full);
|
||||
let lift = panels::media_panels::import_card(self, ui, full);
|
||||
self.draw_toast(ui, full, lift);
|
||||
}
|
||||
|
||||
fn draw_toast(&mut self, ui: &mut egui::Ui, full: egui::Rect) {
|
||||
/// The status message or the latest toast, bottom left (`lift` points higher while the
|
||||
/// Importing card is there).
|
||||
fn draw_toast(&mut self, ui: &mut egui::Ui, full: egui::Rect, lift: f32) {
|
||||
let now = ui.input(|i| i.time);
|
||||
let msg = if !self.ui.status.is_empty() {
|
||||
Some(self.ui.status.clone())
|
||||
@@ -1463,11 +1466,12 @@ impl EffectcraftApp {
|
||||
};
|
||||
if let Some(m) = msg {
|
||||
let t = &self.tokens;
|
||||
let galley = ui.painter().layout_no_wrap(m, Tokens::ui(12.0), t.text);
|
||||
let r = egui::Rect::from_min_size(egui::pos2(full.min.x + 16.0, full.max.y - 44.0), galley.size() + egui::vec2(24.0, 14.0));
|
||||
let galley = ui.painter().layout_no_wrap(m.clone(), Tokens::ui(12.0), t.text);
|
||||
let r = egui::Rect::from_min_size(egui::pos2(full.min.x + 16.0, full.max.y - 44.0 - lift), galley.size() + egui::vec2(24.0, 14.0));
|
||||
ui.painter().rect_filled(r, 6.0, t.panel_bg);
|
||||
ui.painter().rect_stroke(r, 6.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
ui.painter().galley(r.min + egui::vec2(12.0, 7.0), galley, t.text);
|
||||
self.auto.add("toast", r, &m);
|
||||
let resp = ui.interact(r, egui::Id::new("toast"), egui::Sense::click());
|
||||
if resp.clicked() {
|
||||
self.ui.status.clear();
|
||||
|
||||
@@ -1158,6 +1158,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
};
|
||||
let Some(v) = picked else { return Some(Ok(Value::Null)) };
|
||||
p.insert(key.to_string(), v);
|
||||
// Picked files import in the background, with the Importing card showing progress (#270).
|
||||
if matches!(id, "file.import" | "file.importMultiple") {
|
||||
p.insert("background".into(), Value::Bool(true));
|
||||
}
|
||||
// Photoshop files ask how to import them first.
|
||||
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &Value::Object(p.clone())) {
|
||||
return Some(Ok(json!({"dialog": id})));
|
||||
|
||||
@@ -342,9 +342,7 @@ pub fn progress(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let frac = j.fraction.unwrap_or(0.0) as f32;
|
||||
let pct = j.fraction.map(|f| format!("{:.0} %", f * 100.0)).unwrap_or_else(|| "…".into());
|
||||
p.text(pos2(x0 + w - 70.0, y + 8.0), Align2::RIGHT_CENTER, format!("{pct} {}", j.message), Tokens::ui(11.0), t.text_dim);
|
||||
let bar = Rect::from_min_size(pos2(x0, y + 20.0), vec2(w - 76.0, 6.0));
|
||||
p.rect_filled(bar, 3.0, t.field_bg);
|
||||
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * frac.clamp(0.0, 1.0), bar.height())), 3.0, t.accent);
|
||||
progress_bar(&p, Rect::from_min_size(pos2(x0, y + 20.0), vec2(w - 76.0, 6.0)), frac, &t);
|
||||
app.auto.add(&format!("progress.job.{}", j.id), Rect::from_min_size(pos2(x0, y), vec2(w, 28.0)), &j.label);
|
||||
if j.cancellable
|
||||
&& kit::button(app, ui, Rect::from_min_size(pos2(x0 + w - 64.0, y + 12.0), vec2(64.0, 18.0)), &format!("progress.cancel.{}", j.id), "Cancel", false)
|
||||
@@ -374,3 +372,39 @@ pub fn progress(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A job's progress bar (`frac` 0…1).
|
||||
fn progress_bar(p: &egui::Painter, bar: Rect, frac: f32, t: &Tokens) {
|
||||
p.rect_filled(bar, 3.0, t.field_bg);
|
||||
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * frac.clamp(0.0, 1.0), bar.height())), 3.0, t.accent);
|
||||
}
|
||||
|
||||
/// The Importing card, bottom left over the panels while files import in the background (dropped
|
||||
/// or picked in the Import dialog): the file being read, how many are left, and Cancel (#270).
|
||||
/// Returns the height it takes, so toasts sit above it.
|
||||
pub fn import_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
|
||||
if app.session.tasks.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let t = app.tokens;
|
||||
let w = 320.0_f32.min(full.width() - 32.0).max(120.0);
|
||||
let mut bottom = full.max.y - 16.0;
|
||||
for j in app.session.jobs().into_iter().filter(|j| j.kind == "import" && j.running) {
|
||||
let r = Rect::from_min_max(pos2(full.min.x + 16.0, bottom - 56.0), pos2(full.min.x + 16.0 + w, bottom));
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 6.0, t.panel_bg);
|
||||
p.rect_stroke(r, 6.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
let x0 = r.min.x + 12.0;
|
||||
let inner = w - 24.0;
|
||||
widgets::text_fit(p, pos2(x0, r.min.y + 15.0), Align2::LEFT_CENTER, &j.label, Tokens::medium(12.0), inner - 64.0, t.text);
|
||||
widgets::text_fit(p, pos2(x0, r.min.y + 32.0), Align2::LEFT_CENTER, &j.message, Tokens::ui(11.5), inner, t.text_dim);
|
||||
progress_bar(p, Rect::from_min_size(pos2(x0, r.min.y + 42.0), vec2(inner, 6.0)), j.fraction.unwrap_or(0.0) as f32, &t);
|
||||
app.auto.add(&format!("import.job.{}", j.id), r, &j.message);
|
||||
if kit::button(app, ui, Rect::from_min_size(pos2(r.max.x - 68.0, r.min.y + 6.0), vec2(56.0, 18.0)), &format!("import.cancel.{}", j.id), "Cancel", false)
|
||||
{
|
||||
kit::exec(app, "jobs.cancel", json!({"job": j.id}));
|
||||
}
|
||||
bottom = r.min.y - 8.0;
|
||||
}
|
||||
full.max.y - 16.0 - bottom
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
video_preview(app, ctx);
|
||||
}
|
||||
|
||||
/// Files dropped on the window are imported (layered files as Default Drag Import As says).
|
||||
/// Dropped on the Composition viewer, they also become layers centred there, as in After
|
||||
/// Effects (#85), when the platform reports where the pointer is during file drags.
|
||||
/// Files dropped on the window are imported (layered files as Default Drag Import As says) in
|
||||
/// the background, with the Importing card showing what is read (#270). Dropped on the
|
||||
/// Composition viewer, they also become layers centred there, as in After Effects (#85), when the
|
||||
/// platform reports where the pointer is during file drags.
|
||||
fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let paths: Vec<String> =
|
||||
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| std::path::Path::new(p).is_absolute()).collect());
|
||||
@@ -39,7 +40,8 @@ fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let viewer = app.auto.find("viewer.area").map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])));
|
||||
let at =
|
||||
ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(ctx, p));
|
||||
let params = json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), "addToComp": at.is_some(), "position": at});
|
||||
let params =
|
||||
json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), "addToComp": at.is_some(), "position": at, "background": true});
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Drag and drop between panels (egui_kittest, real pointer drags): Project
|
||||
//! items land in the Timeline where they are dropped, between layers and, over the time graph,
|
||||
//! starting there (#89); dropped on the Composition viewer they are centred where they land
|
||||
//! (#85); an effect dropped on the viewer goes on the layer under the pointer (#88).
|
||||
//! (#85); an effect dropped on the viewer goes on the layer under the pointer (#88). Files
|
||||
//! dropped on the window import with visible progress (#270).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
@@ -165,3 +169,91 @@ fn project_items_dropped_on_new_comp_make_a_composition() {
|
||||
assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog");
|
||||
assert_eq!(comps(&h), before + 1, "nothing made yet");
|
||||
}
|
||||
|
||||
/// A file dropped on the window (egui hands it over as a dropped file).
|
||||
#[derive(Debug)]
|
||||
struct Dropped(std::path::PathBuf);
|
||||
|
||||
impl egui::DroppedFile for Dropped {
|
||||
fn path(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
Err("not read".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage probes that hold file k until `allow` > k (slow video decoding), 5 s at most (an
|
||||
/// import that blocks the app then fails the test instead of hanging it).
|
||||
struct Gated {
|
||||
started: AtomicUsize,
|
||||
allow: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl effectcraft_engine::Importer for Gated {
|
||||
fn probe(&self, path: &str) -> Result<effectcraft_engine::project::Footage, String> {
|
||||
let k = self.started.fetch_add(1, Ordering::SeqCst);
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while self.allow.load(Ordering::SeqCst) <= k && std::time::Instant::now() < until {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
Ok(effectcraft_engine::project::Footage { path: path.into(), width: 64, height: 32, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Step the app until `done` holds (the import runs on another thread).
|
||||
fn step_until(h: &mut Harness<'_, EffectcraftApp>, done: impl Fn(&Harness<'_, EffectcraftApp>) -> bool) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while !done(h) {
|
||||
assert!(std::time::Instant::now() < deadline, "timed out");
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// #270: files dropped on the window import in the background with an Importing card that names
|
||||
/// the file being read and how many are left (the app keeps drawing meanwhile); then they are
|
||||
/// selected and shown in the Project panel, with the count in a toast. The same file dropped
|
||||
/// again arrives again, selected.
|
||||
#[test]
|
||||
fn dropped_files_show_import_progress_then_the_items_they_made() {
|
||||
let (mut h, _) = harness();
|
||||
let allow = Arc::new(AtomicUsize::new(1));
|
||||
h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: allow.clone() }));
|
||||
let file = |n: &str| std::env::temp_dir().join(n);
|
||||
for n in ["a.png", "b.png", "c.png"] {
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(file(n))));
|
||||
}
|
||||
h.run_steps(2);
|
||||
let card = |h: &Harness<'_, EffectcraftApp>| h.state().auto.query("import.job.").first().map(|e| e.label.clone());
|
||||
step_until(&mut h, |h| card(h).as_deref() == Some("b.png (2 of 3)"));
|
||||
let r = rect(&h, &card_id(&h));
|
||||
assert!(r.min.x < 40.0 && r.max.y > 960.0, "bottom left, over the panels (where toasts show): {r:?}");
|
||||
assert!(h.state().auto.find(&card_id(&h).replace("import.job.", "import.cancel.")).is_some(), "with Cancel");
|
||||
let items = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name.ends_with(".png")).count();
|
||||
assert_eq!(items(&h), 0, "nothing added until the import finishes");
|
||||
allow.store(usize::MAX, Ordering::SeqCst);
|
||||
step_until(&mut h, |h| card(h).is_none() && items(h) == 3);
|
||||
h.run_steps(2);
|
||||
let st = &h.state().session.state;
|
||||
let names: Vec<String> = st.project_selection.iter().map(|i| h.state().session.project.item(*i).unwrap().name.clone()).collect();
|
||||
assert_eq!(names, ["a.png", "b.png", "c.png"], "what arrived is selected");
|
||||
let last = st.project_selection[2].0;
|
||||
assert!(h.state().auto.find(&format!("project.item.{last}")).is_some(), "and in view in the Project panel");
|
||||
assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 3 items"));
|
||||
|
||||
// The same file again: imported again, and that one is selected.
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(file("a.png"))));
|
||||
h.run_steps(2);
|
||||
step_until(&mut h, |h| items(h) == 4);
|
||||
h.run_steps(2);
|
||||
let sel = &h.state().session.state.project_selection;
|
||||
assert_eq!(sel.len(), 1);
|
||||
assert_eq!(h.state().session.project.item(sel[0]).unwrap().name, "a.png");
|
||||
assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 1 item"));
|
||||
}
|
||||
|
||||
/// The automation id of the Importing card on screen.
|
||||
fn card_id(h: &Harness<'_, EffectcraftApp>) -> String {
|
||||
h.state().auto.query("import.job.").first().map(|e| e.id.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user