asonix
29bdf064e9
Clear profile data on suspend Clear comment body on delete Update Unfollow and Unblock operations to only delete apub IDs if present
787 lines
17 KiB
Rust
787 lines
17 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use sled::Db;
|
|
use std::fmt;
|
|
use uuid::Uuid;
|
|
|
|
mod comment;
|
|
mod file;
|
|
mod profile;
|
|
mod react;
|
|
mod report;
|
|
mod submission;
|
|
pub mod view;
|
|
|
|
pub use comment::Store as CommentStore;
|
|
pub use file::Store as FileStore;
|
|
pub use profile::Store as ProfileStore;
|
|
pub use react::Store as ReactStore;
|
|
pub use report::Store as ReportStore;
|
|
pub use submission::Store as SubmissionStore;
|
|
pub use view::Store as ViewStore;
|
|
|
|
#[derive(Clone)]
|
|
pub struct Store {
|
|
pub profiles: profile::Store,
|
|
pub files: file::Store,
|
|
pub submissions: submission::Store,
|
|
pub comments: comment::Store,
|
|
pub reacts: react::Store,
|
|
pub reports: report::Store,
|
|
pub view: view::Store,
|
|
}
|
|
|
|
impl Store {
|
|
pub fn build(db: &Db) -> Result<Store, sled::Error> {
|
|
Ok(Store {
|
|
profiles: profile::Store::build(db)?,
|
|
files: file::Store::build(db)?,
|
|
submissions: submission::Store::build(db)?,
|
|
comments: comment::Store::build(db)?,
|
|
reacts: react::Store::build(db)?,
|
|
reports: report::Store::build(db)?,
|
|
view: view::Store::build(db)?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Report {
|
|
id: Uuid,
|
|
reporter: Uuid,
|
|
reporter_kind: ReporterKind,
|
|
reported_item: Uuid,
|
|
kind: ReportKind,
|
|
note: Option<String>,
|
|
resolved: Option<DateTime<Utc>>,
|
|
resolution: Option<String>,
|
|
forwarded: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl Report {
|
|
pub fn id(&self) -> Uuid {
|
|
self.id
|
|
}
|
|
|
|
pub fn note(&self) -> Option<&str> {
|
|
self.note.as_deref()
|
|
}
|
|
|
|
pub fn reporter_profile(&self) -> Option<Uuid> {
|
|
if matches!(self.reporter_kind, ReporterKind::Profile) {
|
|
Some(self.reporter)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn item(&self) -> Uuid {
|
|
self.reported_item
|
|
}
|
|
|
|
pub fn reporter_server(&self) -> Option<Uuid> {
|
|
if matches!(self.reporter_kind, ReporterKind::Server) {
|
|
Some(self.reporter)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn kind(&self) -> ReportKind {
|
|
self.kind
|
|
}
|
|
|
|
pub fn profile(&self) -> Option<Uuid> {
|
|
if matches!(self.kind, ReportKind::Profile) {
|
|
Some(self.reported_item)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn submission(&self) -> Option<Uuid> {
|
|
if matches!(self.kind, ReportKind::Submission) {
|
|
Some(self.reported_item)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn comment(&self) -> Option<Uuid> {
|
|
if matches!(self.kind, ReportKind::Comment) {
|
|
Some(self.reported_item)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn post(&self) -> Option<Uuid> {
|
|
if matches!(self.kind, ReportKind::Post) {
|
|
Some(self.reported_item)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn resolved(&self) -> Option<DateTime<Utc>> {
|
|
self.resolved
|
|
}
|
|
|
|
pub fn resolution(&self) -> Option<&str> {
|
|
self.resolution.as_deref()
|
|
}
|
|
|
|
pub fn forwarded(&self) -> Option<DateTime<Utc>> {
|
|
self.forwarded
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)]
|
|
pub enum ReportKind {
|
|
Post,
|
|
Comment,
|
|
Submission,
|
|
Profile,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)]
|
|
pub(crate) enum ReporterKind {
|
|
Profile,
|
|
Server,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)]
|
|
enum ReportState {
|
|
Open,
|
|
Resolved,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum OwnerSource {
|
|
Local(Uuid),
|
|
Remote(String),
|
|
}
|
|
|
|
impl OwnerSource {
|
|
pub fn is_local(&self) -> bool {
|
|
matches!(self, OwnerSource::Local(_))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Profile {
|
|
id: Uuid,
|
|
owner_source: OwnerSource,
|
|
handle: String,
|
|
domain: String,
|
|
display_name: Option<String>,
|
|
description: Option<String>,
|
|
icon: Option<Uuid>,
|
|
banner: Option<Uuid>,
|
|
published: DateTime<Utc>,
|
|
login_required: bool,
|
|
suspended: bool,
|
|
}
|
|
|
|
impl Profile {
|
|
pub fn update(&self) -> ProfileChanges {
|
|
ProfileChanges {
|
|
id: self.id,
|
|
display_name: None,
|
|
description: None,
|
|
login_required: None,
|
|
}
|
|
}
|
|
|
|
pub fn update_images(&self) -> ProfileImageChanges {
|
|
ProfileImageChanges {
|
|
id: self.id,
|
|
icon: None,
|
|
banner: None,
|
|
}
|
|
}
|
|
|
|
pub fn id(&self) -> Uuid {
|
|
self.id
|
|
}
|
|
|
|
pub(crate) fn owner_source(&self) -> &OwnerSource {
|
|
&self.owner_source
|
|
}
|
|
|
|
pub fn local_owner(&self) -> Option<Uuid> {
|
|
match self.owner_source {
|
|
OwnerSource::Local(id) => Some(id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn handle(&self) -> &str {
|
|
&self.handle
|
|
}
|
|
|
|
pub fn domain(&self) -> &str {
|
|
&self.domain
|
|
}
|
|
|
|
pub fn display_name(&self) -> Option<&str> {
|
|
self.display_name.as_ref().map(|dn| dn.as_str())
|
|
}
|
|
|
|
pub fn description(&self) -> Option<&str> {
|
|
self.description.as_ref().map(|d| d.as_str())
|
|
}
|
|
|
|
pub fn icon(&self) -> Option<Uuid> {
|
|
self.icon
|
|
}
|
|
|
|
pub fn banner(&self) -> Option<Uuid> {
|
|
self.banner
|
|
}
|
|
|
|
pub fn published(&self) -> DateTime<Utc> {
|
|
self.published
|
|
}
|
|
|
|
pub fn login_required(&self) -> bool {
|
|
self.login_required
|
|
}
|
|
|
|
pub fn is_suspended(&self) -> bool {
|
|
self.suspended
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct PictRsFile {
|
|
key: String,
|
|
token: String,
|
|
width: usize,
|
|
height: usize,
|
|
media_type: mime::Mime,
|
|
}
|
|
|
|
impl PictRsFile {
|
|
pub(crate) fn new(
|
|
key: &str,
|
|
token: &str,
|
|
width: usize,
|
|
height: usize,
|
|
media_type: mime::Mime,
|
|
) -> Self {
|
|
PictRsFile {
|
|
key: key.to_owned(),
|
|
token: token.to_owned(),
|
|
width,
|
|
height,
|
|
media_type,
|
|
}
|
|
}
|
|
|
|
pub fn key(&self) -> &str {
|
|
&self.key
|
|
}
|
|
|
|
pub(crate) fn token(&self) -> &str {
|
|
&self.token
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum FileSource {
|
|
PictRs(PictRsFile),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct File {
|
|
id: Uuid,
|
|
source: FileSource,
|
|
}
|
|
|
|
impl File {
|
|
pub fn id(&self) -> Uuid {
|
|
self.id
|
|
}
|
|
|
|
pub fn source(&self) -> &FileSource {
|
|
&self.source
|
|
}
|
|
|
|
pub fn pictrs(&self) -> Option<&PictRsFile> {
|
|
let FileSource::PictRs(ref file) = self.source;
|
|
Some(file)
|
|
}
|
|
|
|
pub fn pictrs_key(&self) -> Option<&str> {
|
|
self.pictrs().map(|p| p.key())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)]
|
|
pub enum Visibility {
|
|
Public,
|
|
Unlisted,
|
|
Followers,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Submission {
|
|
id: Uuid,
|
|
profile_id: Uuid,
|
|
title: String,
|
|
description: Option<String>,
|
|
files: Vec<Uuid>,
|
|
published: Option<DateTime<Utc>>,
|
|
visibility: Visibility,
|
|
}
|
|
|
|
impl Submission {
|
|
pub fn update(&self) -> SubmissionChanges {
|
|
SubmissionChanges {
|
|
id: self.id,
|
|
title: None,
|
|
description: None,
|
|
published: self.published,
|
|
}
|
|
}
|
|
|
|
pub fn update_files(&self) -> SubmissionFileChanges {
|
|
SubmissionFileChanges {
|
|
id: self.id,
|
|
files: self.files.clone(),
|
|
changed: false,
|
|
}
|
|
}
|
|
|
|
pub fn id(&self) -> Uuid {
|
|
self.id
|
|
}
|
|
|
|
pub fn profile_id(&self) -> Uuid {
|
|
self.profile_id
|
|
}
|
|
|
|
pub fn title(&self) -> &str {
|
|
&self.title
|
|
}
|
|
|
|
pub fn description(&self) -> Option<&str> {
|
|
self.description.as_ref().map(|d| d.as_str())
|
|
}
|
|
|
|
pub fn files(&self) -> &[Uuid] {
|
|
&self.files
|
|
}
|
|
|
|
pub fn published(&self) -> Option<DateTime<Utc>> {
|
|
self.published
|
|
}
|
|
|
|
pub fn visibility(&self) -> Visibility {
|
|
self.visibility
|
|
}
|
|
|
|
pub fn is_public(&self) -> bool {
|
|
matches!(self.visibility, Visibility::Public)
|
|
}
|
|
|
|
pub fn is_unlisted(&self) -> bool {
|
|
matches!(self.visibility, Visibility::Unlisted)
|
|
}
|
|
|
|
pub fn is_followers_only(&self) -> bool {
|
|
matches!(self.visibility, Visibility::Followers)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum Comment {
|
|
Submission(SubmissionComment),
|
|
Reply(ReplyComment),
|
|
}
|
|
|
|
impl Comment {
|
|
pub fn id(&self) -> Uuid {
|
|
match self {
|
|
Comment::Reply(ReplyComment { id, .. }) => *id,
|
|
Comment::Submission(SubmissionComment { id, .. }) => *id,
|
|
}
|
|
}
|
|
|
|
pub fn submission_id(&self) -> Uuid {
|
|
match self {
|
|
Comment::Reply(ReplyComment { submission_id, .. }) => *submission_id,
|
|
Comment::Submission(SubmissionComment { submission_id, .. }) => *submission_id,
|
|
}
|
|
}
|
|
|
|
pub fn profile_id(&self) -> Uuid {
|
|
match self {
|
|
Comment::Reply(ReplyComment { profile_id, .. }) => *profile_id,
|
|
Comment::Submission(SubmissionComment { profile_id, .. }) => *profile_id,
|
|
}
|
|
}
|
|
|
|
pub fn comment_id(&self) -> Option<Uuid> {
|
|
match self {
|
|
Comment::Reply(ReplyComment { comment_id, .. }) => Some(*comment_id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn body(&self) -> &str {
|
|
if !self.deleted() {
|
|
match self {
|
|
Comment::Reply(ReplyComment { body, .. }) => &body,
|
|
Comment::Submission(SubmissionComment { body, .. }) => &body,
|
|
}
|
|
} else {
|
|
"Comment Deleted"
|
|
}
|
|
}
|
|
|
|
pub fn published(&self) -> DateTime<Utc> {
|
|
match self {
|
|
Comment::Reply(ReplyComment { published, .. }) => *published,
|
|
Comment::Submission(SubmissionComment { published, .. }) => *published,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn update(&self) -> CommentChanges {
|
|
match self {
|
|
Comment::Reply(rc) => rc.update(),
|
|
Comment::Submission(sc) => sc.update(),
|
|
}
|
|
}
|
|
|
|
pub fn deleted(&self) -> bool {
|
|
match self {
|
|
Comment::Reply(rc) => rc.deleted,
|
|
Comment::Submission(sc) => sc.deleted,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct SubmissionComment {
|
|
id: Uuid,
|
|
submission_id: Uuid,
|
|
profile_id: Uuid,
|
|
body: String,
|
|
published: DateTime<Utc>,
|
|
deleted: bool,
|
|
}
|
|
|
|
impl SubmissionComment {
|
|
fn update(&self) -> CommentChanges {
|
|
CommentChanges {
|
|
id: self.id,
|
|
body: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ReplyComment {
|
|
id: Uuid,
|
|
submission_id: Uuid,
|
|
profile_id: Uuid,
|
|
comment_id: Uuid,
|
|
body: String,
|
|
published: DateTime<Utc>,
|
|
deleted: bool,
|
|
}
|
|
|
|
impl ReplyComment {
|
|
fn update(&self) -> CommentChanges {
|
|
CommentChanges {
|
|
id: self.id,
|
|
body: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum React {
|
|
Submission(SubmissionReact),
|
|
Reply(ReplyReact),
|
|
}
|
|
|
|
impl React {
|
|
pub(crate) fn id(&self) -> Uuid {
|
|
match self {
|
|
React::Submission(SubmissionReact { id, .. }) => *id,
|
|
React::Reply(ReplyReact { id, .. }) => *id,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn submission_id(&self) -> Uuid {
|
|
match self {
|
|
React::Submission(SubmissionReact { submission_id, .. }) => *submission_id,
|
|
React::Reply(ReplyReact { submission_id, .. }) => *submission_id,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn profile_id(&self) -> Uuid {
|
|
match self {
|
|
React::Submission(SubmissionReact { profile_id, .. }) => *profile_id,
|
|
React::Reply(ReplyReact { profile_id, .. }) => *profile_id,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn comment_id(&self) -> Option<Uuid> {
|
|
match self {
|
|
React::Reply(ReplyReact { comment_id, .. }) => Some(*comment_id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn react(&self) -> &str {
|
|
match self {
|
|
React::Submission(SubmissionReact { react, .. }) => &react,
|
|
React::Reply(ReplyReact { react, .. }) => &react,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn published(&self) -> DateTime<Utc> {
|
|
match self {
|
|
React::Submission(SubmissionReact { published, .. }) => *published,
|
|
React::Reply(ReplyReact { published, .. }) => *published,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct SubmissionReact {
|
|
id: Uuid,
|
|
submission_id: Uuid,
|
|
profile_id: Uuid,
|
|
react: String,
|
|
published: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ReplyReact {
|
|
id: Uuid,
|
|
submission_id: Uuid,
|
|
profile_id: Uuid,
|
|
comment_id: Uuid,
|
|
react: String,
|
|
published: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Undo<T>(pub T);
|
|
|
|
#[derive(Debug)]
|
|
pub struct ProfileChanges {
|
|
id: Uuid,
|
|
display_name: Option<String>,
|
|
description: Option<String>,
|
|
login_required: Option<bool>,
|
|
}
|
|
|
|
impl ProfileChanges {
|
|
pub fn display_name(&mut self, display_name: &str) -> &mut Self {
|
|
self.display_name = Some(display_name.to_owned());
|
|
self
|
|
}
|
|
|
|
pub fn description(&mut self, description: &str) -> &mut Self {
|
|
self.description = Some(description.to_owned());
|
|
self
|
|
}
|
|
|
|
pub fn login_required(&mut self, required: bool) -> &mut Self {
|
|
self.login_required = Some(required);
|
|
self
|
|
}
|
|
|
|
pub(crate) fn any_changes(&self) -> bool {
|
|
self.display_name.is_some() || self.description.is_some() || self.login_required.is_some()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ProfileImageChanges {
|
|
id: Uuid,
|
|
icon: Option<Uuid>,
|
|
banner: Option<Uuid>,
|
|
}
|
|
|
|
impl ProfileImageChanges {
|
|
pub fn icon(&mut self, file: &File) -> &mut Self {
|
|
self.icon = Some(file.id);
|
|
self
|
|
}
|
|
|
|
pub fn banner(&mut self, file: &File) -> &mut Self {
|
|
self.banner = Some(file.id);
|
|
self
|
|
}
|
|
|
|
pub(crate) fn any_changes(&self) -> bool {
|
|
self.icon.is_some() || self.banner.is_some()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SubmissionChanges {
|
|
id: Uuid,
|
|
title: Option<String>,
|
|
description: Option<String>,
|
|
published: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl SubmissionChanges {
|
|
pub fn title(&mut self, title: &str) -> &mut Self {
|
|
self.title = Some(title.to_owned());
|
|
self
|
|
}
|
|
|
|
pub fn description(&mut self, description: &str) -> &mut Self {
|
|
self.description = Some(description.to_owned());
|
|
self
|
|
}
|
|
|
|
pub fn publish(&mut self, time: Option<DateTime<Utc>>) -> &mut Self {
|
|
if self.published.is_none() {
|
|
self.published = time.or_else(|| Some(Utc::now()));
|
|
}
|
|
self
|
|
}
|
|
|
|
pub(crate) fn any_changes(&self) -> bool {
|
|
self.title.is_some() || self.description.is_some() || self.published.is_some()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SubmissionFileChanges {
|
|
id: Uuid,
|
|
files: Vec<Uuid>,
|
|
changed: bool,
|
|
}
|
|
|
|
impl SubmissionFileChanges {
|
|
pub fn add_file(&mut self, file: &File) -> &mut Self {
|
|
self.files.push(file.id);
|
|
self.changed = true;
|
|
self
|
|
}
|
|
|
|
pub fn delete_file(&mut self, file: &File) -> &mut Self {
|
|
self.files.retain(|id| *id != file.id);
|
|
self.changed = true;
|
|
self
|
|
}
|
|
|
|
pub(crate) fn any_changes(&self) -> bool {
|
|
self.changed
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct CommentChanges {
|
|
id: Uuid,
|
|
body: Option<String>,
|
|
}
|
|
|
|
impl CommentChanges {
|
|
pub fn body(&mut self, body: &str) -> &mut CommentChanges {
|
|
self.body = Some(body.to_owned());
|
|
self
|
|
}
|
|
|
|
pub(crate) fn any_changes(&self) -> bool {
|
|
self.body.is_some()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum StoreError {
|
|
#[error("{0}")]
|
|
Json(#[from] serde_json::Error),
|
|
|
|
#[error("{0}")]
|
|
Sled(#[from] sled::Error),
|
|
|
|
#[error("{0}")]
|
|
Transaction(#[from] sled::transaction::TransactionError),
|
|
|
|
#[error("Profile changed during modification")]
|
|
DoubleStore,
|
|
|
|
#[error("Cannot update missing item")]
|
|
Missing,
|
|
}
|
|
|
|
fn modify<T>(
|
|
tree: &sled::transaction::TransactionalTree,
|
|
key: &str,
|
|
f: impl Fn(&mut T),
|
|
) -> Result<(), sled::transaction::ConflictableTransactionError>
|
|
where
|
|
T: serde::Serialize + Default,
|
|
for<'de> T: serde::Deserialize<'de>,
|
|
{
|
|
let mut item = match tree.get(key.as_bytes())? {
|
|
Some(ivec) => {
|
|
let item: T = serde_json::from_slice(&ivec).expect("JSON is valid");
|
|
item
|
|
}
|
|
None => T::default(),
|
|
};
|
|
|
|
(f)(&mut item);
|
|
|
|
let item_vec = serde_json::to_vec(&item).expect("JSON is valid");
|
|
|
|
tree.insert(key.as_bytes(), item_vec.as_slice())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn count(
|
|
tree: &sled::transaction::TransactionalTree,
|
|
key: &str,
|
|
f: impl Fn(u64) -> u64,
|
|
) -> Result<(), sled::transaction::ConflictableTransactionError> {
|
|
let count = match tree.get(key.as_bytes())? {
|
|
Some(ivec) => {
|
|
let s = String::from_utf8_lossy(&ivec);
|
|
let count: u64 = s.parse().expect("Count is valid");
|
|
count
|
|
}
|
|
None => 0,
|
|
};
|
|
|
|
let count = (f)(count).to_string();
|
|
|
|
tree.insert(key.as_bytes(), count.as_bytes())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
impl fmt::Display for OwnerSource {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
match self {
|
|
OwnerSource::Local(id) => write!(f, "local:{}", id),
|
|
OwnerSource::Remote(s) => write!(f, "remote:{}", s),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for Store {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
f.debug_struct("Store")
|
|
.field("profiles", &"ProfileStore")
|
|
.field("files", &"FileStore")
|
|
.field("submissions", &"SubmissionStore")
|
|
.field("comments", &"CommentStore")
|
|
.field("reacts", &"ReactStore")
|
|
.field("view", &"ViewStore")
|
|
.finish()
|
|
}
|
|
}
|