diff --git a/docs/downgrade.sql b/docs/downgrade.sql index 64622c3af7..1d26a6fa3b 100644 --- a/docs/downgrade.sql +++ b/docs/downgrade.sql @@ -1,6 +1,8 @@ BEGIN; DELETE FROM "migrations" WHERE name IN ( + 'FixMutingIndices1710690239308', + 'NoteFile1710304584214', 'RenameMetaColumns1705944717480', 'SeparateHardMuteWordsAndPatterns1706413792769', 'IndexAltTextAndCw1708872574733', @@ -16,6 +18,20 @@ DELETE FROM "migrations" WHERE name IN ( 'RemoveNativeUtilsMigration1705877093218' ); +-- fix-muting-indices +DROP INDEX "IDX_renote_muting_createdAt"; +DROP INDEX "IDX_renote_muting_muteeId"; +DROP INDEX "IDX_renote_muting_muterId"; +DROP INDEX "IDX_reply_muting_createdAt"; +DROP INDEX "IDX_reply_muting_muteeId"; +DROP INDEX "IDX_reply_muting_muterId"; +CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt"); +CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId"); +CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId"); + +-- note-file +DROP TABLE "note_file"; + -- rename-meta-columns ALTER TABLE "meta" RENAME COLUMN "tosUrl" TO "ToSUrl"; ALTER TABLE "meta" RENAME COLUMN "objectStorageUseSsl" TO "objectStorageUseSSL"; diff --git a/locales/en-US.yml b/locales/en-US.yml index f354d4c36e..77564ec7bd 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1198,7 +1198,9 @@ searchWordsDescription: "To search for posts, enter the search term. Separate wo search.\nFor example, 'morning night' will find posts that contain both 'morning' and 'night', and 'morning OR night' will find posts that contain either 'morning' or 'night' (or both).\nYou can also combine AND/OR conditions like '(morning OR - night) sleepy'.\n\nIf you want to go to a specific user page or post page, enter + night) sleepy'.\nIf you want to search for a sequence of words (e.g., a sentence), you + must put it in double quotes, not to make it an AND search: \"Today I learned\"\n\n + If you want to go to a specific user page or post page, enter the ID or URL in this field and click the 'Lookup' button. Clicking 'Search' will search for posts that literally contain the ID/URL." searchUsers: "Posted by (optional)" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index abbce70fcc..c61187a510 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1008,7 +1008,8 @@ enableTimelineStreaming: "タイムラインを自動で更新する" searchWords: "検索語句・照会するIDやURL" searchWordsDescription: "投稿を検索するには、ここに検索語句を入力してください。空白区切りでAND検索になり、ORを挟むとOR検索になります。\n 例えば「朝 夜」と入力すると「朝」と「夜」が両方含まれた投稿を検索し、「朝 OR 夜」と入力すると「朝」または「夜」(または両方)が含まれた投稿を検索します。\n - 「(朝 OR 夜) 眠い」のように、AND検索とOR検索を同時に行うこともできます。\n\n特定のユーザーや投稿のページに飛びたい場合には、この欄にID (@user@example.com) + 「(朝 OR 夜) 眠い」のように、AND検索とOR検索を同時に行うこともできます。\n空白を含む文字列をAND検索ではなくそのまま検索したい場合、\"明日 買うもの\"\ + \ のように二重引用符 (\") で囲む必要があります。\n\n特定のユーザーや投稿のページに飛びたい場合には、この欄にID (@user@example.com) や投稿のURLを入力し「照会」を押してください。「検索」を押すとそのIDやURLが文字通り含まれる投稿を検索します。" searchUsers: "投稿元(オプション)" searchUsersDescription: "投稿検索で投稿者を絞りたい場合、@user@example.com(ローカルユーザーなら @user)の形式で投稿者のIDを入力してください。ユーザーIDではなくドメイン名 diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 30677ef021..2e220950d8 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1936,7 +1936,7 @@ moveFrom: 从旧账号迁移至此账号 defaultReaction: 发出和收到帖子的默认表情符号反应 sendModMail: 发送管理通知 moderationNote: "管理笔记" -ipFirstAcknowledged: "该日期是这个 IP 地址首次被获取到的日期" +ipFirstAcknowledged: "首次获取此 IP 地址的日期" driveCapacityOverride: "网盘容量变更" isLocked: 该账号设置了关注请求 _filters: @@ -2047,11 +2047,12 @@ publishTimelines: 为访客发布时间线 publishTimelinesDescription: 如果启用,在用户登出时本地和全局时间线也会显示在 {url} 上。 searchWordsDescription: "要搜索帖子,请输入关键词。交集搜索关键词之间使用空格进行区分,并集搜索关键词之间使用 OR 进行区分。\n例如 '早上 晚上' 将查找包含 '早上' 和 '晚上' 的帖子,而 '早上 OR 晚上' 将查找包含 '早上' 或 '晚上' (以及同时包含两者)的帖子。\n您还可以组合交集/并集条件,例如 - '(早上 OR 晚上) 困了' 。\n\n如果您想转到特定的用户页面或帖子页面,请在此字段中输入用户 ID 或 URL,然后单击 “查询” 按钮。 单击 “搜索” - 将搜索字面包含用户 ID/URL 的帖子。" + '(早上 OR 晚上) 困了' 。\n如果您想搜索单词序列(例如一个英语句子),您必须将其放在双引号中,例如 \"Today I learned\" 以区分于交集搜索。\n + \n如果您想转到特定的用户页面或帖子页面,请在此字段中输入用户 ID 或 URL,然后单击 “查询” 按钮。 单击 “搜索” 将搜索字面包含用户 ID/URL + 的帖子。" searchRangeDescription: "如果您要过滤时间段,请按以下格式输入:20220615-20231031\n\n如果您省略年份(例如 0105-0106 或 20231105-0110),它将被解释为当前年份。\n\n您还可以省略开始日期或结束日期。 例如 -0102 将过滤搜索结果以仅显示今年 1 月 2 日之前发布的帖子,而 20231026- 将过滤结果以仅显示 2023 年 10 月 26 日之后发布的帖子。" messagingUnencryptedInfo: "Firefish 上的聊天没有经过端到端加密,请不要在聊天中分享您的敏感信息。" -noAltTextWarning: 有些附件没有说明。您是否忘记写说明了? -showNoAltTextWarning: 当您尝试发布没有说明的帖子附件时显示警告 +noAltTextWarning: 有些附件没有描述。您是否忘记写描述了? +showNoAltTextWarning: 当您尝试发布没有描述的帖子附件时显示警告 diff --git a/packages/backend-rs/src/model/entity/drive_file.rs b/packages/backend-rs/src/model/entity/drive_file.rs index 1a7a0f35b1..a5f7e9bfca 100644 --- a/packages/backend-rs/src/model/entity/drive_file.rs +++ b/packages/backend-rs/src/model/entity/drive_file.rs @@ -64,6 +64,8 @@ pub enum Relation { DriveFolder, #[sea_orm(has_many = "super::messaging_message::Entity")] MessagingMessage, + #[sea_orm(has_many = "super::note_file::Entity")] + NoteFile, #[sea_orm(has_many = "super::page::Entity")] Page, #[sea_orm( @@ -94,6 +96,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::NoteFile.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::Page.def() diff --git a/packages/backend-rs/src/model/entity/mod.rs b/packages/backend-rs/src/model/entity/mod.rs index 38be7561b1..223b07fb4f 100644 --- a/packages/backend-rs/src/model/entity/mod.rs +++ b/packages/backend-rs/src/model/entity/mod.rs @@ -35,6 +35,7 @@ pub mod muting; pub mod note; pub mod note_edit; pub mod note_favorite; +pub mod note_file; pub mod note_reaction; pub mod note_thread_muting; pub mod note_unread; diff --git a/packages/backend-rs/src/model/entity/note.rs b/packages/backend-rs/src/model/entity/note.rs index 4774cae952..f713e2e47e 100644 --- a/packages/backend-rs/src/model/entity/note.rs +++ b/packages/backend-rs/src/model/entity/note.rs @@ -38,8 +38,6 @@ pub struct Model { #[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")] @@ -100,6 +98,8 @@ pub enum Relation { NoteEdit, #[sea_orm(has_many = "super::note_favorite::Entity")] NoteFavorite, + #[sea_orm(has_many = "super::note_file::Entity")] + NoteFile, #[sea_orm(has_many = "super::note_reaction::Entity")] NoteReaction, #[sea_orm(has_many = "super::note_unread::Entity")] @@ -164,6 +164,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::NoteFile.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::NoteReaction.def() diff --git a/packages/backend-rs/src/model/entity/note_file.rs b/packages/backend-rs/src/model/entity/note_file.rs new file mode 100644 index 0000000000..c75222360b --- /dev/null +++ b/packages/backend-rs/src/model/entity/note_file.rs @@ -0,0 +1,48 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.12 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "note_file")] +pub struct Model { + #[sea_orm(column_name = "serialNo", primary_key)] + pub serial_no: i64, + #[sea_orm(column_name = "noteId")] + pub note_id: String, + #[sea_orm(column_name = "fileId")] + pub file_id: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::drive_file::Entity", + from = "Column::FileId", + to = "super::drive_file::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + DriveFile, + #[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::DriveFile.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Note.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/backend-rs/src/model/entity/prelude.rs b/packages/backend-rs/src/model/entity/prelude.rs index 10d3795362..0935ac9ff7 100644 --- a/packages/backend-rs/src/model/entity/prelude.rs +++ b/packages/backend-rs/src/model/entity/prelude.rs @@ -33,6 +33,7 @@ pub use super::muting::Entity as Muting; pub use super::note::Entity as Note; pub use super::note_edit::Entity as NoteEdit; pub use super::note_favorite::Entity as NoteFavorite; +pub use super::note_file::Entity as NoteFile; pub use super::note_reaction::Entity as NoteReaction; pub use super::note_thread_muting::Entity as NoteThreadMuting; pub use super::note_unread::Entity as NoteUnread; diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index b14f028e8b..b6c3f0db8f 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -73,6 +73,7 @@ import { UserPending } from "@/models/entities/user-pending.js"; import { Webhook } from "@/models/entities/webhook.js"; import { UserIp } from "@/models/entities/user-ip.js"; import { NoteEdit } from "@/models/entities/note-edit.js"; +import { NoteFile } from "@/models/entities/note-file.js"; import { entities as charts } from "@/services/chart/entities.js"; import { dbLogger } from "./logger.js"; @@ -143,6 +144,7 @@ export const entities = [ Note, NoteEdit, NoteFavorite, + NoteFile, NoteReaction, NoteWatching, NoteThreadMuting, diff --git a/packages/backend/src/migration/1705877093218-remove-native-utils-migration.ts b/packages/backend/src/migration/1705877093218-remove-native-utils-migration.ts index df9569baeb..a155a4ff78 100644 --- a/packages/backend/src/migration/1705877093218-remove-native-utils-migration.ts +++ b/packages/backend/src/migration/1705877093218-remove-native-utils-migration.ts @@ -105,10 +105,10 @@ export class RemoveNativeUtilsMigration1705877093218 `CREATE INDEX "IDX_9937ea48d7ae97ffb4f3f063a4" ON "antenna_note" ("read")`, ); await queryRunner.query( - `ALTER TABLE "antenna_note" ADD CONSTRAINT IF NOT EXISTS "FK_0d775946662d2575dfd2068a5f5" FOREIGN KEY ("antennaId") REFERENCES "antenna"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "antenna_note" ADD CONSTRAINT "FK_0d775946662d2575dfd2068a5f5" FOREIGN KEY ("antennaId") REFERENCES "antenna"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "antenna_note" ADD CONSTRAINT IF NOT EXISTS "FK_bd0397be22147e17210940e125b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "antenna_note" ADD CONSTRAINT "FK_bd0397be22147e17210940e125b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query(`DROP INDEX IF EXISTS "IDX_note_url"`); await queryRunner.query( @@ -124,10 +124,10 @@ export class RemoveNativeUtilsMigration1705877093218 `CREATE INDEX IF NOT EXISTS "IDX_e247b23a3c9b45f89ec1299d06" ON "reversi_matching" ("childId")`, ); await queryRunner.query( - `ALTER TABLE "reversi_matching" ADD CONSTRAINT IF NOT EXISTS "FK_3b25402709dd9882048c2bbade0" FOREIGN KEY ("parentId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "reversi_matching" ADD CONSTRAINT "FK_3b25402709dd9882048c2bbade0" FOREIGN KEY ("parentId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "reversi_matching" ADD CONSTRAINT IF NOT EXISTS "FK_e247b23a3c9b45f89ec1299d066" FOREIGN KEY ("childId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "reversi_matching" ADD CONSTRAINT "FK_e247b23a3c9b45f89ec1299d066" FOREIGN KEY ("childId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( `COMMENT ON COLUMN "reversi_matching"."createdAt" IS 'The created date of the ReversiMatching.'`, @@ -139,10 +139,10 @@ export class RemoveNativeUtilsMigration1705877093218 `CREATE INDEX IF NOT EXISTS "IDX_b46ec40746efceac604142be1c" ON "reversi_game" ("createdAt")`, ); await queryRunner.query( - `ALTER TABLE "reversi_game" ADD CONSTRAINT IF NOT EXISTS "FK_f7467510c60a45ce5aca6292743" FOREIGN KEY ("user1Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "reversi_game" ADD CONSTRAINT "FK_f7467510c60a45ce5aca6292743" FOREIGN KEY ("user1Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "reversi_game" ADD CONSTRAINT IF NOT EXISTS "FK_6649a4e8c5d5cf32fb03b5da9f6" FOREIGN KEY ("user2Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "reversi_game" ADD CONSTRAINT "FK_6649a4e8c5d5cf32fb03b5da9f6" FOREIGN KEY ("user2Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); await queryRunner.query( `COMMENT ON COLUMN "reversi_game"."createdAt" IS 'The created date of the ReversiGame.'`, diff --git a/packages/backend/src/migration/1710304584214-note-file.ts b/packages/backend/src/migration/1710304584214-note-file.ts new file mode 100644 index 0000000000..be0458d297 --- /dev/null +++ b/packages/backend/src/migration/1710304584214-note-file.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NoteFile1710304584214 implements MigrationInterface { + name = "NoteFile1710304584214"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "note_file" ( + "serialNo" bigserial PRIMARY KEY, + "noteId" varchar(32) NOT NULL, + "fileId" varchar(32) NOT NULL + )`, + ); + await queryRunner.query(` + INSERT INTO "note_file" ("noteId", "fileId") + SELECT "t"."id", "t"."fid" FROM ( + SELECT ROW_NUMBER() OVER () AS "rn", * FROM ( + SELECT "id", UNNEST("fileIds") AS "fid" FROM "note" + ) AS "s" + ) AS "t" + INNER JOIN "drive_file" ON "drive_file"."id" = "t"."fid" + ORDER BY "rn" + `); + await queryRunner.query( + `ALTER TABLE "note_file" ADD FOREIGN KEY ("noteId") REFERENCES "note" ("id") ON DELETE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "note_file" ADD FOREIGN KEY ("fileId") REFERENCES "drive_file" ("id") ON DELETE CASCADE`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_note_file_noteId" ON "note_file" ("noteId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_note_file_fileId" ON "note_file" ("fileId")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "note_file"`); + } +} diff --git a/packages/backend/src/migration/1710690239308-fix-muting-indices.ts b/packages/backend/src/migration/1710690239308-fix-muting-indices.ts new file mode 100644 index 0000000000..3dc24c2531 --- /dev/null +++ b/packages/backend/src/migration/1710690239308-fix-muting-indices.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FixMutingIndices1710690239308 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_renote_muting_createdAt"`); + await queryRunner.query(`DROP INDEX "IDX_renote_muting_muteeId"`); + await queryRunner.query(`DROP INDEX "IDX_renote_muting_muterId"`); + await queryRunner.query(`DROP INDEX "IDX_reply_muting_createdAt"`); + await queryRunner.query(`DROP INDEX "IDX_reply_muting_muteeId"`); + await queryRunner.query(`DROP INDEX "IDX_reply_muting_muterId"`); + await queryRunner.query( + `CREATE INDEX "IDX_renote_muting_createdAt" ON "renote_muting" ("createdAt")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_renote_muting_muteeId" ON "renote_muting" ("muteeId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_renote_muting_muterId" ON "renote_muting" ("muterId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_reply_muting_createdAt" ON "reply_muting" ("createdAt")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_reply_muting_muteeId" ON "reply_muting" ("muteeId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_reply_muting_muterId" ON "reply_muting" ("muterId")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_renote_muting_createdAt"`); + await queryRunner.query(`DROP INDEX "IDX_renote_muting_muteeId"`); + await queryRunner.query(`DROP INDEX "IDX_renote_muting_muterId"`); + await queryRunner.query(`DROP INDEX "IDX_reply_muting_createdAt"`); + await queryRunner.query(`DROP INDEX "IDX_reply_muting_muteeId"`); + await queryRunner.query(`DROP INDEX "IDX_reply_muting_muterId"`); + await queryRunner.query( + `CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_reply_muting_createdAt" ON "muting" ("createdAt")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_reply_muting_muteeId" ON "muting" ("muteeId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_reply_muting_muterId" ON "muting" ("muterId")`, + ); + } +} diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index 9d698527c4..016b7159c9 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -32,6 +32,7 @@ import { packedQueueCountSchema } from "@/models/schema/queue.js"; import { packedGalleryPostSchema } from "@/models/schema/gallery-post.js"; import { packedEmojiSchema } from "@/models/schema/emoji.js"; import { packedNoteEdit } from "@/models/schema/note-edit.js"; +import { packedNoteFileSchema } from "@/models/schema/note-file.js"; export const refs = { UserLite: packedUserLiteSchema, @@ -47,6 +48,7 @@ export const refs = { App: packedAppSchema, MessagingMessage: packedMessagingMessageSchema, Note: packedNoteSchema, + NoteFile: packedNoteFileSchema, NoteEdit: packedNoteEdit, NoteReaction: packedNoteReactionSchema, NoteFavorite: packedNoteFavoriteSchema, diff --git a/packages/backend/src/models/entities/drive-file.ts b/packages/backend/src/models/entities/drive-file.ts index 5b31a7e336..3c4510b533 100644 --- a/packages/backend/src/models/entities/drive-file.ts +++ b/packages/backend/src/models/entities/drive-file.ts @@ -4,12 +4,17 @@ import { Index, JoinColumn, Column, + ManyToMany, ManyToOne, + OneToMany, + type Relation, } from "typeorm"; import { id } from "../id.js"; +import { Note } from "./note.js"; import { User } from "./user.js"; import { DriveFolder } from "./drive-folder.js"; import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js"; +import { NoteFile } from "./note-file.js"; @Entity() @Index(["userId", "folderId", "id"]) @@ -31,12 +36,6 @@ export class DriveFile { }) public userId: User["id"] | null; - @ManyToOne((type) => User, { - onDelete: "SET NULL", - }) - @JoinColumn() - public user: User | null; - @Index() @Column("varchar", { length: 512, @@ -171,12 +170,6 @@ export class DriveFile { }) public folderId: DriveFolder["id"] | null; - @ManyToOne((type) => DriveFolder, { - onDelete: "SET NULL", - }) - @JoinColumn() - public folder: DriveFolder | null; - @Index() @Column("boolean", { default: false, @@ -205,4 +198,30 @@ export class DriveFile { nullable: true, }) public requestIp: string | null; + + //#region Relations + @OneToMany( + () => NoteFile, + (noteFile: NoteFile) => noteFile.file, + ) + public noteFiles: Relation; + + @ManyToMany( + () => Note, + (note: Note) => note.files, + ) + public notes: Relation; + + @ManyToOne(() => User, { + onDelete: "SET NULL", + }) + @JoinColumn() + public user: User | null; + + @ManyToOne(() => DriveFolder, { + onDelete: "SET NULL", + }) + @JoinColumn() + public folder: DriveFolder | null; + //#endregion Relations } diff --git a/packages/backend/src/models/entities/note-file.ts b/packages/backend/src/models/entities/note-file.ts new file mode 100644 index 0000000000..7e7013e03a --- /dev/null +++ b/packages/backend/src/models/entities/note-file.ts @@ -0,0 +1,45 @@ +import { + Entity, + Index, + Column, + ManyToOne, + PrimaryGeneratedColumn, + type Relation, +} from "typeorm"; +import { Note } from "./note.js"; +import { DriveFile } from "./drive-file.js"; +import { id } from "../id.js"; + +@Entity() +export class NoteFile { + @PrimaryGeneratedColumn("increment") + public serialNo: number; + + @Index("IDX_note_file_noteId", { unique: false }) + @Column({ + ...id(), + nullable: false, + }) + public noteId: Note["id"]; + + @Index("IDX_note_file_fileId", { unique: false }) + @Column({ + ...id(), + nullable: false, + }) + public fileId: DriveFile["id"]; + + //#region Relations + @ManyToOne( + () => Note, + (note: Note) => note.noteFiles, + ) + public note: Relation; + + @ManyToOne( + () => DriveFile, + (file: DriveFile) => file.noteFiles, + ) + public file: Relation; + //#endregion Relations +} diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts index e45fb02466..a31dd7dd46 100644 --- a/packages/backend/src/models/entities/note.ts +++ b/packages/backend/src/models/entities/note.ts @@ -2,15 +2,20 @@ import { Entity, Index, JoinColumn, + JoinTable, Column, PrimaryColumn, + ManyToMany, ManyToOne, + OneToMany, + type Relation, } from "typeorm"; import { User } from "./user.js"; -import type { DriveFile } from "./drive-file.js"; +import { DriveFile } from "./drive-file.js"; import { id } from "../id.js"; import { noteVisibilities } from "../../types.js"; import { Channel } from "./channel.js"; +import { NoteFile } from "./note-file.js"; @Entity() @Index("IDX_NOTE_TAGS", { synchronize: false }) @@ -34,12 +39,6 @@ export class Note { }) public replyId: Note["id"] | null; - @ManyToOne((type) => Note, { - onDelete: "CASCADE", - }) - @JoinColumn() - public reply: Note | null; - @Index() @Column({ ...id(), @@ -48,12 +47,6 @@ export class Note { }) public renoteId: Note["id"] | null; - @ManyToOne((type) => Note, { - onDelete: "CASCADE", - }) - @JoinColumn() - public renote: Note | null; - @Index() @Column("varchar", { length: 256, @@ -93,12 +86,6 @@ export class Note { }) public userId: User["id"]; - @ManyToOne((type) => User, { - onDelete: "CASCADE", - }) - @JoinColumn() - public user: User | null; - @Column("boolean", { default: false, }) @@ -151,6 +138,8 @@ export class Note { }) public score: number; + // FIXME: file id is not removed from this array even if the file is deleted + // TODO: drop this column and use note_files @Index() @Column({ ...id(), @@ -183,6 +172,7 @@ export class Note { }) public mentions: User["id"][]; + // FIXME: WHAT IS THIS @Column("text", { default: "[]", }) @@ -216,12 +206,55 @@ export class Note { }) public channelId: Channel["id"] | null; - @ManyToOne((type) => Channel, { + //#region Relations + @OneToMany( + () => NoteFile, + (noteFile: NoteFile) => noteFile.note, + ) + public noteFiles: Relation; + + @ManyToMany( + () => DriveFile, + (file: DriveFile) => file.notes, + ) + @JoinTable({ + name: "note_file", + joinColumn: { + name: "noteId", + referencedColumnName: "id", + }, + inverseJoinColumn: { + name: "fileId", + referencedColumnName: "id", + }, + }) + public files: Relation; + + @ManyToOne(() => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public reply: Note | null; + + @ManyToOne(() => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public renote: Note | null; + + @ManyToOne(() => Channel, { onDelete: "CASCADE", }) @JoinColumn() public channel: Channel | null; + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + //#endregion Relations + //#region Denormalized fields @Index() @Column("varchar", { diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 8ae12a63df..5d4ff52198 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -66,12 +66,14 @@ import { InstanceRepository } from "./repositories/instance.js"; import { Webhook } from "./entities/webhook.js"; import { UserIp } from "./entities/user-ip.js"; import { NoteEdit } from "./entities/note-edit.js"; +import { NoteFileRepository } from "./repositories/note-file.js"; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); export const Apps = AppRepository; export const Notes = NoteRepository; export const NoteEdits = db.getRepository(NoteEdit); +export const NoteFiles = NoteFileRepository; export const NoteFavorites = NoteFavoriteRepository; export const NoteWatchings = db.getRepository(NoteWatching); export const NoteThreadMutings = db.getRepository(NoteThreadMuting); diff --git a/packages/backend/src/models/repositories/note-file.ts b/packages/backend/src/models/repositories/note-file.ts new file mode 100644 index 0000000000..f755fb5fea --- /dev/null +++ b/packages/backend/src/models/repositories/note-file.ts @@ -0,0 +1,4 @@ +import { db } from "@/db/postgre.js"; +import { NoteFile } from "@/models/entities/note-file.js"; + +export const NoteFileRepository = db.getRepository(NoteFile).extend({}); diff --git a/packages/backend/src/models/schema/note-file.ts b/packages/backend/src/models/schema/note-file.ts new file mode 100644 index 0000000000..c9b8a7e181 --- /dev/null +++ b/packages/backend/src/models/schema/note-file.ts @@ -0,0 +1,24 @@ +export const packedNoteFileSchema = { + type: "object", + properties: { + serialNo: { + type: "number", + optional: false, + nullable: false, + }, + noteId: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + fileId: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + }, +} as const; diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index 0bc70d37f9..b159a91944 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -1,4 +1,3 @@ -import { Brackets } from "typeorm"; import { Notes } from "@/models/index.js"; import { Note } from "@/models/entities/note.js"; import define from "@/server/api/define.js"; @@ -7,6 +6,7 @@ import { generateVisibilityQuery } from "@/server/api/common/generate-visibility import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js"; import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; +import type { SelectQueryBuilder } from "typeorm"; export const meta = { tags: ["notes"], @@ -69,91 +69,123 @@ export const paramDef = { } as const; export default define(meta, paramDef, async (ps, me) => { - const query = makePaginationQuery( - Notes.createQueryBuilder("note"), - ps.sinceId, - ps.untilId, - ps.sinceDate ?? undefined, - ps.untilDate ?? undefined, - ); + async function search( + modifier?: (query: SelectQueryBuilder) => void, + ): Promise { + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ps.sinceDate ?? undefined, + ps.untilDate ?? undefined, + ); + modifier?.(query); - if (ps.userId != null) { - query.andWhere("note.userId = :userId", { userId: ps.userId }); + if (ps.userId != null) { + query.andWhere("note.userId = :userId", { userId: ps.userId }); + } + + if (ps.channelId != null) { + query.andWhere("note.channelId = :channelId", { + channelId: ps.channelId, + }); + } + + query.innerJoinAndSelect("note.user", "user"); + + // "from: me": search all (public, home, followers, specified) my posts + // otherwise: search public indexable posts only + if (ps.userId == null || ps.userId !== me?.id) { + query + .andWhere("note.visibility = 'public'") + .andWhere("user.isIndexable = TRUE"); + } + + if (ps.userId != null) { + query.andWhere("note.userId = :userId", { userId: ps.userId }); + } + + if (ps.host === null) { + query.andWhere("note.userHost IS NULL"); + } + if (ps.host != null) { + query.andWhere("note.userHost = :userHost", { userHost: ps.host }); + } + + if (ps.withFiles === true) { + query.andWhere("note.fileIds != '{}'"); + } + + query + .leftJoinAndSelect("user.avatar", "avatar") + .leftJoinAndSelect("user.banner", "banner") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("replyUser.avatar", "replyUserAvatar") + .leftJoinAndSelect("replyUser.banner", "replyUserBanner") + .leftJoinAndSelect("renote.user", "renoteUser") + .leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar") + .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner"); + + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me); + if (me) generateBlockedUserQuery(query, me); + + return await query.take(ps.limit).getMany(); } - if (ps.channelId != null) { - query.andWhere("note.channelId = :channelId", { - channelId: ps.channelId, - }); - } + let notes: Note[]; if (ps.query != null) { const q = sqlLikeEscape(ps.query); if (ps.searchCwAndAlt) { - query.andWhere( - new Brackets((qb) => { - qb.where("note.text &@~ :q", { q }) - .orWhere("note.cw &@~ :q", { q }) - .orWhere( - `EXISTS ( - SELECT FROM "drive_file" - WHERE - comment &@~ :q - AND - drive_file."id" = ANY(note."fileIds") - )`, - { q }, - ); - }), - ); + // Whether we should return latest notes first + const isDescendingOrder = + (ps.sinceId == null || ps.untilId != null) && + (ps.sinceId != null || + ps.untilId != null || + ps.sinceDate == null || + ps.untilDate != null); + + const compare = isDescendingOrder + ? (lhs: Note, rhs: Note) => + Math.sign(rhs.createdAt.getTime() - lhs.createdAt.getTime()) + : (lhs: Note, rhs: Note) => + Math.sign(lhs.createdAt.getTime() - rhs.createdAt.getTime()); + + notes = [ + ...new Map( + ( + await Promise.all([ + search((query) => { + query.andWhere("note.text &@~ :q", { q }); + }), + search((query) => { + query.andWhere("note.cw &@~ :q", { q }); + }), + search((query) => { + query + .andWhere("drive_file.comment &@~ :q", { q }) + .innerJoin("note.files", "drive_file"); + }), + ]) + ) + .flatMap((e) => e) + .map((note) => [note.id, note]), + ).values(), + ] + .sort(compare) + .slice(0, ps.limit); } else { - query.andWhere("note.text &@~ :q", { q }); + notes = await search((query) => { + query.andWhere("note.text &@~ :q", { q }); + }); } + } else { + notes = await search(); } - query.innerJoinAndSelect("note.user", "user"); - - // "from: me": search all (public, home, followers, specified) my posts - // otherwise: search public indexable posts only - if (ps.userId == null || ps.userId !== me?.id) { - query - .andWhere("note.visibility = 'public'") - .andWhere("user.isIndexable = TRUE"); - } - - if (ps.userId != null) { - query.andWhere("note.userId = :userId", { userId: ps.userId }); - } - - if (ps.host === null) { - query.andWhere("note.userHost IS NULL"); - } - if (ps.host != null) { - query.andWhere("note.userHost = :userHost", { userHost: ps.host }); - } - - if (ps.withFiles === true) { - query.andWhere("note.fileIds != '{}'"); - } - - query - .leftJoinAndSelect("user.avatar", "avatar") - .leftJoinAndSelect("user.banner", "banner") - .leftJoinAndSelect("note.reply", "reply") - .leftJoinAndSelect("note.renote", "renote") - .leftJoinAndSelect("reply.user", "replyUser") - .leftJoinAndSelect("replyUser.avatar", "replyUserAvatar") - .leftJoinAndSelect("replyUser.banner", "replyUserBanner") - .leftJoinAndSelect("renote.user", "renoteUser") - .leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar") - .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner"); - - generateVisibilityQuery(query, me); - if (me) generateMutedUserQuery(query, me); - if (me) generateBlockedUserQuery(query, me); - - const notes: Note[] = await query.take(ps.limit).getMany(); - return await Notes.packMany(notes, me); }); diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index fc7f1265b3..fc9913e985 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -31,6 +31,7 @@ import { Channels, ChannelFollowings, NoteThreadMutings, + NoteFiles, } from "@/models/index.js"; import type { DriveFile } from "@/models/entities/drive-file.js"; import type { App } from "@/models/entities/app.js"; @@ -343,6 +344,12 @@ export default async ( const note = await insertNote(user, data, tags, emojis, mentionedUsers); + await NoteFiles.insert( + note.fileIds.map((fileId) => ({ noteId: note.id, fileId })), + ).catch((e) => { + logger.error(inspect(e)); + }); + res(note); // Register host diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts index 53d2062e15..c413fa37e4 100644 --- a/packages/client/src/init.ts +++ b/packages/client/src/init.ts @@ -40,7 +40,6 @@ import { i18n } from "@/i18n"; import { fetchInstance, instance } from "@/instance"; import { isSignedIn, me } from "@/me"; import { alert, api, confirm, popup, post, toast } from "@/os"; -import { compareFirefishVersions } from "@/scripts/compare-versions"; import { deviceKind } from "@/scripts/device-kind"; import { getAccountFromId } from "@/scripts/get-account-from-id"; import { makeHotkey } from "@/scripts/hotkey"; @@ -246,11 +245,7 @@ function checkForSplash() { try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため - if ( - lastVersion != null && - compareFirefishVersions(lastVersion, version) === 1 && - defaultStore.state.showUpdates - ) { + if (lastVersion < version && defaultStore.state.showUpdates) { // ログインしてる場合だけ if (me) { popup( diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue index 53cdad840f..701a63e6c2 100644 --- a/packages/client/src/pages/admin/index.vue +++ b/packages/client/src/pages/admin/index.vue @@ -85,7 +85,6 @@ import { provideMetadataReceiver, } from "@/scripts/page-metadata"; import icon from "@/scripts/icon"; -import { compareFirefishVersions } from "@/scripts/compare-versions"; const isEmpty = (x: string | null) => x == null || x === ""; const el = ref(null); @@ -122,8 +121,7 @@ os.api("admin/abuse-user-reports", { if (defaultStore.state.showAdminUpdates) { os.api("latest-version").then((res) => { - updateAvailable.value = - compareFirefishVersions(version, res?.latest_version) === 1; + updateAvailable.value = version < res?.latest_version; }); } diff --git a/packages/client/src/scripts/compare-versions.ts b/packages/client/src/scripts/compare-versions.ts deleted file mode 100644 index 7b232db838..0000000000 --- a/packages/client/src/scripts/compare-versions.ts +++ /dev/null @@ -1,19 +0,0 @@ -const less = -1; -const same = 0; -const more = 1; - -export const compareFirefishVersions = ( - oldVersion: string, - newVersion: string, -) => { - if (oldVersion === newVersion) return same; - - const o = oldVersion.split("-"); - const n = newVersion.split("-"); - - if (o[0] < n[0]) return more; - if (o[0] === n[0] && o[1] == null && n[1] != null) return more; - if (o[0] === n[0] && o[1] != null && n[1] != null && o[1] < n[1]) return more; - - return less; -}; diff --git a/packages/client/src/ui/_common_/navbar.vue b/packages/client/src/ui/_common_/navbar.vue index e2937528de..0251a38c6a 100644 --- a/packages/client/src/ui/_common_/navbar.vue +++ b/packages/client/src/ui/_common_/navbar.vue @@ -162,7 +162,6 @@ import { i18n } from "@/i18n"; import { instance } from "@/instance"; import { version } from "@/config"; import icon from "@/scripts/icon"; -import { compareFirefishVersions } from "@/scripts/compare-versions"; const isEmpty = (x: string | null) => x == null || x === ""; @@ -212,8 +211,7 @@ if (isAdmin) { if (defaultStore.state.showAdminUpdates) { os.api("latest-version").then((res) => { - updateAvailable.value = - compareFirefishVersions(version, res?.latest_version) === 1; + updateAvailable.value = version < res?.latest_version; }); }