Files
DarkRoom/ui/dr-ui/src/albums_ui.rs
T
dtourolle 9b580c3720 Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's
incoming row as a named struct rather than an eight-field tuple, which
clippy's type_complexity refused.
2026-09-26 14:13:54 -04:00

785 lines
28 KiB
Rust

//! TRACES: FR-EXP-10 | FR-EXP-6
//! Albums in the window: the sidebar section, the sheet that makes and edits
//! one, and the album the export sheet sends to.
//!
//! The catalog half is [`dr_catalog::albums`]. This half turns an album into
//! somewhere a batch can write — a folder on this device, or a folder on the
//! server reached through the outbox — and records what the batch wrote, so
//! the album can show the originals behind its files.
//!
//! # Why an album and not a destination field
//!
//! Export used to take a path typed into the settings page, or a folder
//! inside the library on the server. The first is how a destination silently
//! becomes a new folder nobody meant; the second put JPEGs inside the tree a
//! scan catalogues, where they came back as photographs of their own. An
//! album is chosen once, by pointing — the platform's folder dialogue, or the
//! server browser here, both able to make a folder — and outside the
//! library. After that, exporting is picking a name.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use dr_catalog::albums::{self, Album, AlbumId, Place};
use dr_types::{ExportTarget, ImageId};
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
use crate::library_ui::LibraryController;
use crate::settings_ui::SettingsController;
use crate::{AlbumRow, Albums, AppWindow, Collections, ExportOptions};
/// `collection-selected` while an album, rather than a collection, the whole
/// library or the trash, scopes the grid. The sidebar has one selection; the
/// album itself is in `Albums.selected`.
pub const ALBUM_SCOPE: i32 = -2;
/// Where a sheet is putting the album's files.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Where {
Device,
Server,
}
/// The sheet, while it is open.
struct Sheet {
/// `None` for a new album.
editing: Option<AlbumId>,
name: String,
at: Where,
/// A folder chosen on this device: a path, or on Android a SAF tree.
device_folder: String,
/// The server folder being browsed, which is the one chosen.
browser: crate::launch::FolderBrowser,
error: String,
confirming_delete: bool,
}
pub struct AlbumsController {
library: Rc<LibraryController>,
settings: Rc<SettingsController>,
/// The live albums, as last read, in the order they are drawn.
rows: RefCell<Vec<Album>>,
/// The album scoping the grid.
selected: Cell<Option<AlbumId>>,
sheet: RefCell<Option<Sheet>>,
}
impl AlbumsController {
pub fn new(library: Rc<LibraryController>, settings: Rc<SettingsController>) -> Rc<Self> {
Rc::new(Self {
library,
settings,
rows: RefCell::new(Vec::new()),
selected: Cell::new(None),
sheet: RefCell::new(None),
})
}
/// The album scoping the grid, if one is.
pub fn selected(&self) -> Option<AlbumId> {
self.selected.get()
}
/// Stop scoping the grid to an album — something else in the sidebar was
/// chosen.
pub fn deselect(&self, window: &AppWindow) {
if self.selected.replace(None).is_some() {
window.global::<Albums>().set_selected(0);
}
}
/// Where the export sheet's album writes, from this device: the album,
/// and the target and folder a batch understands.
///
/// Refused, with a sentence to show, when no album is chosen, when it is
/// gone, or when its folder is local to another device.
pub fn export_destination(&self) -> Result<(AlbumId, ExportTarget, String), String> {
let uuid = self.settings.snapshot().export.album;
if uuid.is_empty() {
return Err("Choose an album to export to (Ctrl+E), or make one.".into());
}
let catalog = self.library.catalog();
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
return Err("Open a library before exporting to an album.".into());
};
let conn = cat.connection();
let album = albums::id_for_uuid(conn, &uuid)
.ok()
.flatten()
.and_then(|id| albums::get(conn, id).ok().flatten())
.ok_or("The album exports went to is gone. Choose another (Ctrl+E).")?;
match album.place {
Some(Place::Local(folder)) => Ok((album.id, ExportTarget::Device, folder)),
// Spelled from `/`: relative to the account, not the library —
// see `export::Pending::remote_dir`.
Some(Place::Server(path)) => Ok((album.id, ExportTarget::Remote, format!("/{path}"))),
None => Err(format!(
"“{}” has no folder on this device yet. Choose one: open the album from the sidebar.",
album.name
)),
}
}
/// Record what a batch wrote into an album, and redraw what counts it.
pub fn record(&self, window: &AppWindow, album: AlbumId, files: Vec<(ImageId, String)>) {
{
let catalog = self.library.catalog();
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
if let Err(e) = albums::record_exports(cat.connection(), album, &files) {
log::warn!(
"recording {} exports in album {}: {e}",
files.len(),
album.0
);
return;
}
}
refresh(window, self);
// The grid is showing this album: it has just gained photographs.
if self.selected.get() == Some(album) {
crate::library_ui::reload(window, &self.library);
}
}
}
/// Re-read the albums and redraw everything that shows them: the sidebar
/// section and the export sheet's choice.
pub fn refresh(window: &AppWindow, ctl: &AlbumsController) {
let catalog = ctl.library.catalog();
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
refresh_from(window, ctl, cat);
}
/// [`refresh`], with the catalog already in hand — for the callers that hold
/// its borrow already.
pub fn refresh_from(window: &AppWindow, ctl: &AlbumsController, cat: &dr_catalog::Catalog) {
adopt_old_destination(ctl, cat);
match albums::list(cat.connection()) {
Ok(rows) => {
// An album deleted elsewhere, and merged away, cannot stay the
// scope: the grid would be narrowed to nothing under a name that
// is no longer in the list.
if let Some(id) = ctl.selected.get() {
if !rows.iter().any(|a| a.id == id) {
ctl.selected.set(None);
}
}
*ctl.rows.borrow_mut() = rows;
}
Err(e) => log::warn!("reading albums: {e}"),
}
render(window, ctl);
}
/// A destination folder set before albums existed becomes the first album,
/// once, so upgrading does not lose where exports were going.
///
/// Only a folder on this device. A server destination was inside the library,
/// which is exactly where an album may not be.
fn adopt_old_destination(ctl: &AlbumsController, cat: &dr_catalog::Catalog) {
let export = ctl.settings.snapshot().export;
if !export.album.is_empty()
|| export.target != ExportTarget::Device
|| export.destination.trim().is_empty()
{
return;
}
let conn = cat.connection();
if !albums::list(conn).map(|a| a.is_empty()).unwrap_or(false) {
return;
}
let place = Place::Local(export.destination.trim().to_string());
match albums::create(conn, "Exports", &place).and_then(|id| albums::get(conn, id)) {
Ok(Some(album)) => {
log::info!(
"the export folder {} is now the album “Exports”",
export.destination
);
ctl.settings.edit(|s| s.export.album = album.uuid);
}
Ok(None) => {}
Err(e) => log::warn!("making an album of the old export folder: {e}"),
}
}
fn render(window: &AppWindow, ctl: &AlbumsController) {
let rows = ctl.rows.borrow();
let sidebar: Vec<AlbumRow> = rows
.iter()
.map(|a| AlbumRow {
id: a.id.0 as i32,
name: a.name.as_str().into(),
count: a.sources as i32,
detail: describe(a.place.as_ref()).into(),
homeless: a.place.is_none(),
})
.collect();
let albums = window.global::<Albums>();
albums.set_rows(ModelRc::new(VecModel::from(sidebar)));
albums.set_selected(ctl.selected.get().map(|a| a.0 as i32).unwrap_or(0));
let chosen = ctl.settings.snapshot().export.album;
let index = rows.iter().position(|a| a.uuid == chosen);
let options = window.global::<ExportOptions>();
options.set_album_labels(ModelRc::new(VecModel::from(
rows.iter()
.map(|a| SharedString::from(a.name.as_str()))
.collect::<Vec<_>>(),
)));
options.set_album_selected(index.map(|i| i as i32).unwrap_or(-1));
options.set_album_detail(
match index {
Some(i) => describe(rows[i].place.as_ref()),
None if rows.is_empty() => String::new(),
None => "No album chosen: exports are refused until one is.".into(),
}
.into(),
);
drop(rows);
crate::refresh_export_label(window);
}
/// Where an album's files go, as the sidebar and the export sheet say it.
fn describe(place: Option<&Place>) -> String {
match place {
Some(Place::Local(folder)) => format!("On this device · {}", display_folder(folder)),
Some(Place::Server(path)) => format!("On the server · /{path}"),
None => "No folder on this device yet".into(),
}
}
/// A local folder as a person reads it. A SAF tree URI is not a path, and
/// its last segment — `primary:Pictures/Web` — is the part worth showing.
fn display_folder(folder: &str) -> String {
if folder.starts_with("content://") {
let tail = folder.rsplit('/').next().unwrap_or(folder);
let decoded = tail
.replace("%3A", ":")
.replace("%2F", "/")
.replace("%20", " ");
return decoded
.split_once(':')
.map(|(_, p)| p.to_string())
.unwrap_or(decoded);
}
folder.to_string()
}
fn render_sheet(window: &AppWindow, ctl: &AlbumsController) {
let albums = window.global::<Albums>();
let sheet = ctl.sheet.borrow();
let Some(s) = sheet.as_ref() else {
albums.set_sheet_open(false);
return;
};
let server = ctl
.library
.credentials()
.is_some_and(|c| !c.account.login.is_empty());
let refusal = if s.at == Where::Server {
refusal(&s.browser.path, ctl)
} else {
String::new()
};
let can_save = !s.name.trim().is_empty()
&& match s.at {
Where::Device => !s.device_folder.is_empty(),
Where::Server => !s.browser.loading && refusal.is_empty(),
};
albums.set_sheet_open(true);
albums.set_sheet_editing(s.editing.is_some());
albums.set_sheet_name(s.name.as_str().into());
albums.set_server_available(server);
albums.set_sheet_where(if s.at == Where::Server { 1 } else { 0 });
albums.set_sheet_device_folder(display_folder(&s.device_folder).into());
albums.set_sheet_error(s.error.as_str().into());
albums.set_sheet_confirming_delete(s.confirming_delete);
albums.set_sheet_can_save(can_save);
albums.set_browse_path(s.browser.path.as_str().into());
albums.set_browse_entries(ModelRc::new(VecModel::from(
s.browser
.entries
.iter()
.map(|e| SharedString::from(e.as_str()))
.collect::<Vec<_>>(),
)));
albums.set_browse_loading(s.browser.loading);
albums.set_browse_at_root(s.browser.parent_path().is_none());
albums.set_browse_refusal(refusal.into());
}
/// Why a server folder cannot hold an album, or empty when it can.
///
/// Inside the library is refused: the next scan would catalogue every JPEG
/// exported there as a photograph, beside the RAW it was made from.
fn refusal(path: &str, ctl: &AlbumsController) -> String {
let Some(conn) = ctl.library.credentials() else {
return "Sign in to the library's server to choose a folder there.".into();
};
let root = conn.account.root.trim_matches('/');
if inside(path, root) {
return if root.is_empty() {
"The whole account is the library here, so every folder on it is inside the library. Put this album on this device instead.".into()
} else {
format!("This is inside the library (/{root}), where exported files would be catalogued as photographs. Choose a folder outside it.")
};
}
String::new()
}
/// Whether `path` is `root` or beneath it. An empty root is the whole account.
fn inside(path: &str, root: &str) -> bool {
let path = path.trim_matches('/');
root.is_empty() || path == root || path.starts_with(&format!("{root}/"))
}
/// List the sheet's server folder, and redraw when the answer comes.
fn browse(weak: slint::Weak<AppWindow>, ctl: Rc<AlbumsController>, path: String) {
let Some(conn) = ctl.library.credentials() else {
return;
};
if let Some(s) = ctl.sheet.borrow_mut().as_mut() {
s.browser.path = path.clone();
s.browser.entries.clear();
s.browser.loading = true;
s.error.clear();
}
let arrived = {
let ctl = ctl.clone();
let weak = weak.clone();
let path = path.clone();
move |result: Result<Vec<String>, String>| settle(&weak, &ctl, &path, result)
};
crate::remote_folders::list(conn, path, arrived);
if let Some(w) = weak.upgrade() {
render_sheet(&w, &ctl);
}
}
/// A listing arrived for `path`. Ignored if the sheet has moved on: two quick
/// clicks must not leave the second folder showing the first one's children.
fn settle(
weak: &slint::Weak<AppWindow>,
ctl: &Rc<AlbumsController>,
path: &str,
result: Result<Vec<String>, String>,
) {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = ctl.sheet.borrow_mut().as_mut() {
if s.browser.path != path {
return;
}
s.browser.loading = false;
match result {
Ok(dirs) => s.browser.entries = dirs,
// The browser stays where it was: a listing that failed is not a
// reason to discard where the user had navigated to.
Err(e) => s.error = format!("Could not list folders: {e}"),
}
}
render_sheet(&w, ctl);
}
/// Open the sheet — on a new album, or on `editing`.
fn open_sheet(window: &AppWindow, ctl: &Rc<AlbumsController>, editing: Option<AlbumId>) {
let album = editing.and_then(|id| ctl.rows.borrow().iter().find(|a| a.id == id).cloned());
let server = ctl
.library
.credentials()
.is_some_and(|c| !c.account.login.is_empty());
let (at, device_folder, server_path) = match album.as_ref().and_then(|a| a.place.clone()) {
Some(Place::Local(f)) => (Where::Device, f, String::new()),
Some(Place::Server(p)) => (Where::Server, String::new(), p),
None => (Where::Device, String::new(), String::new()),
};
*ctl.sheet.borrow_mut() = Some(Sheet {
editing,
name: album.as_ref().map(|a| a.name.clone()).unwrap_or_default(),
at: if server { at } else { Where::Device },
device_folder,
browser: crate::launch::FolderBrowser {
path: server_path.clone(),
..Default::default()
},
error: String::new(),
confirming_delete: false,
});
if server && at == Where::Server {
browse(window.as_weak(), ctl.clone(), server_path);
}
render_sheet(window, ctl);
}
/// Make or update the album the sheet describes.
fn save(window: &AppWindow, ctl: &Rc<AlbumsController>) {
let (editing, name, place) = {
let sheet = ctl.sheet.borrow();
let Some(s) = sheet.as_ref() else { return };
let place = match s.at {
Where::Device => Place::Local(s.device_folder.clone()),
Where::Server => Place::Server(s.browser.path.trim_matches('/').to_string()),
};
(s.editing, s.name.clone(), place)
};
let outcome = {
let catalog = ctl.library.catalog();
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
let conn = cat.connection();
match editing {
Some(id) => albums::rename(conn, id, &name).and_then(|()| {
let current = albums::get(conn, id)?.and_then(|a| a.place);
if current.as_ref() != Some(&place) {
albums::set_place(conn, id, &place)?;
}
Ok(id)
}),
None => albums::create(conn, &name, &place),
}
.and_then(|id| Ok((id, albums::get(conn, id)?)))
};
match outcome {
Ok((_, album)) => {
// A new album is the one the next export goes to: it was made to
// be exported into, often from the export sheet itself.
if editing.is_none() {
if let Some(album) = album.as_ref() {
ctl.settings.edit(|s| s.export.album = album.uuid.clone());
}
}
*ctl.sheet.borrow_mut() = None;
refresh(window, ctl);
render_sheet(window, ctl);
// The grid's title is the album's name while it is open, and
// the name has just changed.
if let (Some(id), Some(album)) = (editing, album) {
if ctl.selected.get() == Some(id) {
window
.global::<Collections>()
.set_collection_scope_label(album.name.into());
}
}
}
Err(e) => {
if let Some(s) = ctl.sheet.borrow_mut().as_mut() {
s.error = e.to_string();
}
render_sheet(window, ctl);
}
}
}
/// Ask for a folder on this device, and put it in the sheet.
fn choose_device_folder(window: &AppWindow, ctl: &Rc<AlbumsController>) {
let start = ctl
.sheet
.borrow()
.as_ref()
.map(|s| s.device_folder.clone())
.unwrap_or_default();
let weak = window.as_weak();
let ctl = ctl.clone();
let chosen = move |folder: String| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = ctl.sheet.borrow_mut().as_mut() {
s.device_folder = folder;
s.error.clear();
}
render_sheet(&w, &ctl);
};
// Android has no filesystem dialogue; its folder picker hands back a
// tree the app is granted, which is what an export there writes into.
#[cfg(target_os = "android")]
{
let _ = (window, start);
crate::saf::pick_tree(chosen);
}
#[cfg(not(target_os = "android"))]
crate::folder_dialog::ask(
window,
"Folder for this album",
crate::folder_dialog::Pick::Folder,
Some(&start),
move |path| chosen(path.to_string_lossy().into_owned()),
);
}
pub fn wire(
window: &AppWindow,
ctl: Rc<AlbumsController>,
collections: Rc<crate::collections_ui::CollectionsController>,
) {
let albums = window.global::<Albums>();
// --- the sidebar --------------------------------------------------------
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_select(move |id| {
let Some(w) = weak.upgrade() else { return };
let id = AlbumId(id.max(0) as u64);
let name = ctl
.rows
.borrow()
.iter()
.find(|a| a.id == id)
.map(|a| a.name.clone())
.unwrap_or_default();
ctl.selected.set(Some(id));
collections.leave_for_album();
w.set_collection_selected(ALBUM_SCOPE);
w.global::<Collections>()
.set_collection_scope_label(name.into());
w.global::<Albums>().set_selected(id.0 as i32);
ctl.library.set_album(Some(id));
crate::library_ui::reload(&w, &ctl.library);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_create(move || {
let Some(w) = weak.upgrade() else { return };
open_sheet(&w, &ctl, None);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_edit(move |id| {
let Some(w) = weak.upgrade() else { return };
open_sheet(&w, &ctl, Some(AlbumId(id.max(0) as u64)));
});
}
// --- the sheet ------------------------------------------------------------
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_sheet_name_edited(move |text| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = ctl.sheet.borrow_mut().as_mut() {
s.name = text.to_string();
}
render_sheet(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_sheet_where_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
let at = if i == 1 { Where::Server } else { Where::Device };
let start = {
let mut sheet = ctl.sheet.borrow_mut();
let Some(s) = sheet.as_mut() else { return };
s.at = at;
s.error.clear();
(at == Where::Server && s.browser.entries.is_empty() && !s.browser.loading)
.then(|| s.browser.path.clone())
};
if let Some(path) = start {
browse(w.as_weak(), ctl.clone(), path);
}
render_sheet(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_sheet_choose_device_folder(move || {
let Some(w) = weak.upgrade() else { return };
choose_device_folder(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_sheet_save(move || {
let Some(w) = weak.upgrade() else { return };
save(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_sheet_cancel(move || {
let Some(w) = weak.upgrade() else { return };
*ctl.sheet.borrow_mut() = None;
render_sheet(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_sheet_delete(move || {
let Some(w) = weak.upgrade() else { return };
let id = {
let mut sheet = ctl.sheet.borrow_mut();
let Some(s) = sheet.as_mut() else { return };
let Some(id) = s.editing else { return };
// Asked twice: the second press is the one that deletes.
if !s.confirming_delete {
s.confirming_delete = true;
drop(sheet);
render_sheet(&w, &ctl);
return;
}
id
};
let uuid = ctl
.rows
.borrow()
.iter()
.find(|a| a.id == id)
.map(|a| a.uuid.clone());
let result = {
let catalog = ctl.library.catalog();
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
albums::delete(cat.connection(), id)
};
if let Err(e) = result {
if let Some(s) = ctl.sheet.borrow_mut().as_mut() {
s.error = e.to_string();
}
render_sheet(&w, &ctl);
return;
}
if uuid.is_some_and(|u| u == ctl.settings.snapshot().export.album) {
ctl.settings.edit(|s| s.export.album.clear());
}
*ctl.sheet.borrow_mut() = None;
render_sheet(&w, &ctl);
// The grid was showing the album just deleted: back to the whole
// library, the way deleting the scoped collection does.
if ctl.selected.get() == Some(id) {
w.invoke_collection_select(0);
}
refresh(&w, &ctl);
});
}
// --- the server browser ---------------------------------------------------
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_browse_into(move |name| {
let path = ctl
.sheet
.borrow()
.as_ref()
.map(|s| s.browser.child_path(&name));
if let Some(path) = path {
browse(weak.clone(), ctl.clone(), path);
}
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_browse_up(move || {
let path = ctl
.sheet
.borrow()
.as_ref()
.and_then(|s| s.browser.parent_path());
if let Some(path) = path {
browse(weak.clone(), ctl.clone(), path);
}
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
albums.on_browse_make(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(conn) = ctl.library.credentials() else {
return;
};
let parent = {
let mut sheet = ctl.sheet.borrow_mut();
let Some(s) = sheet.as_mut() else { return };
s.browser.loading = true;
s.error.clear();
s.browser.path.clone()
};
render_sheet(&w, &ctl);
// Made, then walked into: a folder somebody has just named is the
// one they mean to use.
let name = name.trim().to_string();
let (weak, ctl2) = (weak.clone(), ctl.clone());
let child = name.clone();
crate::remote_folders::make(conn, parent.clone(), name, move |result| match result {
Ok(_) => {
let path = if parent.is_empty() {
child
} else {
format!("{parent}/{child}")
};
browse(weak, ctl2, path);
}
Err(e) => settle(&weak, &ctl2, &parent, Err(e)),
});
});
}
// --- the export sheet's choice --------------------------------------------
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.global::<ExportOptions>().on_album_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
let uuid = ctl
.rows
.borrow()
.get(i.max(0) as usize)
.map(|a| a.uuid.clone());
if let Some(uuid) = uuid {
ctl.settings.edit(|s| s.export.album = uuid);
}
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.global::<ExportOptions>().on_album_new(move || {
let Some(w) = weak.upgrade() else { return };
open_sheet(&w, &ctl, None);
});
}
}
#[cfg(test)]
mod tests {
use super::{display_folder, inside};
#[test]
fn a_folder_inside_the_library_is_refused_and_one_beside_it_is_not() {
assert!(inside("Photos", "Photos"));
assert!(inside("Photos/2026/Web", "Photos"));
assert!(!inside("PhotosWeb", "Photos"), "a sibling sharing a prefix");
assert!(!inside("Shared/Web", "Photos"));
assert!(inside("Anything", ""), "an empty root is the whole account");
}
#[test]
fn a_saf_tree_reads_as_its_folder() {
assert_eq!(
display_folder(
"content://com.android.externalstorage.documents/tree/primary%3APictures%2FWeb"
),
"Pictures/Web"
);
assert_eq!(display_folder("/home/me/Web"), "/home/me/Web");
}
}