diff --git a/packages/backend/native-utils/Cargo.lock b/packages/backend/native-utils/Cargo.lock index 6a4ce979c9..6770f1bfc3 100644 --- a/packages/backend/native-utils/Cargo.lock +++ b/packages/backend/native-utils/Cargo.lock @@ -2445,6 +2445,7 @@ version = "0.1.0" dependencies = [ "chrono", "clap", + "futures", "scylla", "sea-orm", "serde", diff --git a/packages/backend/native-utils/scylla-migration/Cargo.toml b/packages/backend/native-utils/scylla-migration/Cargo.toml index 0268ff1c9b..e9b6082fab 100644 --- a/packages/backend/native-utils/scylla-migration/Cargo.toml +++ b/packages/backend/native-utils/scylla-migration/Cargo.toml @@ -8,6 +8,7 @@ edition = "2021" [dependencies] chrono = "0.4.26" clap = { version = "4.3.11", features = ["derive"] } +futures = "0.3.28" scylla = "0.8.2" sea-orm = { version = "0.12.2", features = ["sqlx-postgres", "runtime-tokio-rustls"] } serde = { version = "1.0.171", features = ["derive"] } diff --git a/packages/backend/native-utils/scylla-migration/src/entity/drive_file.rs b/packages/backend/native-utils/scylla-migration/src/entity/drive_file.rs new file mode 100644 index 0000000000..c307808789 --- /dev/null +++ b/packages/backend/native-utils/scylla-migration/src/entity/drive_file.rs @@ -0,0 +1,60 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "drive_file")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[sea_orm(column_name = "createdAt")] + pub created_at: DateTimeWithTimeZone, + #[sea_orm(column_name = "userId")] + pub user_id: Option, + #[sea_orm(column_name = "userHost")] + pub user_host: Option, + pub md5: String, + pub name: String, + pub r#type: String, + pub size: i32, + pub comment: Option, + #[sea_orm(column_type = "JsonBinary")] + pub properties: Json, + #[sea_orm(column_name = "storedInternal")] + pub stored_internal: bool, + pub url: String, + #[sea_orm(column_name = "thumbnailUrl")] + pub thumbnail_url: Option, + #[sea_orm(column_name = "webpublicUrl")] + pub webpublic_url: Option, + #[sea_orm(column_name = "accessKey")] + pub access_key: Option, + #[sea_orm(column_name = "thumbnailAccessKey")] + pub thumbnail_access_key: Option, + #[sea_orm(column_name = "webpublicAccessKey")] + pub webpublic_access_key: Option, + pub uri: Option, + pub src: Option, + #[sea_orm(column_name = "folderId")] + pub folder_id: Option, + #[sea_orm(column_name = "isSensitive")] + pub is_sensitive: bool, + #[sea_orm(column_name = "isLink")] + pub is_link: bool, + pub blurhash: Option, + #[sea_orm(column_name = "webpublicType")] + pub webpublic_type: Option, + #[sea_orm(column_name = "requestHeaders", column_type = "JsonBinary", nullable)] + pub request_headers: Option, + #[sea_orm(column_name = "requestIp")] + pub request_ip: Option, + #[sea_orm(column_name = "maybeSensitive")] + pub maybe_sensitive: bool, + #[sea_orm(column_name = "maybePorn")] + pub maybe_porn: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/backend/native-utils/scylla-migration/src/entity/emoji.rs b/packages/backend/native-utils/scylla-migration/src/entity/emoji.rs new file mode 100644 index 0000000000..95e78c5dd8 --- /dev/null +++ b/packages/backend/native-utils/scylla-migration/src/entity/emoji.rs @@ -0,0 +1,30 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "emoji")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[sea_orm(column_name = "updatedAt")] + pub updated_at: Option, + pub name: String, + pub host: Option, + #[sea_orm(column_name = "originalUrl")] + pub original_url: String, + pub uri: Option, + pub r#type: Option, + pub aliases: Vec, + pub category: Option, + #[sea_orm(column_name = "publicUrl")] + pub public_url: String, + pub license: Option, + pub width: Option, + pub height: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/backend/native-utils/scylla-migration/src/entity/mod.rs b/packages/backend/native-utils/scylla-migration/src/entity/mod.rs new file mode 100644 index 0000000000..e1453d8df0 --- /dev/null +++ b/packages/backend/native-utils/scylla-migration/src/entity/mod.rs @@ -0,0 +1,6 @@ +pub mod drive_file; +pub mod emoji; +pub mod note; +pub mod poll_vote; +pub mod poll; +pub mod sea_orm_active_enums; diff --git a/packages/backend/native-utils/scylla-migration/src/entity/note.rs b/packages/backend/native-utils/scylla-migration/src/entity/note.rs new file mode 100644 index 0000000000..056b7dd4c9 --- /dev/null +++ b/packages/backend/native-utils/scylla-migration/src/entity/note.rs @@ -0,0 +1,69 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use super::sea_orm_active_enums::NoteVisibilityEnum; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "note")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[sea_orm(column_name = "createdAt")] + pub created_at: DateTimeWithTimeZone, + #[sea_orm(column_name = "replyId")] + pub reply_id: Option, + #[sea_orm(column_name = "renoteId")] + pub renote_id: Option, + #[sea_orm(column_type = "Text", nullable)] + pub text: Option, + pub name: Option, + pub cw: Option, + #[sea_orm(column_name = "userId")] + pub user_id: String, + #[sea_orm(column_name = "localOnly")] + pub local_only: bool, + #[sea_orm(column_name = "renoteCount")] + pub renote_count: i16, + #[sea_orm(column_name = "repliesCount")] + pub replies_count: i16, + #[sea_orm(column_type = "JsonBinary")] + pub reactions: Json, + pub visibility: NoteVisibilityEnum, + pub uri: Option, + pub score: i32, + #[sea_orm(column_name = "fileIds")] + pub file_ids: Vec, + #[sea_orm(column_name = "attachedFileTypes")] + pub attached_file_types: Vec, + #[sea_orm(column_name = "visibleUserIds")] + pub visible_user_ids: Vec, + pub mentions: Vec, + #[sea_orm(column_name = "mentionedRemoteUsers", column_type = "Text")] + pub mentioned_remote_users: String, + pub emojis: Vec, + pub tags: Vec, + #[sea_orm(column_name = "hasPoll")] + pub has_poll: bool, + #[sea_orm(column_name = "userHost")] + pub user_host: Option, + #[sea_orm(column_name = "replyUserId")] + pub reply_user_id: Option, + #[sea_orm(column_name = "replyUserHost")] + pub reply_user_host: Option, + #[sea_orm(column_name = "renoteUserId")] + pub renote_user_id: Option, + #[sea_orm(column_name = "renoteUserHost")] + pub renote_user_host: Option, + pub url: Option, + #[sea_orm(column_name = "channelId")] + pub channel_id: Option, + #[sea_orm(column_name = "threadId")] + pub thread_id: Option, + #[sea_orm(column_name = "updatedAt")] + pub updated_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/backend/native-utils/scylla-migration/src/entity/poll.rs b/packages/backend/native-utils/scylla-migration/src/entity/poll.rs new file mode 100644 index 0000000000..85eb8ebbd0 --- /dev/null +++ b/packages/backend/native-utils/scylla-migration/src/entity/poll.rs @@ -0,0 +1,42 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use super::sea_orm_active_enums::PollNotevisibilityEnum; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "poll")] +pub struct Model { + #[sea_orm(column_name = "noteId", primary_key, auto_increment = false, unique)] + pub note_id: String, + #[sea_orm(column_name = "expiresAt")] + pub expires_at: Option, + pub multiple: bool, + pub choices: Vec, + pub votes: Vec, + #[sea_orm(column_name = "noteVisibility")] + pub note_visibility: PollNotevisibilityEnum, + #[sea_orm(column_name = "userId")] + pub user_id: String, + #[sea_orm(column_name = "userHost")] + pub user_host: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::note::Entity", + from = "Column::NoteId", + to = "super::note::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Note, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Note.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/backend/native-utils/scylla-migration/src/entity/poll_vote.rs b/packages/backend/native-utils/scylla-migration/src/entity/poll_vote.rs new file mode 100644 index 0000000000..dbdd4627cb --- /dev/null +++ b/packages/backend/native-utils/scylla-migration/src/entity/poll_vote.rs @@ -0,0 +1,37 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "poll_vote")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[sea_orm(column_name = "createdAt")] + pub created_at: DateTimeWithTimeZone, + #[sea_orm(column_name = "userId")] + pub user_id: String, + #[sea_orm(column_name = "noteId")] + pub note_id: String, + pub choice: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::note::Entity", + from = "Column::NoteId", + to = "super::note::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Note, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Note.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/backend/native-utils/scylla-migration/src/entity/sea_orm_active_enums.rs b/packages/backend/native-utils/scylla-migration/src/entity/sea_orm_active_enums.rs new file mode 100644 index 0000000000..8d37ae49f5 --- /dev/null +++ b/packages/backend/native-utils/scylla-migration/src/entity/sea_orm_active_enums.rs @@ -0,0 +1,206 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use sea_orm::entity::prelude::*; + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "antenna_src_enum")] +pub enum AntennaSrcEnum { + #[sea_orm(string_value = "all")] + All, + #[sea_orm(string_value = "group")] + Group, + #[sea_orm(string_value = "home")] + Home, + #[sea_orm(string_value = "instances")] + Instances, + #[sea_orm(string_value = "list")] + List, + #[sea_orm(string_value = "users")] + Users, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "meta_sensitivemediadetection_enum" +)] +pub enum MetaSensitivemediadetectionEnum { + #[sea_orm(string_value = "all")] + All, + #[sea_orm(string_value = "local")] + Local, + #[sea_orm(string_value = "none")] + None, + #[sea_orm(string_value = "remote")] + Remote, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "meta_sensitivemediadetectionsensitivity_enum" +)] +pub enum MetaSensitivemediadetectionsensitivityEnum { + #[sea_orm(string_value = "high")] + High, + #[sea_orm(string_value = "low")] + Low, + #[sea_orm(string_value = "medium")] + Medium, + #[sea_orm(string_value = "veryHigh")] + VeryHigh, + #[sea_orm(string_value = "veryLow")] + VeryLow, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "muted_note_reason_enum" +)] +pub enum MutedNoteReasonEnum { + #[sea_orm(string_value = "manual")] + Manual, + #[sea_orm(string_value = "other")] + Other, + #[sea_orm(string_value = "spam")] + Spam, + #[sea_orm(string_value = "word")] + Word, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "note_visibility_enum" +)] +pub enum NoteVisibilityEnum { + #[sea_orm(string_value = "followers")] + Followers, + #[sea_orm(string_value = "hidden")] + Hidden, + #[sea_orm(string_value = "home")] + Home, + #[sea_orm(string_value = "public")] + Public, + #[sea_orm(string_value = "specified")] + Specified, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "notification_type_enum" +)] +pub enum NotificationTypeEnum { + #[sea_orm(string_value = "app")] + App, + #[sea_orm(string_value = "follow")] + Follow, + #[sea_orm(string_value = "followRequestAccepted")] + FollowRequestAccepted, + #[sea_orm(string_value = "groupInvited")] + GroupInvited, + #[sea_orm(string_value = "mention")] + Mention, + #[sea_orm(string_value = "pollEnded")] + PollEnded, + #[sea_orm(string_value = "pollVote")] + PollVote, + #[sea_orm(string_value = "quote")] + Quote, + #[sea_orm(string_value = "reaction")] + Reaction, + #[sea_orm(string_value = "receiveFollowRequest")] + ReceiveFollowRequest, + #[sea_orm(string_value = "renote")] + Renote, + #[sea_orm(string_value = "reply")] + Reply, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "page_visibility_enum" +)] +pub enum PageVisibilityEnum { + #[sea_orm(string_value = "followers")] + Followers, + #[sea_orm(string_value = "public")] + Public, + #[sea_orm(string_value = "specified")] + Specified, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "poll_notevisibility_enum" +)] +pub enum PollNotevisibilityEnum { + #[sea_orm(string_value = "followers")] + Followers, + #[sea_orm(string_value = "home")] + Home, + #[sea_orm(string_value = "public")] + Public, + #[sea_orm(string_value = "specified")] + Specified, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "relay_status_enum")] +pub enum RelayStatusEnum { + #[sea_orm(string_value = "accepted")] + Accepted, + #[sea_orm(string_value = "rejected")] + Rejected, + #[sea_orm(string_value = "requesting")] + Requesting, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "user_profile_ffvisibility_enum" +)] +pub enum UserProfileFfvisibilityEnum { + #[sea_orm(string_value = "followers")] + Followers, + #[sea_orm(string_value = "private")] + Private, + #[sea_orm(string_value = "public")] + Public, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "user_profile_mutingnotificationtypes_enum" +)] +pub enum UserProfileMutingnotificationtypesEnum { + #[sea_orm(string_value = "app")] + App, + #[sea_orm(string_value = "follow")] + Follow, + #[sea_orm(string_value = "followRequestAccepted")] + FollowRequestAccepted, + #[sea_orm(string_value = "groupInvited")] + GroupInvited, + #[sea_orm(string_value = "mention")] + Mention, + #[sea_orm(string_value = "pollEnded")] + PollEnded, + #[sea_orm(string_value = "pollVote")] + PollVote, + #[sea_orm(string_value = "quote")] + Quote, + #[sea_orm(string_value = "reaction")] + Reaction, + #[sea_orm(string_value = "receiveFollowRequest")] + ReceiveFollowRequest, + #[sea_orm(string_value = "renote")] + Renote, + #[sea_orm(string_value = "reply")] + Reply, +} diff --git a/packages/backend/native-utils/scylla-migration/src/lib.rs b/packages/backend/native-utils/scylla-migration/src/lib.rs index 856d870c0c..135b2452cb 100644 --- a/packages/backend/native-utils/scylla-migration/src/lib.rs +++ b/packages/backend/native-utils/scylla-migration/src/lib.rs @@ -3,6 +3,7 @@ pub use clap::Parser; pub mod cli; pub mod config; pub mod error; +pub mod entity; pub(crate) mod migrator; pub(crate) mod setup; diff --git a/packages/backend/native-utils/scylla-migration/src/setup.rs b/packages/backend/native-utils/scylla-migration/src/setup.rs index 0a50365a40..aaa4cbeded 100644 --- a/packages/backend/native-utils/scylla-migration/src/setup.rs +++ b/packages/backend/native-utils/scylla-migration/src/setup.rs @@ -1,9 +1,15 @@ -use scylla::{Session, SessionBuilder}; -use sea_orm::{ConnectionTrait, Database, Statement}; +use std::collections::HashMap; + +use futures::TryStreamExt; +use scylla::{Session, SessionBuilder, IntoUserType, FromUserType, ValueList}; +use sea_orm::{ConnectionTrait, Database, EntityTrait, Statement}; use urlencoding::encode; +use chrono::{DateTime, Utc, NaiveDate}; + use crate::{ config::{DbConfig, ScyllaConfig}, + entity::note, error::Error, }; @@ -41,6 +47,11 @@ impl Initializer { let pool = Database::connect(&self.postgres_url).await?; let db_backend = pool.get_database_backend(); + let mut notes = note::Entity::find().stream(&pool).await?; + while let Some(note) = notes.try_next().await? { + + } + let fk_pairs = vec![ ("channel_note_pining", "FK_10b19ef67d297ea9de325cd4502"), ("clip_note", "FK_a012eaf5c87c65da1deb5fdbfa3"), @@ -79,3 +90,86 @@ impl Initializer { Ok(()) } } + +#[derive(Debug, IntoUserType, FromUserType)] +struct DriveFileType { + id: String, + #[scylla_crate(rename = "type")] + kind: String, + #[scylla_crate(rename = "createdAt")] + created_at: DateTime, + name: String, + comment: Option, + blurhash: Option, + url: String, + #[scylla_crate(rename = "thumbnailUrl")] + thumbnail_url: Option, + #[scylla_crate(rename = "isSensitive")] + is_sensitive: bool, + #[scylla_crate(rename = "isLink")] + is_link: bool, + md5: String, + size: i32, + width: Option, + height: Option, +} + +#[derive(Debug, IntoUserType, FromUserType)] +struct NoteEditHistoryType { + content: Option, + cw: Option, + files: Vec, + #[scylla_crate(rename = "updatedAt")] + updated_at: DateTime +} + +#[derive(Debug, IntoUserType, FromUserType)] +struct EmojiType { + name: String, + url: String, + width: Option, + height: Option, +} + +#[derive(Debug, IntoUserType, FromUserType)] +struct PollType { + #[scylla_crate(rename = "expiresAt")] + expires_at: Option>, + multiple: bool, + choices: HashMap, +} + +#[derive(ValueList)] +struct NoteTable { + #[scylla_crate(rename = "createdAtDate")] + created_at_date: NaiveDate, + #[scylla_crate(rename = "createdAt")] + created_at: DateTime, + id: String, + visibility: String, + content: Option, + name: Option, + cw: Option, + #[scylla_crate(rename = "localOnly")] + local_only: bool, + #[scylla_crate(rename = "renoteCount")] + renote_count: i32, + #[scylla_crate(rename = "scyllaCrate")] + replies_count: i32, + uri: Option, + url: Option, + score: i32, + files: Vec, + #[scylla_crate(rename = "visibleUserIds")] + visible_user_ids: Vec, + mentions: Vec, + #[scylla_crate(rename = "mentionedRemoteUsers")] + mentioned_remote_users: String, + emojis: Vec, + tags: Vec, + #[scylla_crate(rename = "hasPoll")] + has_poll: bool, + poll: PollType, + #[scylla_crate(rename = "threadId")] + thread_id: String, +} diff --git a/packages/backend/src/db/scylla.ts b/packages/backend/src/db/scylla.ts index 16bc68b954..eb6f0d6927 100644 --- a/packages/backend/src/db/scylla.ts +++ b/packages/backend/src/db/scylla.ts @@ -32,10 +32,17 @@ function newClient(): Client | null { const requestTracker = new tracker.RequestLogger({ slowThreshold: 1000, }); + const client = new Client({ contactPoints: config.scylla.nodes, localDataCenter: config.scylla.localDataCentre, keyspace: config.scylla.keyspace, + pooling: { + coreConnectionsPerHost: { + [types.distance.local]: 2, + [types.distance.remote]: 1, + }, + }, requestTracker, }); @@ -79,14 +86,16 @@ export async function fetchPostCount(local = false): Promise { if (local) { return await localPostCountCache.fetch(null, () => scyllaClient - .execute("SELECT COUNT(*) FROM local_note_by_user_id") + .execute("SELECT COUNT(*) FROM local_note_by_user_id", [], { + prepare: true, + }) .then((result) => result.first().get("count") as number), ); } return await allPostCountCache.fetch(null, () => scyllaClient - .execute("SELECT COUNT(*) FROM note") + .execute("SELECT COUNT(*) FROM note", [], { prepare: true }) .then((result) => result.first().get("count") as number), ); } @@ -98,7 +107,7 @@ export async function fetchReactionCount(): Promise { return await reactionCountCache.fetch(null, () => scyllaClient - .execute("SELECT COUNT(*) FROM reaction") + .execute("SELECT COUNT(*) FROM reaction", [], { prepare: true }) .then((result) => result.first().get("count") as number), ); } diff --git a/packages/backend/src/queue/processors/background/index-all-notes.ts b/packages/backend/src/queue/processors/background/index-all-notes.ts index 9cd7c1e471..3ff462ecd5 100644 --- a/packages/backend/src/queue/processors/background/index-all-notes.ts +++ b/packages/backend/src/queue/processors/background/index-all-notes.ts @@ -26,6 +26,7 @@ export default async function indexAllNotes( scyllaClient.eachRow( "SELECT * FROM note", [], + { prepare: true }, (n, row) => { if (n % 1000 === 0) { job.progress(((n / total) * 100).toFixed(1));