add sync action
This commit is contained in:
parent
31501d65d8
commit
23f912d845
@ -50,7 +50,7 @@ pub fn add_channel(ctx: &TrsEnv, args: &AddChannelArgs) -> Result<RssChannelD, T
|
|||||||
.ignore_invalid_encoding_declarations(true)
|
.ignore_invalid_encoding_declarations(true)
|
||||||
.create_reader(&bytes[..]);
|
.create_reader(&bytes[..]);
|
||||||
let channel = parser::parse_rss_channel(xml_source_stream)?;
|
let channel = parser::parse_rss_channel(xml_source_stream)?;
|
||||||
ctx.db.add_channel(&channel)
|
ctx.db.add_channel(&args.link, &channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_channels(ctx: &TrsEnv, args: &ListChannelArgs) -> Result<Vec<RssChannelD>, TrsError> {
|
pub fn list_channels(ctx: &TrsEnv, args: &ListChannelArgs) -> Result<Vec<RssChannelD>, TrsError> {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ pub mod ui;
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let args = argh::from_env::<TrsArgs>();
|
let args = argh::from_env::<TrsArgs>();
|
||||||
let db_name = "test3";
|
let db_name = "test4";
|
||||||
match args.sub_command {
|
match args.sub_command {
|
||||||
TrsSubCommand::AddChannel(args) => {
|
TrsSubCommand::AddChannel(args) => {
|
||||||
let mut ctx = TrsEnv::new(db_name)?;
|
let mut ctx = TrsEnv::new(db_name)?;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const SCHEMA_CHANNELS: &'static str = "CREATE TABLE IF NOT EXISTS Channels ( \
|
|||||||
id INTEGER PRIMARY KEY, \
|
id INTEGER PRIMARY KEY, \
|
||||||
name TEXT NOT NULL, \
|
name TEXT NOT NULL, \
|
||||||
link TEXT NOT NULL UNIQUE, \
|
link TEXT NOT NULL UNIQUE, \
|
||||||
|
feed_link TEXT NOT NULL UNIQUE, \
|
||||||
description TEXT, \
|
description TEXT, \
|
||||||
last_update INTEGER\
|
last_update INTEGER\
|
||||||
)";
|
)";
|
||||||
@ -28,14 +29,14 @@ const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \
|
|||||||
FOREIGN KEY(channel_id) REFERENCES Channels(id) ON DELETE CASCADE \
|
FOREIGN KEY(channel_id) REFERENCES Channels(id) ON DELETE CASCADE \
|
||||||
)";
|
)";
|
||||||
|
|
||||||
const ADD_CHANNEL: &'static str = "INSERT INTO Channels (name, link, description, last_update) \
|
const ADD_CHANNEL: &'static str = "INSERT INTO Channels (name, link, feed_link, description, last_update) \
|
||||||
VALUES (?1, ?2, ?3, ?4)\
|
VALUES (?1, ?2, ?3, ?4, ?5)\
|
||||||
ON CONFLICT(link) DO UPDATE SET name=?1, description=?3, last_update=?4";
|
ON CONFLICT(link) DO UPDATE SET name=?1, description=?4, last_update=?5";
|
||||||
const REMOVE_CHANNEL: &'static str = "DELETE FROM Channels WHERE id = ?1";
|
const REMOVE_CHANNEL: &'static str = "DELETE FROM Channels WHERE id = ?1";
|
||||||
const LIST_CHANNELS: &'static str =
|
const LIST_CHANNELS: &'static str =
|
||||||
"SELECT id, name, link, description, last_update FROM Channels LIMIT ?1";
|
"SELECT id, name, link, feed_link, description, last_update FROM Channels order by last_update DESC LIMIT ?1";
|
||||||
const GET_CHANNEL: &'static str =
|
const GET_CHANNEL: &'static str =
|
||||||
"SELECT id, name, link, description, last_update FROM Channels WHERE link = ?1";
|
"SELECT id, name, link, feed_link, description, last_update FROM Channels WHERE link = ?1";
|
||||||
|
|
||||||
const ADD_ARTICLE: &'static str =
|
const ADD_ARTICLE: &'static str =
|
||||||
"INSERT INTO Articles (channel_id, title, description, link, pub_date, last_update, unread) \
|
"INSERT INTO Articles (channel_id, title, description, link, pub_date, last_update, unread) \
|
||||||
@ -49,7 +50,7 @@ const GET_ARTICLE: &'static str =
|
|||||||
"SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles WHERE link = ?1";
|
"SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles WHERE link = ?1";
|
||||||
|
|
||||||
const LIST_ARTICLES: &'static str =
|
const LIST_ARTICLES: &'static str =
|
||||||
"SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles";
|
"SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles order by last_update DESC";
|
||||||
|
|
||||||
const MARK_ARTICLE_READ: &'static str = "UPDATE Articles SET unread = FALSE WHERE id = ?1";
|
const MARK_ARTICLE_READ: &'static str = "UPDATE Articles SET unread = FALSE WHERE id = ?1";
|
||||||
|
|
||||||
@ -63,6 +64,7 @@ pub struct RssChannelD {
|
|||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub link: String,
|
pub link: String,
|
||||||
|
pub feed_link: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub last_update: OffsetDateTime,
|
pub last_update: OffsetDateTime,
|
||||||
pub articles: Vec<RssArticleD>,
|
pub articles: Vec<RssArticleD>,
|
||||||
@ -126,13 +128,14 @@ impl Db {
|
|||||||
Ok(channel)
|
Ok(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_channel(&self, channel: &RssChannel) -> Result<RssChannelD> {
|
pub fn add_channel(&self, feed_link: impl AsRef<str>, channel: &RssChannel) -> Result<RssChannelD> {
|
||||||
self.connection
|
self.connection
|
||||||
.execute(
|
.execute(
|
||||||
ADD_CHANNEL,
|
ADD_CHANNEL,
|
||||||
(
|
(
|
||||||
&channel.title,
|
&channel.title,
|
||||||
&channel.link,
|
&channel.link,
|
||||||
|
feed_link.as_ref(),
|
||||||
&channel.description,
|
&channel.description,
|
||||||
OffsetDateTime::now_utc().unix_timestamp(),
|
OffsetDateTime::now_utc().unix_timestamp(),
|
||||||
),
|
),
|
||||||
@ -256,7 +259,8 @@ impl Db {
|
|||||||
row.get(1)?,
|
row.get(1)?,
|
||||||
row.get(2)?,
|
row.get(2)?,
|
||||||
row.get(3)?,
|
row.get(3)?,
|
||||||
Db::read_datetime(4, &row)?,
|
row.get(4)?,
|
||||||
|
Db::read_datetime(5, &row)?,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@ -295,6 +299,7 @@ impl RssChannelD {
|
|||||||
id: i64,
|
id: i64,
|
||||||
title: String,
|
title: String,
|
||||||
link: String,
|
link: String,
|
||||||
|
feed_link: String,
|
||||||
description: String,
|
description: String,
|
||||||
last_update: OffsetDateTime,
|
last_update: OffsetDateTime,
|
||||||
articles: Vec<RssArticleD>,
|
articles: Vec<RssArticleD>,
|
||||||
@ -303,6 +308,7 @@ impl RssChannelD {
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
link,
|
link,
|
||||||
|
feed_link,
|
||||||
description,
|
description,
|
||||||
last_update,
|
last_update,
|
||||||
articles,
|
articles,
|
||||||
|
|||||||
43
src/ui.rs
43
src/ui.rs
@ -1,9 +1,9 @@
|
|||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod articles;
|
pub mod articles;
|
||||||
|
pub mod backend;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod controls;
|
pub mod controls;
|
||||||
pub mod debug;
|
pub mod debug;
|
||||||
pub mod executor;
|
|
||||||
pub mod title;
|
pub mod title;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
@ -22,7 +22,6 @@ use channels::ChannelsWidget;
|
|||||||
use controls::ControlsWidget;
|
use controls::ControlsWidget;
|
||||||
use crossterm::event::{self, KeyEventKind};
|
use crossterm::event::{self, KeyEventKind};
|
||||||
use debug::DebugWidget;
|
use debug::DebugWidget;
|
||||||
use executor::UiCommandExecutor;
|
|
||||||
use futures::{FutureExt, StreamExt};
|
use futures::{FutureExt, StreamExt};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
prelude::*,
|
prelude::*,
|
||||||
@ -66,6 +65,7 @@ pub enum UiAction {
|
|||||||
ShowAddChannelUi,
|
ShowAddChannelUi,
|
||||||
RemoveChannel,
|
RemoveChannel,
|
||||||
ToggleReadStatus,
|
ToggleReadStatus,
|
||||||
|
SyncChannel,
|
||||||
Exit,
|
Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,11 +86,24 @@ pub enum UiCommandDispatchActions {
|
|||||||
ListChannels(args::ListChannelArgs),
|
ListChannels(args::ListChannelArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// APP
|
||||||
|
/// - Listen Event
|
||||||
|
/// - Publish UiCommandDispatchActions
|
||||||
|
///
|
||||||
|
/// BACKEND
|
||||||
|
/// - Listen UiCommandDispatchActions
|
||||||
|
/// - Publish BackendEvent
|
||||||
|
///
|
||||||
|
/// EVENT LOOP
|
||||||
|
/// - Listen BackendEvent
|
||||||
|
/// - Listen crossterm::event::Event
|
||||||
|
/// - Publish Event
|
||||||
pub async fn ui(args: &UiArgs, db_name: &str) -> Result<()> {
|
pub async fn ui(args: &UiArgs, db_name: &str) -> Result<()> {
|
||||||
let (app_dispatch, app_recv) = channel();
|
let (ui_action_publisher, ui_action_receiver) = channel();
|
||||||
let (executor_dispatch, executor_recv) = tokio::sync::mpsc::unbounded_channel();
|
let (backend_event_publisher, backend_event_receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let event_recv = start_event_loop(executor_recv);
|
let event_receiver = start_event_loop(backend_event_receiver);
|
||||||
let mut terminal = ratatui::init();
|
|
||||||
let mut app_state = AppState {
|
let mut app_state = AppState {
|
||||||
channels: Vec::new(),
|
channels: Vec::new(),
|
||||||
exit: false,
|
exit: false,
|
||||||
@ -102,15 +115,11 @@ pub async fn ui(args: &UiArgs, db_name: &str) -> Result<()> {
|
|||||||
last_action: None,
|
last_action: None,
|
||||||
show_add_channel_ui: false,
|
show_add_channel_ui: false,
|
||||||
add_channel: String::new(),
|
add_channel: String::new(),
|
||||||
dispatcher: app_dispatch,
|
dispatcher: ui_action_publisher,
|
||||||
receiver: event_recv,
|
receiver: event_receiver,
|
||||||
};
|
};
|
||||||
|
|
||||||
let db_name = db_name.to_string();
|
start_backend(db_name, ui_action_receiver, backend_event_publisher);
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut executor = UiCommandExecutor::new(app_recv, executor_dispatch);
|
|
||||||
executor.run(db_name);
|
|
||||||
});
|
|
||||||
|
|
||||||
app_state
|
app_state
|
||||||
.dispatcher
|
.dispatcher
|
||||||
@ -119,6 +128,7 @@ pub async fn ui(args: &UiArgs, db_name: &str) -> Result<()> {
|
|||||||
))
|
))
|
||||||
.map_err(|e| TrsError::Error(format!("Unable to send initial app: {}", e)))?;
|
.map_err(|e| TrsError::Error(format!("Unable to send initial app: {}", e)))?;
|
||||||
|
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
loop {
|
loop {
|
||||||
draw(&app_state, &mut terminal)?;
|
draw(&app_state, &mut terminal)?;
|
||||||
handle_events(&mut app_state).await?;
|
handle_events(&mut app_state).await?;
|
||||||
@ -132,6 +142,13 @@ pub async fn ui(args: &UiArgs, db_name: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_backend(db_name: &str, app_recv: std::sync::mpsc::Receiver<UiCommandDispatchActions>, executor_dispatch: tokio::sync::mpsc::UnboundedSender<BackendEvent>) {
|
||||||
|
let db_name = db_name.to_string();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
backend::start(db_name, app_recv, executor_dispatch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn start_event_loop(
|
fn start_event_loop(
|
||||||
mut executor_recv: UnboundedReceiver<BackendEvent>,
|
mut executor_recv: UnboundedReceiver<BackendEvent>,
|
||||||
) -> UnboundedReceiver<Event> {
|
) -> UnboundedReceiver<Event> {
|
||||||
|
|||||||
@ -82,6 +82,18 @@ pub fn handle_action(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UiAction::SyncChannel => {
|
||||||
|
let channel = get_highlighted_channel(app_state);
|
||||||
|
if let Some(channel) = channel {
|
||||||
|
let sync_channel_args = args::AddChannelArgs {
|
||||||
|
link: channel.feed_link.clone(),
|
||||||
|
};
|
||||||
|
app_state
|
||||||
|
.dispatcher
|
||||||
|
.send(UiCommandDispatchActions::AddChannel(sync_channel_args))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/ui/backend.rs
Normal file
66
src/ui/backend.rs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
|
use crate::{commands::TrsEnv, ui::BackendEvent};
|
||||||
|
|
||||||
|
use super::UiCommandDispatchActions;
|
||||||
|
|
||||||
|
// This one will have to run on the same thread as this manages the sqlite connection
|
||||||
|
pub fn start(
|
||||||
|
db_name: String,
|
||||||
|
cmd_recv: Receiver<UiCommandDispatchActions>,
|
||||||
|
backend_dispatch: UnboundedSender<BackendEvent>,
|
||||||
|
) -> () {
|
||||||
|
let ctx = TrsEnv::new(db_name.as_str()).unwrap();
|
||||||
|
loop {
|
||||||
|
let action = cmd_recv.recv();
|
||||||
|
let Ok(action) = action else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
match action {
|
||||||
|
UiCommandDispatchActions::AddChannel(args) => {
|
||||||
|
if let Ok(_) = crate::commands::add_channel(&ctx, &args) {
|
||||||
|
send_new_state_default(&ctx, &backend_dispatch);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
UiCommandDispatchActions::RemoveChannel(args) => {
|
||||||
|
if let Ok(_) = crate::commands::remove_channel(&ctx, &args) {
|
||||||
|
send_new_state_default(&ctx, &backend_dispatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiCommandDispatchActions::MarkArticleRead(args) => {
|
||||||
|
if let Ok(_) = crate::commands::mark_read(&ctx, &args) {
|
||||||
|
send_new_state_default(&ctx, &backend_dispatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiCommandDispatchActions::ListChannels(args) => {
|
||||||
|
send_new_state(&ctx, args, &backend_dispatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_new_state_default(
|
||||||
|
ctx: &crate::commands::TrsEnv,
|
||||||
|
dispatcher: &UnboundedSender<BackendEvent>,
|
||||||
|
) {
|
||||||
|
send_new_state(
|
||||||
|
ctx,
|
||||||
|
crate::args::ListChannelArgs { limit: None },
|
||||||
|
dispatcher,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_new_state(
|
||||||
|
ctx: &crate::commands::TrsEnv,
|
||||||
|
args: crate::args::ListChannelArgs,
|
||||||
|
dispatcher: &UnboundedSender<BackendEvent>,
|
||||||
|
) {
|
||||||
|
if let Ok(channels) = crate::commands::list_channels(ctx, &args) {
|
||||||
|
dispatcher
|
||||||
|
.send(BackendEvent::ReloadState(channels))
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -57,11 +57,12 @@ pub fn parse_ui_action(raw_event: Event) -> UiAction {
|
|||||||
KeyCode::Char('h') => UiAction::FocusPaneLeft,
|
KeyCode::Char('h') => UiAction::FocusPaneLeft,
|
||||||
KeyCode::Char('k') => UiAction::FocusEntryUp,
|
KeyCode::Char('k') => UiAction::FocusEntryUp,
|
||||||
KeyCode::Char('j') => UiAction::FocusEntryDown,
|
KeyCode::Char('j') => UiAction::FocusEntryDown,
|
||||||
KeyCode::Char('d') => UiAction::ToggleDebug,
|
KeyCode::Char('n') => UiAction::ToggleDebug,
|
||||||
KeyCode::Char('q') => UiAction::Exit,
|
KeyCode::Char('q') => UiAction::Exit,
|
||||||
KeyCode::Char('a') => UiAction::ShowAddChannelUi,
|
KeyCode::Char('a') => UiAction::ShowAddChannelUi,
|
||||||
KeyCode::Char('r') => UiAction::RemoveChannel,
|
KeyCode::Char('d') => UiAction::RemoveChannel,
|
||||||
KeyCode::Char('u') => UiAction::ToggleReadStatus,
|
KeyCode::Char('r') => UiAction::ToggleReadStatus,
|
||||||
|
KeyCode::Char('s') => UiAction::SyncChannel,
|
||||||
KeyCode::Enter => UiAction::OpenArticle,
|
KeyCode::Enter => UiAction::OpenArticle,
|
||||||
_ => UiAction::None,
|
_ => UiAction::None,
|
||||||
};
|
};
|
||||||
@ -97,9 +98,11 @@ impl Widget for ControlsWidget {
|
|||||||
description!(" to switch between channels and articles, "),
|
description!(" to switch between channels and articles, "),
|
||||||
control!("a"),
|
control!("a"),
|
||||||
description!(" add a new RSS channel, "),
|
description!(" add a new RSS channel, "),
|
||||||
|
control!("s"),
|
||||||
|
description!(" sync channel, "),
|
||||||
|
control!("d"),
|
||||||
|
description!(" delete an RSS channel, "),
|
||||||
control!("r"),
|
control!("r"),
|
||||||
description!(" remove an RSS channel, "),
|
|
||||||
control!("u"),
|
|
||||||
description!(" toggle read state of article, "),
|
description!(" toggle read state of article, "),
|
||||||
control!("q"),
|
control!("q"),
|
||||||
description!(" to exit"),
|
description!(" to exit"),
|
||||||
|
|||||||
@ -1,72 +0,0 @@
|
|||||||
use std::sync::mpsc::Receiver;
|
|
||||||
|
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
|
||||||
|
|
||||||
use crate::{commands::TrsEnv, ui::BackendEvent};
|
|
||||||
|
|
||||||
use super::UiCommandDispatchActions;
|
|
||||||
|
|
||||||
pub struct UiCommandExecutor {
|
|
||||||
pub app_recv: Receiver<UiCommandDispatchActions>,
|
|
||||||
pub executor_dispatch: UnboundedSender<BackendEvent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UiCommandExecutor {
|
|
||||||
pub fn new(
|
|
||||||
app_recv: Receiver<UiCommandDispatchActions>,
|
|
||||||
executor_dispatch: UnboundedSender<BackendEvent>,
|
|
||||||
) -> Self {
|
|
||||||
UiCommandExecutor {
|
|
||||||
app_recv,
|
|
||||||
executor_dispatch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This one will have to run on the same thread as this manages the sqlite connection
|
|
||||||
pub fn run(&mut self, db_name: String) -> () {
|
|
||||||
let ctx = TrsEnv::new(db_name.as_str()).unwrap();
|
|
||||||
loop {
|
|
||||||
let action = self.app_recv.recv();
|
|
||||||
let Ok(action) = action else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
|
|
||||||
match action {
|
|
||||||
UiCommandDispatchActions::AddChannel(args) => {
|
|
||||||
if let Ok(_) = crate::commands::add_channel(&ctx, &args) {
|
|
||||||
self.send_new_state_default(&ctx);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
UiCommandDispatchActions::RemoveChannel(args) => {
|
|
||||||
if let Ok(_) = crate::commands::remove_channel(&ctx, &args) {
|
|
||||||
self.send_new_state_default(&ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UiCommandDispatchActions::MarkArticleRead(args) => {
|
|
||||||
if let Ok(_) = crate::commands::mark_read(&ctx, &args) {
|
|
||||||
self.send_new_state_default(&ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UiCommandDispatchActions::ListChannels(args) => {
|
|
||||||
self.send_new_state(&ctx, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_new_state_default(&mut self, ctx: &crate::commands::TrsEnv) {
|
|
||||||
self.send_new_state(ctx, crate::args::ListChannelArgs { limit: None });
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_new_state(
|
|
||||||
&mut self,
|
|
||||||
ctx: &crate::commands::TrsEnv,
|
|
||||||
args: crate::args::ListChannelArgs,
|
|
||||||
) {
|
|
||||||
if let Ok(channels) = crate::commands::list_channels(ctx, &args) {
|
|
||||||
self.executor_dispatch
|
|
||||||
.send(BackendEvent::ReloadState(channels))
|
|
||||||
.unwrap_or_default();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user