diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index e75e32a17a..a028e2685e 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -165,6 +165,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 1ffed00cc7..4be1352bd7 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -177,6 +177,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/.config/example.yml b/.config/example.yml index 71427c84bc..d4584215c9 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -259,6 +259,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 3eb4fc2879..6d904e87b9 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -152,6 +152,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 0deefbf166..e17ce2a889 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -11,14 +11,15 @@ on: # Storybook CI is checked on the "push" event of "develop" branch so it would cause a duplicate build. # This is a waste of chromatic build quota, so we don't run storybook CI on pull requests targets master. - master - # Neither Dependabot nor Renovate will change the actual behavior for components. - - dependabot/** - - renovate/** jobs: build: - # chromatic is not likely to be available for fork repositories, so we disable for fork repositories. - if: github.repository == 'misskey-dev/misskey' + # Chromatic is not likely to be available for fork repositories, so we disable for fork repositories. + # Neither Dependabot nor Renovate will change the actual behavior for components. + if: >- + github.repository == 'misskey-dev/misskey' && + startsWith(github.head_ref, 'refs/heads/dependabot/') != true && + startsWith(github.head_ref, 'refs/heads/renovate/') != true runs-on: ubuntu-latest env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc4636120..770e5cef13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,23 @@ +## Unreleased + +### General +- + +### Client +- Fix: ログアウトした際に処理が終了しない問題を修正 +- Fix: 自動バックアップが設定されている環境でログアウト直前に設定をバックアップするように + +### Server +- Enhance: フォローしているユーザーならフォロワー限定投稿のノートでもアンテナで検知できるように + (Cherry-picked from https://github.com/yojo-art/cherrypick/pull/568 and https://github.com/team-shahu/misskey/pull/38) +- Fix: システムアカウントの名前がサーバー名と同期されない問題を修正 +- Fix: 大文字を含むユーザの URL で紹介された場合に 404 エラーを返す問題 #15813 +- Fix: リードレプリカ設定時にレコードの追加・更新・削除を伴うクエリを発行した際はmasterノードで実行されるように調整( #10897 ) + ## 2025.4.0 ### General -- Feat: チャットがリニューアルして復活しました(beta) +- Feat: チャット(ダイレクトメッセージ)がリニューアルして復活しました - 既存のDM機能よりも便利で効率的な実装になっています - チャットを受け付ける相手を制限可能です - 誰でも / フォローユーザーのみ / フォロワーのみ / 相互のみ / 受け付けない から選択できます @@ -11,9 +27,17 @@ - 過去自分が送ったメッセージ・自分に送られたメッセージの検索が可能です - 参加中のルームをミュートして通知が来ないように設定可能です - メッセージにはリアクションも可能です + - 現在、リモートユーザーがチャットを受け付ける設定になっているかどうかを取得する術がないため、ローカルユーザー間でのみ利用可能です +- Feat: アカウントの移行時に古いアカウントからあたらしいアカウントにロールをコピーできるようになりました。 + - 管理者がロールの設定でマイグレーション時にコピーするかを指定できるようになります。 - Enhance: セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。 - Misskeyネイティブでダッシュボードを実装予定です +- Enhance: フロントエンドのエラートラッキングができるように + - `.config/default.yml`中の項目`sentryForFrontend`を適宜設定してください。 + - 外部サービスであるSentryへエラー情報が送信されます。ご利用の地域の法令に従い、適切なプライバシーポリシーを策定の上で運用してください。 - Enhance: ミュートしているユーザーをユーザー検索の結果から除外するように +- Enhance: アンテナでセンシティブなチャンネルのノートを除外できるように `#14177` +- Fix: 通知のページネーションで2つ以上読み込めなくなることがある問題を修正 ### Client - Feat: 設定の管理が強化されました @@ -34,6 +58,7 @@ - 再度ログインすればサーバーのバックアップから設定データを復元可能です - エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました - 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です + - 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください - Feat: 画面を重ねて表示するオプションを実装(実験的) - 設定 → その他 → 実験的機能 → Enable stacking router view - Enhance: プラグインの管理が強化されました @@ -52,8 +77,11 @@ - Enhance: 2段階認証時のリカバリーコードのファイル名にサーバーURLを含めるように - Enhance: 全体的なブラッシュアップ - Enhance 全体的なパフォーマンス向上 +- Enhance: ファイルのアップロードでデフォルトで圧縮するかどうかのオプションが廃止され、アップロード時に圧縮するかどうかを選択するようになりました + - 画像データの貼り付け、ドロップ時は圧縮されるようになりました - Fix: 読み込み直後にスクロールしようとすると途中で止まる場合があるのを修正 - Fix: テーマ切り替え時に一部の色が変わらない問題を修正 +- Fix: iPadOSでdeck uiをマウスカーソルによってスクロールできない問題を修正 - NOTE: 構造上クラシックUIを新しいデザインシステムに移行することが困難なため、クラシックUIが削除されました - デッキUIでカラムを中央寄せにし、メインカラムの左右にウィジェットカラムを配置し、ナビゲーションバーを上部に表示することである程度クラシックUIを再現できます @@ -63,6 +91,7 @@ - Fix: ActivityPubリクエストURLチェック実装は仕様に従っていないのを修正 - Fix: 連合無しモードでも外部から照会可能だった問題を修正 - Fix: テスト用WebHookのペイロードの`emojis`パラメータが実際のものと異なる問題を修正 +- Fix: 非ログインでタイムラインのストリームに接続した際、表示にログイン必須のノートが流れる場合がある問題を修正 ## 2025.3.1 diff --git a/chart/files/default.yml b/chart/files/default.yml index 4d17131c25..06f762aafa 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -173,6 +173,11 @@ id: "aidx" # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index a792a6804d..adc37c2414 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1240,7 +1240,6 @@ _theme: shadow: "الظل" navBg: "خلفية الشريط الجانبي" navFg: "نص الشريط الجانبي" - navHoverFg: "نص الشريط الجانبي (عند التمرير فوقه)" link: "رابط" hashtag: "وسم" mention: "أشر الى" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 4a55c91006..442fed6e21 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -998,7 +998,6 @@ _theme: header: "হেডার" navBg: "সাইডবারের পটভূমি" navFg: "সাইডবারের পাঠ্য" - navHoverFg: "সাইডবারের পাঠ্য (হভার)" navActive: "সাইডবারের পাঠ্য (অ্যাকটিভ)" navIndicator: "সাইডবারের ইনডিকেটর" link: "লিংক" @@ -1021,11 +1020,8 @@ _theme: buttonHoverBg: "বাটনের পটভূমি (হভার)" inputBorder: "ইনপুট ফিল্ডের বর্ডার" driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি" - wallpaperOverlay: "ওয়ালপেপার ওভারলে" badge: "ব্যাজ" messageBg: "চ্যাটের পটভূমি" - accentDarken: "অ্যাকসেন্ট (গাঢ়)" - accentLighten: "অ্যাকসেন্ট (হাল্কা)" fgHighlighted: "হাইলাইট করা পাঠ্য" _sfx: note: "নোটগুলি" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index b0d32c4fc1..8f83a33356 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "Exclou els bots" antennaKeywordsDescription: "Separar amb espais per la condició AND o amb salts de línia per la condició OR." notifyAntenna: "Notifica'm les publicacions noves" withFileAntenna: "Només les publicacions amb fitxers" +excludeNotesInSensitiveChannel: "Excloure notes a canals sensibles" enableServiceworker: "Activar les notificacions al navegador" antennaUsersDescription: "Llistar un nom d'usuari per línia" caseSensitive: "Sensible a majúscules i minúscules " @@ -536,7 +537,7 @@ mediaListWithOneImageAppearance: "Altura de la llista de fitxers amb una única limitTo: "Limita a {x}" noFollowRequests: "No tens sol·licituds de seguiment" openImageInNewTab: "Obre imatges a una nova pestanya" -dashboard: "Taulell de control" +dashboard: "Tauler de control" local: "Local" remote: "Remot" total: "Total" @@ -651,7 +652,7 @@ manage: "Administració" plugins: "Extensions" preferencesBackups: "Configuracions de les Còpies de seguretat" deck: "Escriptori" -undeck: "Tanca l'escriptori" +undeck: "Tanca el tauler" useBlurEffectForModal: "Utilitzar l'efecte de difuminació a modals" useFullReactionPicker: "Utilitza el cercador de reaccions d'escala sencera" width: "Amplada" @@ -914,7 +915,7 @@ off: "Desactivar" emailRequiredForSignup: "Demanar correu electrònic per registrar-se " unread: "Sense llegir" filter: "Filtrar" -controlPanel: "Taulell de control" +controlPanel: "Tauler de control" manageAccounts: "Gestionar comptes" makeReactionsPublic: "Reaccions públiques " makeReactionsPublicDescription: "Això fa que totes les teves reaccions siguin visibles públicament " @@ -1339,6 +1340,10 @@ compress: "Comprimir " right: "Dreta" bottom: "A baix " top: "A dalt " +embed: "Incrustar" +settingsMigrating: "Estem fent la migració de la teva configuració. Si us plau espera un moment... (També pots fer la migració més tard i manualment anant a Preferències → Altres configuracions → Migrar configuració antiga)" +readonly: "Només lectura" +goToDeck: "Tornar al tauler" _chat: noMessagesYet: "Encara no tens missatges " newMessage: "Missatge nou" @@ -1368,6 +1373,7 @@ _chat: muteThisRoom: "Silenciar aquesta sala" deleteRoom: "Esborrar la sala" chatNotAvailableForThisAccountOrServer: "El xat no està disponible per aquest servidor o aquest compte." + chatIsReadOnlyForThisAccountOrServer: "El xat és només de lectura en aquest servidor o compte. No es poden escriure nous missatges ni crear o unir-se a sales de xat." chatNotAvailableInOtherAccount: "La funció de xat es troba desactivada al compte de l'altre usuari." cannotChatWithTheUser: "No pots xatejar amb aquest usuari" cannotChatWithTheUser_description: "El xat està desactivat o l'altra part encara no l'ha obert." @@ -1413,6 +1419,7 @@ _settings: showNavbarSubButtons: "Mostrar sub botons a la barra de navegació " ifOn: "Quan s'encén " ifOff: "Quan s'apaga " + enableSyncThemesBetweenDevices: "Sincronitzar els temes instal·lats entre dispositius" _chat: showSenderName: "Mostrar el nom del remitent" sendOnEnter: "Introdueix per enviar" @@ -1885,6 +1892,8 @@ _role: descriptionOfIsExplorable: "La línia de temps d'aquest rol i la llista d'usuaris seran públics si s'activa." displayOrder: "Posició " descriptionOfDisplayOrder: "Com més gran és el número, més dalt la seva posició a la interfície." + preserveAssignmentOnMoveAccount: "L'estat de l'assignació també es trasllada amb el compte migrat" + preserveAssignmentOnMoveAccount_description: "Si s'activa quan es migra un compte amb aquest rol, el compte migrat també heretarà aquest rol." canEditMembersByModerator: "Permetre que els moderadors editin la llista d'usuaris en aquest rol" descriptionOfCanEditMembersByModerator: "Quan s'activa, els moderadors, així com els administradors, podran afegir i treure usuaris d'aquest rol. Si es troba desactivat, només els administradors poden assignar usuaris." priority: "Prioritat" @@ -1925,7 +1934,7 @@ _role: canImportFollowing: "Autoritza la importació de seguidors" canImportMuting: "Autoritza la importació de silenciats" canImportUserLists: "Autoritza la importació de llistes d'usuaris " - canChat: "Pot xatejar" + chatAvailability: "Es permet xatejar" _condition: roleAssignedTo: "Assignat a rols manuals" isLocal: "Usuari local" @@ -2117,12 +2126,11 @@ _theme: fg: "Text" focus: "Enfocament" indicator: "Indicador" - panel: "Taulell " + panel: "Tauler" shadow: "Ombra" header: "Capçalera" navBg: "Fons de la barra lateral" navFg: "Text de la barra lateral" - navHoverFg: "Text barra lateral (en passar per sobre)" navActive: "Text barra lateral (actiu)" navIndicator: "Indicador barra lateral" link: "Enllaç" @@ -2145,11 +2153,8 @@ _theme: buttonHoverBg: "Fons botó (en passar-hi per sobre)" inputBorder: "Contorn del cap d'introducció " driveFolderBg: "Fons de la carpeta Disc" - wallpaperOverlay: "Superposició del fons de pantalla " badge: "Insígnia " messageBg: "Fons del xat" - accentDarken: "Accent (fosc)" - accentLighten: "Accent (clar)" fgHighlighted: "Text ressaltat" _sfx: note: "Notes" @@ -2599,7 +2604,7 @@ _deck: columnGap: "Espai entre columnes" deckMenuPosition: "Posició del menú del tauler" navbarPosition: "Posició de la barra de navegació " - addColumn: "Afig una columna" + addColumn: "Afegeix una columna" newNoteNotificationSettings: "Configuració de notificacions per a notes noves" configureColumn: "Configuració de columnes" swapLeft: "Mou a l’esquerra" @@ -2794,7 +2799,7 @@ _hemisphere: _reversi: reversi: "Reversi" gameSettings: "Opcions del joc" - chooseBoard: "Escull un taulell" + chooseBoard: "Escull un tauler" blackOrWhite: "Negres/Blanques" blackIs: "{name} juga amb negres " rules: "Regles" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index c5c8c0f860..87f9445409 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -1626,7 +1626,6 @@ _theme: header: "Nadpis" navBg: "Pozadí postranního panelu" navFg: "Text na postranním panelu" - navHoverFg: "Text na postranním panelu (Hover)" navActive: "Text na postranním panelu (Aktivní)" navIndicator: "Indikátor na postranním panelu" link: "Odkaz" @@ -1649,11 +1648,8 @@ _theme: buttonHoverBg: "Pozadí tlačítka (Hover)" inputBorder: "Ohraničení vstupního pole" driveFolderBg: "Pozadí složky disku" - wallpaperOverlay: "Překrytí tapety" badge: "Odznak" messageBg: "Pozadí chatu" - accentDarken: "Akcent (Ztmavený)" - accentLighten: "Akcent (Zesvětlený)" fgHighlighted: "Zvýrazněný text" _sfx: note: "Poznámky" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 8dea7518cb..b953a5717e 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "Bot-Accounts ausschließen" antennaKeywordsDescription: "Zum Nutzen einer \"UND\"-Verknüpfung Einträge mit Leerzeichen trennen, zum Nutzen einer \"ODER\"-Verknüpfung Einträge mit einem Zeilenumbruch trennen" notifyAntenna: "Über neue Notizen benachrichtigen" withFileAntenna: "Nur Notizen mit Dateien" +excludeNotesInSensitiveChannel: "Schließe Notizen von sensitive Kanäle aus" enableServiceworker: "Push-Benachrichtigungen im Browser aktivieren" antennaUsersDescription: "Benutzernamen getrennt durch Zeilenumbrüche angeben" caseSensitive: "Groß-/Kleinschreibung unterscheiden" @@ -1304,6 +1305,7 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "Logge dich ein, um weitere Inhal lockdown: "Sperren" pleaseSelectAccount: "Bitte Konto auswählen" availableRoles: "Verfügbare Rollen" +acknowledgeNotesAndEnable: "Schalten Sie dies erst ein, wenn Sie die Vorsichtsmaßnahmen verstanden haben." federationSpecified: "Dieser Server arbeitet mit Whitelist-Föderation. Er kann nicht mit anderen als den vom Administrator angegebenen Servern interagieren." federationDisabled: "Föderation ist auf diesem Server deaktiviert. Es ist nicht möglich, mit Benutzern auf anderen Servern zu interagieren." confirmOnReact: "Reagieren bestätigen" @@ -1311,9 +1313,11 @@ reactAreYouSure: "Willst du eine \"{emoji}\"-Reaktion hinzufügen?" markAsSensitiveConfirm: "Möchtest du dieses Medium als sensibel kennzeichnen?" unmarkAsSensitiveConfirm: "Möchtest du die Kennzeichnung dieses Mediums als sensibel aufheben?" preferences: "Einstellungen" +accessibility: "Eingabehilfe" preferencesProfile: "Einstellungsprofil" copyPreferenceId: "Kopiere die Einstellungs-ID" resetToDefaultValue: "Auf Standard zurücksetzen" +overrideByAccount: "Überschreibung durch das Konto" untitled: "Unbenannt" noName: "Kein Name" skip: "Überspringen" @@ -1333,15 +1337,24 @@ chat: "Chat" migrateOldSettings: "Alte Client-Einstellungen migrieren" migrateOldSettings_description: "Dies sollte normalerweise automatisch geschehen, aber wenn die Migration aus irgendeinem Grund nicht erfolgreich war, kannst du den Migrationsprozess selbst manuell auslösen. Die aktuellen Konfigurationsinformationen werden dabei überschrieben." compress: "Komprimieren" +right: "Rechts" +bottom: "Unten" +top: "Oben" +embed: "Einbetten" +settingsMigrating: "Ihre Einstellungen werden gerade migriert, Bitte warten Sie einen Moment... (Sie können die Einstellungen später auch manuell migrieren, indem Sie zu Einstellungen → Sonstiges → Alte Einstellungen migrieren gehen)" +readonly: "Nur Lesezugriff" +goToDeck: "Zurück zum Deck" _chat: noMessagesYet: "Noch keine Nachrichten" newMessage: "Neue Nachricht" individualChat: "Privater Chat" individualChat_description: "Führe einen privaten Chat mit einer anderen Person." + roomChat: "Chatraum" roomChat_description: "Ein Chat-Raum, an dem mehrere Personen teilnehmen können.\nDu kannst auch Personen einladen, die keine privaten Chats zulassen, wenn sie die Einladung annehmen." createRoom: "Raum erstellen" inviteUserToChat: "Lade Benutzer ein, um mit dem Chatten zu beginnen" yourRooms: "Erstellte Räume" + joiningRooms: "Raum beitreten" invitations: "Einladen" noInvitations: "Keine Einladungen" history: "Verlauf" @@ -1360,6 +1373,7 @@ _chat: muteThisRoom: "Raum stummschalten" deleteRoom: "Raum löschen" chatNotAvailableForThisAccountOrServer: "Der Chat ist auf diesem Server oder für dieses Konto nicht aktiviert." + chatIsReadOnlyForThisAccountOrServer: "Der Chat ist auf dieser Instanz oder diesem Konto nur zum Lesen freigegeben. Es ist nicht möglich, neue Nachrichten zu schreiben oder Chaträume zu erstellen oder zu betreten." chatNotAvailableInOtherAccount: "Die Chatfunktion wurde vom anderen Benutzer deaktiviert." cannotChatWithTheUser: "Starten eines Chats mit diesem Benutzer nicht möglich" cannotChatWithTheUser_description: "Der Chat ist entweder nicht verfügbar oder die andere Seite hat den Chat nicht aktiviert." @@ -1384,8 +1398,10 @@ _emojiPalette: _settings: driveBanner: "Du kannst den Drive verwalten und konfigurieren, die Auslastung überprüfen und Einstellungen für das Hochladen von Dateien vornehmen." pluginBanner: "Du kannst die Funktionen des Clients mit Plugins erweitern. Plugins können installiert, individuell konfiguriert und verwaltet werden." + notificationsBanner: "Sie können die Arten und den Umfang der Benachrichtigungen vom Server und der Push- Mitteilungen konfigurieren." api: "API" webhook: "Webhook" + serviceConnection: "Integrierte Dienste" serviceConnectionBanner: "Du kannst Zugriffstoken und Webhooks für die Integration mit externen Anwendungen und Diensten verwalten und konfigurieren." accountData: "Kontodaten" accountDataBanner: "Export/Import und Verwaltung von Kontodatenarchiven." @@ -1393,13 +1409,17 @@ _settings: accessibilityBanner: "Die Clients können personalisiert und für eine optimale Nutzung im Hinblick auf ihre Darstellung und ihr Verhalten eingerichtet werden." privacyBanner: "Du kannst Einstellungen für die Privatsphäre deines Kontos vornehmen, z. B. inwieweit Inhalte veröffentlicht werden, wie leicht sie zu finden sind und ob Follower genehmigt werden müssen." securityBanner: "Du kannst Einstellungen für die Kontosicherheit konfigurieren, z. B. Passwörter, Anmeldemethoden, Authentifizierungs-Apps und Passkeys." + preferencesBanner: "Sie können das Gesamtverhalten des Clients nach Ihren Wünschen konfigurieren." appearanceBanner: "Du kannst das Erscheinungsbild und die Anzeigeeinstellungen für den Client nach deinen Wünschen konfigurieren." soundsBanner: "Du kannst die Einstellungen für die Wiedergabe von Klängen im Client konfigurieren." timelineAndNote: "Chroniken und Notizen" makeEveryTextElementsSelectable: "Alle Textelemente auswählbar machen" makeEveryTextElementsSelectable_description: "Die Aktivierung kann in manchen Situationen die Benutzerfreundlichkeit beeinträchtigen." + useStickyIcons: "Icons beim Scrollen folgen lassen" + showNavbarSubButtons: "Unterschaltflächen in der Navigationsleiste anzeigen" ifOn: "Wenn eingeschaltet" ifOff: "Wenn ausgeschaltet" + enableSyncThemesBetweenDevices: "Synchronisierung von installierten Themen auf verschiedenen Endgeräten" _chat: showSenderName: "Name des Absenders anzeigen" sendOnEnter: "Eingabetaste sendet Nachricht" @@ -1427,6 +1447,7 @@ _accountSettings: makeNotesHiddenBeforeDescription: "" mayNotEffectForFederatedNotes: "Dies hat möglicherweise keine Auswirkungen auf Notizen, die an andere Server föderiert werden." mayNotEffectSomeSituations: "Diese Einschränkungen sind vereinfacht. Sie gelten möglicherweise nicht in allen Situationen, z. B. bei der Anzeige auf einem fremden Server oder während der Moderation." + notesHavePassedSpecifiedPeriod: "Notizen die nach der folgenden Zeit veröffentlicht worden" notesOlderThanSpecifiedDateAndTime: "Notizen vor einem bestimmtem Datum und Uhrzeit" _abuseUserReport: forward: "Weiterleiten" @@ -1438,6 +1459,7 @@ _abuseUserReport: _delivery: status: "Auslieferungsstatus" stop: "Gesperrt" + resume: "Zustellung wieder fortsetzen" _type: none: "Wird veröffentlicht" manuallySuspended: "Manuell gesperrt" @@ -1519,6 +1541,7 @@ _initialTutorial: description2: "Du kannst jederzeit am oberen Rand des Bildschirms zwischen den jeweiligen Chroniken wechseln." description3: "Darüber hinaus gibt es Listen-Chroniken und Kanal-Chroniken. Weitere Einzelheiten findest du unter {link}." _postNote: + title: "Optionen bei Abschicken einer Notiz" description1: "Wenn du eine Notiz auf Misskey veröffentlichst, stehen dir verschiedene Optionen zur Verfügung. Die Oberfläche sieht folgendermaßen aus." _visibility: description: "Du kannst einschränken, wer deine Notiz sehen kann." @@ -1839,6 +1862,7 @@ _achievements: _bubbleGameDoubleExplodingHead: title: "Doppel🤯" description: "Zwei der größten Objekte im Bubble Game zur gleichen Zeit" + flavor: "Eine Lunchbox kann man auch mit etwas mehr 🤯 🤯 füllen" _role: new: "Rolle erstellen" edit: "Rolle bearbeiten" @@ -1868,6 +1892,8 @@ _role: descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Chronik dieser Rolle, sowie eine Liste der Benutzer mit dieser Rolle, frei zugänglich." displayOrder: "Position" descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position." + preserveAssignmentOnMoveAccount: "Rolle übertragbar machen" + preserveAssignmentOnMoveAccount_description: "Wenn diese Option aktiviert ist, wird diese Rolle bei der Migration mit übertragen." canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen" descriptionOfCanEditMembersByModerator: "Wenn aktiviert, so können Moderatoren und Adminstratoren anderen Benutzern diese Rolle zuweisen bzw. diese Zuweisung aufheben. Wenn deaktiviert, so ist es nur Administratoren möglich, Zuweisungen dieser Rolle zu verwalten." priority: "Priorität" @@ -1908,7 +1934,7 @@ _role: canImportFollowing: "Importieren von Gefolgten zulassen" canImportMuting: "Importieren von Stummgeschalteten zulassen" canImportUserLists: "Importieren von Listen erlauben" - canChat: "Chatten erlauben" + chatAvailability: "Chatten erlauben" _condition: roleAssignedTo: "Manuellen Rollen zugewiesen" isLocal: "Lokaler Benutzer" @@ -2105,7 +2131,6 @@ _theme: header: "Kopfzeile" navBg: "Hintergrund der Seitenleiste" navFg: "Text der Seitenleiste" - navHoverFg: "Text der Seitenleiste (Mouseover)" navActive: "Text der Seitenleiste (Aktiv)" navIndicator: "Indikator der Seitenleiste" link: "Link" @@ -2128,11 +2153,8 @@ _theme: buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)" inputBorder: "Rahmen von Eingabefeldern" driveFolderBg: "Hintergrund von Drive-Ordnern" - wallpaperOverlay: "Hintergrundbild-Overlay" badge: "Wappen" messageBg: "Hintergrund von Chats" - accentDarken: "Akzent (Verdunkelt)" - accentLighten: "Akzent (Erhellt)" fgHighlighted: "Hervorgehobener Text" _sfx: note: "Notizen" @@ -2267,12 +2289,14 @@ _permissions: "read:admin:announcements": "Ankündigungen einsehen" "write:admin:avatar-decorations": "Kann Avatar-Dekorationen verwalten" "read:admin:avatar-decorations": "Avatar-Dekorationen ansehen" + "write:admin:federation": "Informationen über Föderationen bearbeiten oder löschen" "write:admin:account": "Benutzerkonten verwalten" "read:admin:account": "Benutzerkonten anzeigen" "write:admin:emoji": "Emojis verwalten" "read:admin:emoji": "Emojis anzeigen" "write:admin:queue": "Job-Warteschlange verwalten" "read:admin:queue": "Job-Warteschlange anzeigen" + "write:admin:promo": "Moderationsnotiz hinzufügen" "write:admin:drive": "Benutzer-Drive verwalten" "read:admin:drive": "Benutzer-Drive ansehen" "read:admin:stream": "Verwendung der Websocket-API für Administratoren" @@ -2280,6 +2304,8 @@ _permissions: "read:admin:ad": "Werbung ansehen" "write:invite-codes": "Einladungscodes erstellen" "read:invite-codes": "Einladungscodes anzeigen" + "write:clip-favorite": "Clip-Likes bearbeiten oder löschen" + "read:clip-favorite": "Clip-Likes ansehen" "read:federation": "Informationen zur Föderation einsehen" "write:report-abuse": "Verstöße melden" "write:chat": "Chats bedienen" @@ -2294,6 +2320,7 @@ _auth: callback: "Es wird zur Anwendung zurückgekehrt" accepted: "Zugriff gewährt" denied: "Zugriff verweigert" + scopeUser: "Als folgender Benutzer agieren" pleaseLogin: "Bitte logge dich ein, um Apps zu authorisieren." byClickingYouWillBeRedirectedToThisUrl: "Wenn der Zugang gewährt wird, wirst du automatisch zu folgender URL weitergeleitet" _antennaSources: @@ -2546,6 +2573,7 @@ _notification: exportOfXCompleted: "Der Export von {x} ist abgeschlossen" login: "Neue Anmeldung erfolgt" createToken: "Ein Zugangstoken wurde erstellt" + createTokenDescription: "Wenn Sie keine Ahnung haben, löschen Sie das Zugriffstoken über \"{text}\"" _types: all: "Alle" note: "Neue Notizen" @@ -2573,6 +2601,9 @@ _notification: _deck: alwaysShowMainColumn: "Hauptspalte immer zeigen" columnAlign: "Spaltenausrichtung" + columnGap: "Spaltenabstand" + deckMenuPosition: "Position des Deck-Menüs" + navbarPosition: "Position der Navigationsleiste" addColumn: "Spalte hinzufügen" newNoteNotificationSettings: "Benachrichtigungseinstellungen für neue Notizen" configureColumn: "Spalteneinstellungen" @@ -2795,6 +2826,9 @@ _reversi: allGames: "Alle Runden" ended: "Beendet" playing: "Partie läuft" + isLlotheo: "Der mit weniger Steinen gewinnt (Llotheo)" + loopedMap: "Wiederholendes Spielbrett" + canPutEverywhere: "Steine können überall platziert werden" timeLimitForEachTurn: "Zeitlimit eines Zugs" freeMatch: "Freies Spiel" lookingForPlayer: "Gegner werden gesucht..." @@ -2845,6 +2879,7 @@ _customEmojisManager: copySelectionRows: "Ausgewählte Zeilen kopieren" copySelectionRanges: "Auswahl kopieren" deleteSelectionRows: "Ausgewählte Zeilen löschen" + deleteSelectionRanges: "Zeilen in der Auswahl löschen" searchSettings: "Sucheinstellungen" searchSettingCaption: "Detaillierte Suchkriterien festlegen." searchLimit: "Anzahl der Ergebnisse" @@ -2853,10 +2888,12 @@ _customEmojisManager: registrationLogsCaption: "Protokolle werden beim Aktualisieren oder Löschen von Emojis angezeigt. Sie verschwinden nach dem Aktualisieren oder Löschen, dem Wechsel zu einer neuen Seite oder dem Neuladen." alertEmojisRegisterFailedDescription: "Emoji konnte nicht aktualisiert oder gelöscht werden. Bitte prüfe das Registrierungsprotokoll für Details." _logs: + showSuccessLogSwitch: "Erfolgsprotokoll zeigen" failureLogNothing: "Es gibt kein Fehlerprotokoll." logNothing: "Keine Protokoll-Einträge." _remote: selectionRowDetail: "Details der ausgewählten Zeile" + importSelectionRows: "Ausgewählte Zeilen importieren" importSelectionRangesRows: "Zeilen in der Auswahl importieren" importEmojisButton: "Ausgewählte Emojis importieren" confirmImportEmojisTitle: "Emojis importieren" @@ -2866,15 +2903,23 @@ _customEmojisManager: tabTitleRegister: "Emojis hinzufügen" _list: emojisNothing: "Es wurden keine Emojis hinzugefügt." + markAsDeleteTargetRows: "Ausgewählte Zeilen als zu löschendes Element markieren" + markAsDeleteTargetRanges: "Zeilen in der Auswahl als zu löschendes Element markieren" alertUpdateEmojisNothingDescription: "Es wurden keine Emojis geändert." alertDeleteEmojisNothingDescription: "Es gibt keine zu löschenden Emojis." + confirmMovePage: "Möchten Sie die Seiten verschieben?" + confirmChangeView: "Möchten Sie die Darstellung wechseln?" confirmUpdateEmojisDescription: "Aktualisiere {count} Emoji(s). Willst du fortfahren?" confirmDeleteEmojisDescription: "Lösche {count} ausgewählte Emoji(s). Willst du fortfahren?" + confirmResetDescription: "Alle bisher vorgenommenen Änderungen werden zurückgesetzt." confirmMovePageDesciption: "An den Emojis auf dieser Seite wurden Änderungen vorgenommen.\nWenn du die Seite verlässt, ohne zu speichern, werden alle auf dieser Seite vorgenommenen Änderungen verworfen." + dialogSelectRoleTitle: "Suche nach dem Rollensatz in Emojis" _register: + uploadSettingTitle: "Upload-Einstellungen" uploadSettingDescription: "Hier kannst du das Verhalten beim Hochladen von Emojis konfigurieren." directoryToCategoryLabel: "Gib den Namen des Verzeichnisses in das Feld „Kategorie“ ein" directoryToCategoryCaption: "Wenn du ein Verzeichnis ziehst und ablegst, gib den Verzeichnisnamen in das Feld „Kategorie“ ein." + emojiInputAreaCaption: "Wählen Sie die Emojis aus, die Sie mit einer der folgenden Methoden speichern möchten." emojiInputAreaList1: "Ziehe Bilddateien oder Verzeichnisse per Drag-and-drop in diesen Rahmen" emojiInputAreaList2: "Klicke auf diesen Link, um von deinem PC aus zu wählen" emojiInputAreaList3: "Klicke auf diesen Link, um vom Drive aus zu wählen" diff --git a/locales/en-US.yml b/locales/en-US.yml index 8c2e5f5221..02cc24a8b3 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "Exclude bot accounts" antennaKeywordsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition." notifyAntenna: "Notify about new notes" withFileAntenna: "Only notes with files" +excludeNotesInSensitiveChannel: "Exclude notes from sensitive channels" enableServiceworker: "Enable Push-Notifications for your Browser" antennaUsersDescription: "List one username per line" caseSensitive: "Case sensitive" @@ -1339,6 +1340,10 @@ compress: "Compress" right: "Right" bottom: "Bottom" top: "Top" +embed: "Embed" +settingsMigrating: "Settings are being migrated, please wait a moment... (You can also migrate manually later by going to Settings→Others→Migrate old settings)" +readonly: "Read only" +goToDeck: "Return to Deck" _chat: noMessagesYet: "No messages yet" newMessage: "New message" @@ -1368,6 +1373,7 @@ _chat: muteThisRoom: "Mute room" deleteRoom: "Delete room" chatNotAvailableForThisAccountOrServer: "Chat is not enabled on this server or for this account." + chatIsReadOnlyForThisAccountOrServer: "Chat is read-only on this instance or this account. You cannot write new messages or create/join chat rooms." chatNotAvailableInOtherAccount: "The chat function is disabled for the other user." cannotChatWithTheUser: "Cannot start a chat with this user" cannotChatWithTheUser_description: "Chat is either unavailable or the other party has not enabled chat." @@ -1413,6 +1419,7 @@ _settings: showNavbarSubButtons: "Show sub-buttons on the navigation bar" ifOn: "When turned on" ifOff: "When turned off" + enableSyncThemesBetweenDevices: "Synchronize installed themes across devices" _chat: showSenderName: "Show sender's name" sendOnEnter: "Press Enter to send" @@ -1885,6 +1892,8 @@ _role: descriptionOfIsExplorable: "This role's timeline and the list of users with this will be made public if enabled." displayOrder: "Position" descriptionOfDisplayOrder: "The higher the number, the higher its UI position." + preserveAssignmentOnMoveAccount: "Preserve role assignment during migration" + preserveAssignmentOnMoveAccount_description: "When turned on, this role will be carried over to the destination account when an account with this role is migrated." canEditMembersByModerator: "Allow moderators to edit the list of members for this role" descriptionOfCanEditMembersByModerator: "When turned on, moderators as well as administrators will be able to assign and unassign users to this role. When turned off, only administrators will be able to assign users." priority: "Priority" @@ -1925,7 +1934,7 @@ _role: canImportFollowing: "Allow importing following" canImportMuting: "Allow importing muting" canImportUserLists: "Allow importing lists" - canChat: "Allow Chat" + chatAvailability: "Allow Chat" _condition: roleAssignedTo: "Assigned to manual roles" isLocal: "Local user" @@ -2122,7 +2131,6 @@ _theme: header: "Header" navBg: "Sidebar background" navFg: "Sidebar text" - navHoverFg: "Sidebar text (Hover)" navActive: "Sidebar text (Active)" navIndicator: "Sidebar indicator" link: "Link" @@ -2145,11 +2153,8 @@ _theme: buttonHoverBg: "Button background (Hover)" inputBorder: "Input field border" driveFolderBg: "Drive folder background" - wallpaperOverlay: "Wallpaper overlay" badge: "Badge" messageBg: "Chat background" - accentDarken: "Accent (Darkened)" - accentLighten: "Accent (Lightened)" fgHighlighted: "Highlighted Text" _sfx: note: "New note" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index aeb9f33cec..f60e9beabe 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -1965,7 +1965,6 @@ _theme: header: "Cabezal" navBg: "Fondo de la barra lateral" navFg: "Texto de la barra lateral" - navHoverFg: "Texto de la barra lateral (hover)" navActive: "Texto de la barra lateral (activo)" navIndicator: "Indicador de la barra lateral" link: "Vínculo" @@ -1988,11 +1987,8 @@ _theme: buttonHoverBg: "Fondo de botón (hover)" inputBorder: "Borde de los campos de entrada" driveFolderBg: "Fondo de capeta del drive" - wallpaperOverlay: "Transparencia del fondo de pantalla" badge: "Medalla" messageBg: "Fondo de chat" - accentDarken: "Acento (oscuro)" - accentLighten: "Acento (claro)" fgHighlighted: "Texto resaltado" _sfx: note: "Notas" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index aed6b5c570..3a6f520ae6 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -1816,7 +1816,6 @@ _theme: header: "Entête" navBg: "Fond de la barre latérale" navFg: "Texte de la barre latérale" - navHoverFg: "Texte de la barre latérale (survolé)" navActive: "Texte de la barre latérale (actif)" navIndicator: "Indicateur de barre latérale" link: "Lien" @@ -1839,11 +1838,8 @@ _theme: buttonHoverBg: "Arrière-plan du bouton (survolé)" inputBorder: "Cadre de la zone de texte" driveFolderBg: "Arrière-plan du dossier de disque" - wallpaperOverlay: "Superposition de fond d'écran" badge: "Badge" messageBg: "Arrière plan de la discussion" - accentDarken: "Plus sombre" - accentLighten: "Plus clair" fgHighlighted: "Texte mis en évidence" _sfx: note: "Nouvelle note" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 944d416ac1..22ccbf153a 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -1931,7 +1931,6 @@ _theme: header: "Header" navBg: "Latar belakang bilah samping" navFg: "Teks bilah samping" - navHoverFg: "Teks bilah samping (Mengambang)" navActive: "Teks bilah samping (Aktif)" navIndicator: "Indikator bilah samping" link: "Tautan" @@ -1954,11 +1953,8 @@ _theme: buttonHoverBg: "Latar belakang tombol (Mengambang)" inputBorder: "Batas bidang masukan" driveFolderBg: "Latar belakang folder drive" - wallpaperOverlay: "Lapisan wallpaper" badge: "Lencana" messageBg: "Latar belakang obrolan" - accentDarken: "Aksen (Gelap)" - accentLighten: "Aksen (Terang)" fgHighlighted: "Teks yang disorot" _sfx: note: "Catatan" diff --git a/locales/index.d.ts b/locales/index.d.ts index 177c9685a1..887627d905 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1714,6 +1714,10 @@ export interface Locale extends ILocale { * ファイルが添付されたノートのみ */ "withFileAntenna": string; + /** + * センシティブなチャンネルのノートを除外 + */ + "excludeNotesInSensitiveChannel": string; /** * ブラウザへのプッシュ通知を有効にする */ @@ -3930,6 +3934,10 @@ export interface Locale extends ILocale { * ログアウトしますか? */ "logoutConfirm": string; + /** + * ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。 + */ + "logoutWillClearClientData": string; /** * 最終利用日時 */ @@ -5374,6 +5382,22 @@ export interface Locale extends ILocale { * 上 */ "top": string; + /** + * 埋め込み + */ + "embed": string; + /** + * 設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます) + */ + "settingsMigrating": string; + /** + * 読み取り専用 + */ + "readonly": string; + /** + * デッキへ戻る + */ + "goToDeck": string; "_chat": { /** * まだメッセージはありません @@ -5488,6 +5512,10 @@ export interface Locale extends ILocale { * このサーバー、またはこのアカウントでチャットは有効化されていません。 */ "chatNotAvailableForThisAccountOrServer": string; + /** + * このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。 + */ + "chatIsReadOnlyForThisAccountOrServer": string; /** * 相手のアカウントでチャット機能が使えない状態になっています。 */ @@ -7353,6 +7381,14 @@ export interface Locale extends ILocale { * 数値が大きいほどUI上で先頭に表示されます。 */ "descriptionOfDisplayOrder": string; + /** + * アサイン状態を移行先アカウントにも引き継ぐ + */ + "preserveAssignmentOnMoveAccount": string; + /** + * オンにすると、このロールが付与されたアカウントが移行された際に、移行先アカウントにもこのロールが引き継がれるようになります。 + */ + "preserveAssignmentOnMoveAccount_description": string; /** * モデレーターのメンバー編集を許可 */ @@ -7511,7 +7547,7 @@ export interface Locale extends ILocale { /** * チャットを許可 */ - "canChat": string; + "chatAvailability": string; }; "_condition": { /** diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 3ec8414ded..44b0858fe8 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -522,7 +522,7 @@ showNoteActionsOnlyHover: "Mostra le azioni delle Note solo al passaggio del mou showReactionsCount: "Visualizza il numero di reazioni su una nota" noHistory: "Nessuna cronologia" signinHistory: "Storico degli accessi al profilo" -enableAdvancedMfm: "Attiva MFM avanzati" +enableAdvancedMfm: "Attivare i Misskey Flavoured Markdown (MFM) avanzati" enableAnimatedMfm: "Attiva MFM animati" doing: "In corso..." category: "Categoria" @@ -605,7 +605,7 @@ uiInspector: "UI Inspector" uiInspectorDescription: "Puoi visualizzare un elenco di elementi UI presenti in memoria. I componenti dell'interfaccia utente vengono generati dalle funzioni Ui:C:." output: "Output" script: "Script" -disablePagesScript: "Disabilita AiScript nelle pagine" +disablePagesScript: "Disabilitare AiScript nelle pagine" updateRemoteUser: "Aggiorna dati dal profilo remoto" unsetUserAvatar: "Rimozione foto profilo" unsetUserAvatarConfirm: "Vuoi davvero rimuovere la foto profilo?" @@ -663,7 +663,7 @@ generateAccessToken: "Genera token di accesso" permission: "Autorizzazioni " adminPermission: "Privilegi amministrativi" enableAll: "Abilita tutto" -disableAll: "Disabilita tutto" +disableAll: "Disabilitare tutto" tokenRequested: "Autorizza accesso al profilo" pluginTokenRequestedDescription: "Il plugin potrà utilizzare le autorizzazioni impostate qui." notificationType: "Tipo di notifiche" @@ -766,7 +766,7 @@ noCrawleDescription: "Richiedi che i motori di ricerca non indicizzino la tua pa lockedAccountInfo: "A meno che non imposti la visibilità delle tue note su \"Solo ai follower\", le tue note sono visibili da tutti, anche se hai configurato l'account per confermare manualmente le richieste di follow." alwaysMarkSensitive: "Segnare automaticamente come espliciti gli allegati" loadRawImages: "Visualizza le intere immagini allegate invece delle miniature." -disableShowingAnimatedImages: "Disabilita le immagini animate" +disableShowingAnimatedImages: "Disabilitare le immagini animate" highlightSensitiveMedia: "Evidenzia i media espliciti" verificationEmailSent: "Una mail di verifica è stata inviata. Si prega di accedere al collegamento per compiere la verifica." notSet: "Non impostato" @@ -1192,7 +1192,7 @@ renotes: "Rinota" loadReplies: "Leggi le risposte" loadConversation: "Leggi la conversazione" pinnedList: "Elenco in primo piano" -keepScreenOn: "Mantieni lo schermo acceso" +keepScreenOn: "Mantenere lo schermo acceso" verifiedLink: "Abbiamo confermato la validità di questo collegamento" notifyNotes: "Notifica nuove Note" unnotifyNotes: "Interrompi le notifiche di nuove Note" @@ -1234,7 +1234,7 @@ flip: "Inverti" showAvatarDecorations: "Mostra decorazione della foto profilo" releaseToRefresh: "Rilascia per aggiornare" refreshing: "Aggiornamento..." -pullDownToRefresh: "Trascina per aggiornare" +pullDownToRefresh: "Trascinare per aggiornare" disableStreamingTimeline: "Disabilitare gli aggiornamenti della TL in tempo reale" useGroupedNotifications: "Mostra le notifiche raggruppate" signupPendingError: "Si è verificato un problema durante la verifica del tuo indirizzo email. Potrebbe essere scaduto il collegamento temporaneo." @@ -1262,7 +1262,7 @@ backToTitle: "Torna al titolo" hemisphere: "Geolocalizzazione" withSensitive: "Mostra le Note con allegati espliciti" userSaysSomethingSensitive: "Note da {name} con allegati espliciti" -enableHorizontalSwipe: "Trascina per invertire i tab" +enableHorizontalSwipe: "Trascinare per invertire le colonne" loading: "Caricamento" surrender: "Annulla" gameRetry: "Riprova" @@ -1336,6 +1336,10 @@ chat: "Chat" migrateOldSettings: "Migrare le vecchie impostazioni" migrateOldSettings_description: "Di solito, viene fatto automaticamente. Se per qualche motivo non fossero migrate con successo, è possibile avviare il processo di migrazione manualmente, sovrascrivendo le configurazioni attuali." compress: "Comprimi" +right: "Destra" +bottom: "Sotto" +top: "Sopra" +embed: "Incorporare" _chat: noMessagesYet: "Ancora nessun messaggio" newMessage: "Nuovo messaggio" @@ -1406,9 +1410,11 @@ _settings: timelineAndNote: "Note e Timeline" makeEveryTextElementsSelectable: "Imposta ogni elemento come selezionabile" makeEveryTextElementsSelectable_description: "Potrebbe ridurre l'usabilità in alcune situazioni." + useStickyIcons: "Fissa le icone durante lo scorrimento" showNavbarSubButtons: "Mostra i pulsanti secondari nella barra di navigazione" ifOn: "Quando attivato" ifOff: "Quando disattivato" + enableSyncThemesBetweenDevices: "Sincronizzare il tema tra i dispositivi" _chat: showSenderName: "Mostra il nome del mittente" sendOnEnter: "Invio spedisce" @@ -1881,6 +1887,8 @@ _role: descriptionOfIsExplorable: "Selezionandolo, la timeline del ruolo diventerà accessibile pubblicamente. Tranne se il ruolo non è pubblico." displayOrder: "Ordine di visualizzazione" descriptionOfDisplayOrder: "I valori più alti vengono visualizzati per primi" + preserveAssignmentOnMoveAccount: "Mantenere l'assegnazione alla migrazione del profilo" + preserveAssignmentOnMoveAccount_description: "Attivando, il ruolo verrà portato sul profilo destinatario, durante la migrazione." canEditMembersByModerator: "Anche i Moderatori assegnano profili a questo ruolo" descriptionOfCanEditMembersByModerator: "Se disattivo, potranno farlo solamente gli Amministratori." priority: "Priorità" @@ -1921,7 +1929,6 @@ _role: canImportFollowing: "Può importare Following" canImportMuting: "Può importare Silenziati" canImportUserLists: "Può importare liste di Profili" - canChat: "Chat consentita" _condition: roleAssignedTo: "Assegnato a ruoli manualmente" isLocal: "Profilo locale" @@ -2118,14 +2125,13 @@ _theme: header: "Intestazione" navBg: "Sfondo della barra laterale" navFg: "Testo della barra laterale" - navHoverFg: "Testo della barra laterale (al passaggio del mouse)" navActive: "Testo della barra laterale (attivo)" navIndicator: "Indicatore di barra laterale" link: "Link" hashtag: "Hashtag" mention: "Menzioni" mentionMe: "Menzioni (di me)" - renote: "Rinota" + renote: "Renota" modalBg: "Sfondo modale." divider: "Interruzione di linea" scrollbarHandle: "Maniglie della barra di scorrimento" @@ -2141,11 +2147,8 @@ _theme: buttonHoverBg: "Sfondo del pulsante (sorvolato)" inputBorder: "Inquadra casella di testo" driveFolderBg: "Sfondo della cartella di disco" - wallpaperOverlay: "Sovrapposizione dello sfondo" badge: "Distintivo" messageBg: "Sfondo della chat" - accentDarken: "Temi (scuri)" - accentLighten: "Temi (luminosi)" fgHighlighted: "Testo in evidenza." _sfx: note: "Nota" @@ -2592,6 +2595,9 @@ _notification: _deck: alwaysShowMainColumn: "Mostra sempre la colonna principale" columnAlign: "Allineare colonne" + columnGap: "Margine tra le colonne" + deckMenuPosition: "Posizione del menu Deck" + navbarPosition: "Posizione barra di navigazione" addColumn: "Aggiungi colonna" newNoteNotificationSettings: "Preferenze per le notifiche di nuove Note" configureColumn: "Impostazioni colonna" @@ -2908,7 +2914,7 @@ _customEmojisManager: directoryToCategoryLabel: "Inseriscile in una cartella omonima alla categoria" directoryToCategoryCaption: "Crea il campo categoria in base alla cartella." emojiInputAreaCaption: "Seleziona l'emoji da registrare utilizzando uno dei metodi." - emojiInputAreaList1: "Trascina una immagine o una cartella in quest'area" + emojiInputAreaList1: "Trascinare una immagine o una cartella in quest'area" emojiInputAreaList2: "Clicca per scegliere file dal tuo dispositivo" emojiInputAreaList3: "Clicca per selezionare dal Drive" confirmRegisterEmojisDescription: "Registrazione delle emoji elencate come nuove emoji personalizzate. Vuoi davvero procedere? (Per evitare sovraccarichi, puoi registrare al massimo {count} emoji per volta)" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 483ece655d..db01b9a4f9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "Botアカウントを除外" antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります" notifyAntenna: "新しいノートを通知する" withFileAntenna: "ファイルが添付されたノートのみ" +excludeNotesInSensitiveChannel: "センシティブなチャンネルのノートを除外" enableServiceworker: "ブラウザへのプッシュ通知を有効にする" antennaUsersDescription: "ユーザー名を改行で区切って指定します" caseSensitive: "大文字小文字を区別する" @@ -978,6 +979,7 @@ document: "ドキュメント" numberOfPageCache: "ページキャッシュ数" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" logoutConfirm: "ログアウトしますか?" +logoutWillClearClientData: "ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。" lastActiveDate: "最終利用日時" statusbar: "ステータスバー" pleaseSelect: "選択してください" @@ -1339,6 +1341,10 @@ compress: "圧縮" right: "右" bottom: "下" top: "上" +embed: "埋め込み" +settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)" +readonly: "読み取り専用" +goToDeck: "デッキへ戻る" _chat: noMessagesYet: "まだメッセージはありません" @@ -1369,6 +1375,7 @@ _chat: muteThisRoom: "このルームをミュート" deleteRoom: "ルームを削除" chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。" + chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。" chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。" cannotChatWithTheUser: "このユーザーとのチャットを開始できません" cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。" @@ -1905,6 +1912,8 @@ _role: descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。" displayOrder: "表示順" descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" + preserveAssignmentOnMoveAccount: "アサイン状態を移行先アカウントにも引き継ぐ" + preserveAssignmentOnMoveAccount_description: "オンにすると、このロールが付与されたアカウントが移行された際に、移行先アカウントにもこのロールが引き継がれるようになります。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" priority: "優先度" @@ -1945,7 +1954,7 @@ _role: canImportFollowing: "フォローのインポートを許可" canImportMuting: "ミュートのインポートを許可" canImportUserLists: "リストのインポートを許可" - canChat: "チャットを許可" + chatAvailability: "チャットを許可" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index ec11cd8df5..378eaf2ad5 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -2007,7 +2007,6 @@ _theme: header: "ヘッダー" navBg: "サイドバーの背景" navFg: "サイドバーの文字" - navHoverFg: "サイドバー文字(ホバー)" navActive: "サイドバー文字(アクティブ)" navIndicator: "サイドバーのインジケーター" link: "リンク" @@ -2030,11 +2029,8 @@ _theme: buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" driveFolderBg: "ドライブフォルダーの背景" - wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" messageBg: "チャットの背景" - accentDarken: "アクセント (暗め)" - accentLighten: "アクセント (明るめ)" fgHighlighted: "強調されとる文字" _sfx: note: "ノート" diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index 6e0ed8ce81..fb21b47fac 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -747,6 +747,7 @@ _theme: description: "설멩" keys: mention: "멘션" + renote: "리노트" _sfx: note: "새 노트" notification: "알림" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 9ee5f19513..fc578758a2 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -64,8 +64,8 @@ copyNoteId: "노트 ID 복사" copyFileId: "파일 ID 복사" copyFolderId: "폴더 ID 복사" copyProfileUrl: "프로필 URL 복사" -searchUser: "사용자 검색" -searchThisUsersNotes: "사용자의 노트 검색" +searchUser: "유저 검색" +searchThisUsersNotes: "유저의 노트를 검색" reply: "답글" loadMore: "더 보기" showMore: "더 보기" @@ -267,7 +267,7 @@ publishing: "배포 중" notResponding: "응답 없음" instanceFollowing: "서버의 팔로잉" instanceFollowers: "서버의 팔로워" -instanceUsers: "서버의 사용자" +instanceUsers: "서버의 유저" changePassword: "비밀번호 변경" security: "보안" retypedNotMatch: "입력이 일치하지 않습니다." @@ -301,6 +301,7 @@ uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 explore: "둘러보기" messageRead: "읽음" noMoreHistory: "이것보다 과거의 기록이 없습니다" +startChat: "채팅을 시작하기" nUsersRead: "{n}명이 읽음" agreeTo: "{0}에 동의" agree: "동의합니다" @@ -384,12 +385,12 @@ disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리 registration: "등록" invite: "초대" driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량" -driveCapacityPerRemoteAccount: "원격 사용자별 드라이브 용량" +driveCapacityPerRemoteAccount: "리모트 유저별 드라이브 용량" inMb: "메가바이트 단위" bannerUrl: "배너 이미지 URL" backgroundImageUrl: "배경 이미지 URL" basicInfo: "기본 정보" -pinnedUsers: "고정한 사용자" +pinnedUsers: "고정한 유저" pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적습니다." pinnedPages: "고정한 페이지" pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적습니다." @@ -417,12 +418,13 @@ antennas: "안테나" manageAntennas: "안테나 관리" name: "이름" antennaSource: "받을 소스" -antennaKeywords: "받을 검색어" -antennaExcludeKeywords: "제외할 검색어" +antennaKeywords: "받을 키워드" +antennaExcludeKeywords: "제외할 키워드" antennaExcludeBots: "봇 계정 제외" antennaKeywordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정됩니다" notifyAntenna: "새로운 노트를 알림" withFileAntenna: "파일이 첨부된 노트만" +excludeNotesInSensitiveChannel: "민감한 채널의 노트 제외" enableServiceworker: "ServiceWorker 사용" antennaUsersDescription: "유저명을 한 줄에 한 명씩 적습니다" caseSensitive: "대소문자를 구분" @@ -434,11 +436,11 @@ silence: "사일런스" silenceConfirm: "이 계정을 사일런스로 설정하시겠습니까?" unsilence: "사일런스 해제" unsilenceConfirm: "이 계정의 사일런스를 해제하시겠습니까?" -popularUsers: "인기 사용자" -recentlyUpdatedUsers: "최근에 활동한 사용자" -recentlyRegisteredUsers: "최근에 가입한 사용자" -recentlyDiscoveredUsers: "최근에 발견한 사용자" -exploreUsersCount: "{count}명의 사용자가 있습니다" +popularUsers: "인기 유저" +recentlyUpdatedUsers: "최근에 활동한 유저" +recentlyRegisteredUsers: "최근에 가입한 유저" +recentlyDiscoveredUsers: "최근에 발견한 유저" +exploreUsersCount: "{count}명의 유저가 있습니다" exploreFediverse: "연합우주를 탐색" popularTags: "인기 태그" userList: "리스트" @@ -506,7 +508,7 @@ strongPassword: "강한 비밀번호" passwordMatched: "일치합니다" passwordNotMatched: "일치하지 않습니다" signinWith: "{x}로 로그인" -signinFailed: "로그인할 수 없습니다. 사용자 이름과 비밀번호를 확인해 주십시오." +signinFailed: "로그인할 수 없습니다. 유저 이름과 비밀번호를 확인해 주십시오." or: "혹은" language: "언어" uiLanguage: "UI 표시 언어" @@ -518,7 +520,7 @@ style: "스타일" drawer: "서랍" popup: "팝업" showNoteActionsOnlyHover: "마우스가 올라간 때에만 노트 동작 버튼을 표시하기" -showReactionsCount: "노트의 반응 수를 표시하기" +showReactionsCount: "노트의 리액션 수를 표시하기" noHistory: "기록이 없습니다" signinHistory: "로그인 기록" enableAdvancedMfm: "고급 MFM을 활성화" @@ -605,7 +607,7 @@ uiInspectorDescription: "메모리에 있는 UI 컴포넌트의 인스턴트 목 output: "출력" script: "스크립트" disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" -updateRemoteUser: "원격 사용자 정보 갱신" +updateRemoteUser: "리모트 유저 정보 갱신" unsetUserAvatar: "아바타 제거" unsetUserAvatarConfirm: "아바타를 제거할까요?" unsetUserBanner: "배너 제거" @@ -614,7 +616,7 @@ deleteAllFiles: "모든 파일 삭제" deleteAllFilesConfirm: "모든 파일을 삭제하시겠습니까?" removeAllFollowing: "모든 팔로잉 해제" removeAllFollowingDescription: "{host} 서버의 모든 팔로잉을 해제합니다. 해당 서버가 더 이상 존재하지 않는 경우 등에 실행해 주세요." -userSuspended: "이 사용자는 정지되었습니다." +userSuspended: "이 유저는 정지되었습니다." userSilenced: "이 계정은 사일런스된 상태입니다." yourAccountSuspendedTitle: "계정이 정지되었습니다" yourAccountSuspendedDescription: "이 계정은 서버의 이용 약관을 위반하거나, 기타 다른 이유로 인해 정지되었습니다. 자세한 사항은 관리자에게 문의해 주십시오. 계정을 새로 생성하지 마십시오." @@ -675,7 +677,7 @@ emailAddress: "메일 주소" smtpConfig: "SMTP 서버 설정" smtpHost: "호스트" smtpPort: "포트" -smtpUser: "사용자 이름" +smtpUser: "유저 이름" smtpPass: "비밀번호" emptyToDisableSmtpAuth: "SMTP 인증을 사용하지 않으려면 공란으로 비워둡니다." smtpSecure: "SMTP 연결에 Implicit SSL/TTS 사용" @@ -752,8 +754,8 @@ repliedCount: "받은 답글 수" renotedCount: "받은 리노트 수" followingCount: "팔로우 수" followersCount: "팔로워 수" -sentReactionsCount: "반응 수" -receivedReactionsCount: "받은 반응 수" +sentReactionsCount: "리액션 수" +receivedReactionsCount: "받은 리액션 수" pollVotesCount: "투표 수" pollVotedCount: "받은 투표 수" yes: "예" @@ -761,7 +763,7 @@ no: "아니오" driveFilesCount: "드라이브에 있는 파일 수" driveUsage: "드라이브 사용량" noCrawle: "검색엔진의 인덱싱 거부" -noCrawleDescription: "검색엔진에 사용자 페이지, 노트, 페이지 등의 콘텐츠를 인덱싱되지 않게 합니다." +noCrawleDescription: "검색엔진에 유저 페이지, 노트, 페이지 등의 콘텐츠를 인덱싱되지 않게 합니다." lockedAccountInfo: "팔로우를 승인으로 승인받더라도 노트의 공개 범위를 '팔로워'로 하지 않는 한 누구나 당신의 노트를 볼 수 있습니다." alwaysMarkSensitive: "미디어를 항상 열람 주의로 설정" loadRawImages: "첨부한 이미지의 썸네일을 원본화질로 표시" @@ -793,7 +795,7 @@ needReloadToApply: "변경 사항은 새로고침하면 적용됩니다." showTitlebar: "타이틀 바를 표시하기" clearCache: "캐시 비우기" onlineUsersCount: "{n}명이 접속 중" -nUsers: "{n} 사용자" +nUsers: "{n} 유저" nNotes: "{n} 노트" sendErrorReports: "오류 보고서 보내기" sendErrorReportsDescription: "이 설정을 활성화하면, 문제가 발생했을 때 오류에 대한 상세 정보를 Misskey에 보내어 더 나은 소프트웨어를 만드는 데에 도움을 줄 수 있습니다." @@ -839,7 +841,7 @@ addDescription: "설명 추가" userPagePinTip: "각 노트의 메뉴에서 「프로필에 고정」을 선택하는 것으로, 여기에 노트를 표시해 둘 수 있어요." notSpecifiedMentionWarning: "수신자가 선택되지 않은 멘션이 있어요" info: "정보" -userInfo: "사용자 정보" +userInfo: "유저 정보" unknown: "알 수 없음" onlineStatus: "온라인 상태" hideOnlineStatus: "온라인 상태 숨기기" @@ -855,7 +857,7 @@ switchAccount: "계정 바꾸기" enabled: "활성화" disabled: "비활성화" quickAction: "빠른 동작" -user: "사용자" +user: "유저" administration: "관리" accounts: "계정" switch: "전환" @@ -866,8 +868,8 @@ configure: "설정하기" postToGallery: "갤러리에 업로드" postToHashtag: "이 해시태그에 게시" gallery: "갤러리" -recentPosts: "최근 포스트" -popularPosts: "인기 포스트" +recentPosts: "최근 게시물" +popularPosts: "인기 게시물" shareWithNote: "노트로 공유" ads: "광고" expiration: "기한" @@ -896,7 +898,7 @@ whatIsNew: "패치 정보 보기" translate: "번역" translatedFrom: "{x}에서 번역" accountDeletionInProgress: "계정 삭제 작업을 진행하고 있습니다" -usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있습니다. 사용자명은 나중에 변경할 수 없습니다." +usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있습니다. 유저명은 나중에 변경할 수 없습니다." aiChanMode: "아이 모드" devMode: "개발자 모드" keepCw: "CW 유지하기" @@ -1030,7 +1032,7 @@ correspondingSourceIsAvailable: "소스 코드는 {anchor}에서 받아보실 roles: "역할" role: "역할" noRole: "역할이 없습니다" -normalUser: "일반 사용자" +normalUser: "일반 유저" undefined: "정의되지 않음" assign: "할당" unassign: "할당 취소" @@ -1054,7 +1056,7 @@ thisPostMayBeAnnoyingHome: "홈에 게시" thisPostMayBeAnnoyingCancel: "그만두기" thisPostMayBeAnnoyingIgnore: "이대로 게시" collapseRenotes: "이미 본 리노트를 간략화하기" -collapseRenotesDescription: "반응이나 리노트를 한 노트를 접어서 표시합니다." +collapseRenotesDescription: "리액션이나 리노트를 한 노트를 접어서 표시합니다." internalServerError: "내부 서버 오류" internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했습니다." copyErrorInfo: "오류 정보 복사" @@ -1078,8 +1080,8 @@ resetPasswordConfirm: "비밀번호를 재설정하시겠습니까?" sensitiveWords: "민감한 단어" sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제합니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다." sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." -prohibitedWords: "금지 워드" -prohibitedWordsDescription: "설정된 워드가 포함되는 노트를 작성하려고 하면, 에러가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다." +prohibitedWords: "금지 단어" +prohibitedWordsDescription: "설정된 단어가 포함되는 노트를 작성하려고 하면, 오류가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다." prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." hiddenTags: "숨긴 해시태그" hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다." @@ -1104,7 +1106,7 @@ audio: "소리" audioFiles: "소리" dataSaver: "데이터 절약 모드" accountMigration: "계정 이동" -accountMoved: "이 사용자는 다음 계정으로 이사했습니다:" +accountMoved: "이 유저는 다음 계정으로 이사했습니다:" accountMovedShort: "이사한 계정입니다" operationForbidden: "사용할 수 없습니다" forceShowAds: "광고를 항상 표시" @@ -1125,8 +1127,8 @@ serverRules: "서버 규칙" pleaseConfirmBelowBeforeSignup: "이 서버에 가입하기 전에 아래 사항을 확인하여 주십시오." pleaseAgreeAllToContinue: "계속하시려면 모든 항목에 동의하십시오." continue: "계속" -preservedUsernames: "예약한 사용자 이름" -preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." +preservedUsernames: "예약한 유저명" +preservedUsernamesDescription: "예약할 유저명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 유저명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." createNoteFromTheFile: "이 파일로 노트를 작성" archive: "아카이브" archived: "아카이브 됨" @@ -1140,7 +1142,7 @@ youFollowing: "팔로잉" preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부" preventAiLearningDescription: "외부의 문장 생성 AI나 이미지 생성 AI에 대해 제출한 노트나 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구합니다. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아닙니다." options: "옵션" -specifyUser: "사용자 지정" +specifyUser: "유저 지정" lookupConfirm: "조회 할까요?" openTagPageConfirm: "해시태그의 페이지를 열까요?" specifyHost: "호스트 지정" @@ -1295,8 +1297,8 @@ passkeyVerificationSucceededButPasswordlessLoginDisabled: "입력된 패스키 messageToFollower: "팔로워에게 보낼 메시지" target: "대상" testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. 실제 환경에서는 사용하지 마세요." -prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)" -prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다." +prohibitedWordsForNameOfUser: "금지 단어 (유저명)" +prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 유저명에 있는 경우, 일반 유저는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 유저는 제한 대상에서 제외됩니다." yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다." yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요." thisContentsAreMarkedAsSigninRequiredByAuthor: "게시자에 의해 로그인해야 볼 수 있도록 설정되어 있습니다." @@ -1331,12 +1333,63 @@ emojiPalette: "이모지 팔레트" postForm: "글 입력란" textCount: "문자 수" information: "정보" +chat: "채팅" +migrateOldSettings: "기존 설정 정보를 이전" +migrateOldSettings_description: "보통은 자동으로 이루어지지만, 어떤 이유로 인해 성공적으로 이전이 이루어지지 않는 경우 수동으로 이전을 실행할 수 있습니다. 현재 설정 정보는 덮어쓰게 됩니다." +compress: "압축" +right: "오른쪽" +bottom: "아래" +top: "위" +embed: "임베드" +settingsMigrating: "설정을 이전하는 중입니다. 잠시 기다려주십시오... (나중에 '환경설정 → 기타 → 기존 설정 정보를 이전'에서 수동으로 이전할 수도 있습니다)" +readonly: "읽기 전용" +goToDeck: "덱으로 돌아가기" _chat: + noMessagesYet: "아직 메시지가 없습니다" + newMessage: "새로운 메시지" + individualChat: "개인 대화" + individualChat_description: "특정 유저와 일대일 채팅을 할 수 있습니다." + roomChat: "룸 채팅" + roomChat_description: "여러 명이 함께 채팅할 수 있습니다.\n또한, 개인 채팅을 허용하지 않은 유저와도 상대방이 수락하면 채팅을 할 수 있습니다." + createRoom: "룸을 생성" + inviteUserToChat: "유저를 초대하여 채팅을 시작하세요" + yourRooms: "생성한 룸" + joiningRooms: "참가 중인 룸" invitations: "초대" + noInvitations: "초대장이 없습니다" + history: "이력" noHistory: "기록이 없습니다" + noRooms: "룸이 없습니다" + inviteUser: "유저를 초대" + sentInvitations: "초대를 보내기" + join: "참여" + ignore: "무시" + leave: "룸을 떠나기" members: "멤버" + searchMessages: "메시지 검색" home: "홈" send: "전송" + newline: "줄바꿈" + muteThisRoom: "이 룸을 뮤트" + deleteRoom: "룸을 삭제" + chatNotAvailableForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅이 활성화되어 있지 않습니다." + chatIsReadOnlyForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅은 읽기 전용입니다. 새로 쓰거나 채팅방을 만들거나 참가할 수 없습니다." + chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다." + cannotChatWithTheUser: "이 유저와 채팅을 시작할 수 없습니다" + cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다." + chatWithThisUser: "채팅하기" + thisUserAllowsChatOnlyFromFollowers: "이 유저는 팔로워만 채팅을 할 수 있습니다." + thisUserAllowsChatOnlyFromFollowing: "이 유저는 이 유저가 팔로우하는 유저만 채팅을 허용합니다." + thisUserAllowsChatOnlyFromMutualFollowing: "이 유저는 상호 팔로우하는 유저만 채팅을 허용합니다." + thisUserNotAllowedChatAnyone: "이 유저는 다른 사람의 채팅을 받지 않습니다." + chatAllowedUsers: "채팅을 허용한 상대" + chatAllowedUsers_note: "내가 채팅 메시지를 보낸 상대와는 이 설정과 상관없이 채팅이 가능합니다." + _chatAllowedUsers: + everyone: "누구나" + followers: "자신의 팔로워만" + following: "자신이 팔로우한 유저만" + mutual: "상호 팔로우한 유저만" + none: "아무도 허락하지 않기" _emojiPalette: palettes: "팔레트" enableSyncBetweenDevicesForPalettes: "팔레트의 디바이스 간 동기화를 활성화" @@ -1361,7 +1414,15 @@ _settings: soundsBanner: "클라이언트에서 재생할 소리에 대한 설정을 합니다." timelineAndNote: "타임라인과 노트" makeEveryTextElementsSelectable: "모든 텍스트 요소를 선택할 수 있도록 함" - makeEveryTextElementsSelectable_description: "활성화 시, 일부 동작에서 사용자의 접근성이 나빠질 수도 있습니다." + makeEveryTextElementsSelectable_description: "활성화 시, 일부 동작에서 유저의 접근성이 나빠질 수도 있습니다." + useStickyIcons: "아이콘이 스크롤을 따라가도록 하기" + showNavbarSubButtons: "내비게이션 바에 보조 버튼 표시" + ifOn: "켜져 있을 때" + ifOff: "꺼져 있을 때" + enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화" + _chat: + showSenderName: "발신자 이름 표시" + sendOnEnter: "엔터로 보내기" _preferencesProfile: profileName: "프로필 이름" profileNameDescription: "이 디바이스를 식별할 이름을 설정해 주세요." @@ -1379,12 +1440,12 @@ _accountSettings: requireSigninToViewContents: "콘텐츠 열람을 위해 로그인을 필수로 설정하기" requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다." requireSigninToViewContentsDescription2: "URL 미리보기(OGP), 웹페이지에 삽입, 노트 인용을 지원하지 않는 서버에서 볼 수 없게 됩니다." - requireSigninToViewContentsDescription3: "원격 서버에 연합된 콘텐츠에는 이러한 제한이 적용되지 않을 수 있습니다." + requireSigninToViewContentsDescription3: "리모트 서버에 연합된 콘텐츠에는 이러한 제한이 적용되지 않을 수 있습니다." makeNotesFollowersOnlyBefore: "과거 노트는 팔로워만 볼 수 있도록 설정하기" makeNotesFollowersOnlyBeforeDescription: "이 기능이 활성화되어 있는 동안, 설정된 날짜 및 시간보다 과거 또는 설정된 시간이 지난 노트는 팔로워만 볼 수 있게 됩니다. 비활성화하면 노트의 공개 상태도 원래대로 돌아갑니다." makeNotesHiddenBefore: "과거 노트 비공개로 전환하기" makeNotesHiddenBeforeDescription: "이 기능이 활성화되어 있는 동안 설정한 날짜 및 시간보다 과거 또는 설정한 시간이 지난 노트는 본인만 볼 수 있게(비공개로 전환) 됩니다. 비활성화하면 노트의 공개 상태도 원래대로 돌아갑니다." - mayNotEffectForFederatedNotes: "원격 서버에 연합된 노트에는 효과가 없을 수도 있습니다." + mayNotEffectForFederatedNotes: "리모트 서버에 연합된 노트에는 효과가 없을 수도 있습니다." mayNotEffectSomeSituations: "여기서 설정하는 제한은 모더레이션이나 리모트 서버에서 볼 때 등 일부 환경에서는 적용되지 않을 수도 있습니다." notesHavePassedSpecifiedPeriod: "지정한 시간이 경과된 노트" notesOlderThanSpecifiedDateAndTime: "지정된 날짜 및 시간 이전의 노트" @@ -1425,11 +1486,11 @@ _announcement: needConfirmationToRead: "읽음으로 표시하기 전에 확인하기" needConfirmationToReadDescription: "활성화하면 이 공지사항을 읽음으로 표시하기 전에 확인 알림창을 띄웁니다. '모두 읽음'의 대상에서도 제외됩니다." end: "공지에서 내리기" - tooManyActiveAnnouncementDescription: "공지사항이 너무 많을 경우, 사용자 경험에 영향을 끼칠 가능성이 있습니다. 오래된 공지사항은 아카이브하시는 것을 권장드립니다." + tooManyActiveAnnouncementDescription: "공지사항이 너무 많을 경우, 유저 경험에 영향을 끼칠 가능성이 있습니다. 오래된 공지사항은 아카이브하시는 것을 권장드립니다." readConfirmTitle: "읽음으로 표시합니까?" readConfirmText: "〈{title}〉의 내용을 읽음으로 표시합니다." shouldNotBeUsedToPresentPermanentInfo: "신규 유저의 이용 경험에 악영향을 끼칠 수 있으므로, 일시적인 알림 수단으로만 사용하고 고정된 정보에는 사용을 지양하는 것을 추천합니다." - dialogAnnouncementUxWarn: "다이얼로그 형태의 알림이 동시에 2개 이상 존재하는 경우, 사용자 경험에 악영향을 끼칠 수 있으므로 신중히 결정하십시오." + dialogAnnouncementUxWarn: "다이얼로그 형태의 알림이 동시에 2개 이상 존재하는 경우, 유저 경험에 악영향을 끼칠 수 있으므로 신중히 결정하십시오." silence: "조용히 알림" silenceDescription: "활성화하면 공지사항에 대한 알림이 가지 않게 되며, 확인 버튼을 누를 필요가 없게 됩니다." _initialAccountSetting: @@ -1555,53 +1616,53 @@ _achievements: _types: _notes1: title: "미스키 계정 만들었어요" - description: "첫 노트를 작성했습니다" + description: "첫 노트를 게시했다" flavor: "Misskey에 어서 오세요!" _notes10: title: "몇 가지 노트" - description: "10개의 노트를 작성했습니다" + description: "10개의 노트를 게시했다" _notes100: title: "많은 노트" - description: "100개의 노트를 작성했습니다" + description: "100개의 노트를 게시했다" _notes500: title: "노트 범벅" - description: "500개의 노트를 작성했습니다" + description: "500개의 노트를 게시했다" _notes1000: title: "노트가 산더미" - description: "1,000개의 노트를 작성했습니다" + description: "1,000개의 노트를 게시했다" _notes5000: title: "솟아나는 노트" - description: "5,000개의 노트를 작성했습니다" + description: "5,000개의 노트를 게시했다" _notes10000: title: "슈퍼 노트" - description: "10,000개의 노트를 작성했습니다" + description: "10,000개의 노트를 게시했다" _notes20000: - title: "노트가 필요해요" - description: "20,000개의 노트를 작성했습니다" + title: "노트가 더 필요해요" + description: "20,000개의 노트를 게시했다" _notes30000: title: "노트노트노트" - description: "30,000개의 노트를 작성했습니다" + description: "30,000개의 노트를 게시했다" _notes40000: title: "노트 공장" - description: "40,000개의 노트를 작성했습니다" + description: "40,000개의 노트를 게시했다" _notes50000: title: "노트 행성" - description: "50,000개의 노트를 작성했습니다" + description: "50,000개의 노트를 게시했다" _notes60000: title: "노트 퀘이사" - description: "60,000개의 노트를 작성했습니다" + description: "60,000개의 노트를 게시했다" _notes70000: title: "노트 블랙홀" - description: "70,000개의 노트를 작성했습니다" + description: "70,000개의 노트를 게시했다" _notes80000: title: "노트 은하" - description: "80,000개의 노트를 작성했습니다" + description: "80,000개의 노트를 게시했다" _notes90000: title: "노트 우주" - description: "90,000개의 노트를 작성했습니다" + description: "90,000개의 노트를 게시했다" _notes100000: title: "ALL YOUR NOTE ARE BELONG TO US" - description: "100,000개의 노트를 작성했습니다" + description: "100,000개의 노트를 게시했다" flavor: "이렇게나 쓸 게 있어요?" _login3: title: "초보자 I" @@ -1626,181 +1687,181 @@ _achievements: flavor: "그 유저, 미스키스트이다" _login200: title: "단골 I" - description: "총 200일간 로그인했습니다" + description: "총 로그인한 날이 200일" _login300: title: "단골 II" - description: "총 300일간 로그인했습니다" + description: "총 로그인한 날이 300일" _login400: title: "단골 III" - description: "총 400일간 로그인했습니다" + description: "총 로그인한 날이 400일" _login500: title: "베테랑 I" - description: "총 500일간 로그인했습니다" + description: "총 로그인한 날이 500일" flavor: "제군, 나는 노트가 좋다" _login600: title: "베테랑 II" - description: "총 600일간 로그인했습니다" + description: "총 로그인한 날이 600일" _login700: title: "베테랑 III" - description: "총 700일간 로그인했습니다" + description: "총 로그인한 날이 700일" _login800: title: "노트 마스터 I" - description: "총 800일간 로그인했습니다" + description: "총 로그인한 날이 800일" _login900: title: "노트 마스터 II" - description: "총 900일간 로그인했습니다" + description: "총 로그인한 날이 900일" _login1000: title: "노트 마스터 III" - description: "총 1,000일간 로그인했습니다" + description: "총 로그인한 날이 1,000일" flavor: "Misskey를 사용해 주셔서 감사합니다!" _noteClipped1: title: "클립할 수밖에 없었어" - description: "처음으로 노트를 클립했습니다" + description: "처음으로 노트를 클립했다" _noteFavorited1: title: "별을 바라보는 자" - description: "처음으로 노트를 즐겨찾기했습니다" + description: "처음으로 노트를 즐겨찾기했다" _myNoteFavorited1: title: "별을 원하는 자" - description: "다른 사람이 당신의 노트를 즐겨찾기했습니다" + description: "다른 사람이 당신의 노트를 즐겨찾기했다" _profileFilled: title: "준비 완료" - description: "프로필 설정을 완료했습니다" + description: "프로필 설정을 완료했다" _markedAsCat: title: "나는 고양이다냥!" - description: "계정을 고양이로 설정했습니다냥" + description: "계정을 고양이로 설정했다냥" flavor: "냐냐냐냐냐냐아아아아앙!" _following1: title: "첫 팔로우" - description: "사용자를 처음으로 팔로우했습니다" + description: "유저를 처음으로 팔로우했다" _following10: title: "팔로우, 팔로우" - description: "10명의 사용자를 팔로우했습니다" + description: "10명의 유저를 팔로우했다" _following50: title: "친구 잔뜩" - description: "50명의 사용자를 팔로우했습니다" + description: "50명의 유저를 팔로우했다" _following100: title: "주소록 한 권으론 부족해" - description: "100명의 사용자를 팔로우했습니다" + description: "100명의 유저를 팔로우했다" _following300: title: "친구가 넘쳐나" - description: "300명의 사용자를 팔로우했습니다" + description: "300명의 유저를 팔로우했다" _followers1: title: "첫 팔로워" - description: "사용자가 처음으로 팔로잉했습니다" + description: "유저가 처음으로 팔로잉했다" _followers10: title: "팔로우 미!" - description: "10명의 사용자가 팔로우했습니다" + description: "10명의 유저가 팔로우했다" _followers50: title: "이곳저곳" - description: "50명의 사용자가 팔로우했습니다" + description: "50명의 유저가 팔로우했다" _followers100: title: "인기왕" - description: "100명의 사용자가 팔로우했습니다" + description: "100명의 유저가 팔로우했다" _followers300: title: "줄 좀 서봐요" - description: "100명의 사용자가 팔로우했습니다" + description: "100명의 유저가 팔로우했다" _followers500: title: "기지국" - description: "500명의 사용자가 팔로우했습니다" + description: "500명의 유저가 팔로우했다" _followers1000: title: "유명인사" - description: "1,000명의 사용자가 팔로우했습니다" + description: "1,000명의 유저가 팔로우했다" _collectAchievements30: title: "도전 과제 콜렉터" - description: "30개의 도전과제를 획득했습니다" + description: "30개의 도전과제를 획득했다" _viewAchievements3min: title: "저 도전과제 좋아해요" - description: "도전 과제 목록을 3분 이상 쳐다봤습니다" + description: "도전 과제 목록을 3분 이상 쳐다봤다" _iLoveMisskey: title: "I Love Misskey" - description: "\"I ❤ #Misskey\"를 포스트했습니다" + description: "\"I ❤ #Misskey\"를 게시했다" flavor: "Misskey를 이용해 주셔서 감사합니다! ― 개발 팀" _foundTreasure: title: "보물찾기" - description: "숨겨진 보물을 발견했습니다" + description: "숨겨진 보물을 발견했다" _client30min: title: "잠시 쉬어요" - description: "클라이언트를 시작하고 30분이 경과하였습니다" + description: "클라이언트를 시작하고 30분이 경과했다" _client60min: title: "No \"Miss\" in Misskey" - description: "클라이언트를 시작하고 60분이 경과하였습니다" + description: "클라이언트를 시작하고 60분이 경과했다" _noteDeletedWithin1min: title: "있었는데요 없었습니다" - description: "노트를 포스트한 후 1분 이내에 삭제했습니다" + description: "노트를 게시한 후 1분 이내에 삭제했다" _postedAtLateNight: title: "올빼미" - description: "한밤중에 노트를 포스트했습니다" + description: "한밤중에 노트를 게시했다" flavor: "잠 좀 자세요. 걱정돼요." _postedAt0min0sec: title: "정각" - description: "0분 0초 정각에 노트를 작성했습니다" + description: "0분 0초 정각에 노트를 게시했다" flavor: "째깍 째깍 째깍 땡!" _selfQuote: title: "혼잣말" - description: "자기 노트를 인용했습니다" + description: "자기 노트를 인용했다" _htl20npm: title: "타임라인 폭주 중" - description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성되었습니다" + description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성되었다" _viewInstanceChart: title: "애널리스트" - description: "서버의 차트를 열었습니다" + description: "서버의 차트를 열었다" _outputHelloWorldOnScratchpad: title: "Hello, world!" - description: "스크래치패드에서 hello world를 출력했습니다" + description: "스크래치패드에서 hello world를 출력했다" _open3windows: title: "멀티 윈도우" - description: "3개 이상의 창을 열었습니다" + description: "3개 이상의 창을 열었다" _driveFolderCircularReference: title: "순환 참조" - description: "드라이브 폴더에 스스로를 넣게 했습니다" + description: "드라이브 폴더에 스스로를 넣게 했다" _reactWithoutRead: title: "읽고 답하긴 하시는 건가요?" - description: "100자가 넘는 노트를 작성한 지 3초 안에 반응했어요" + description: "100자가 넘는 노트를 작성한 지 3초 안에 리액션했다" _clickedClickHere: - title: "여기를 누르세요" - description: "여기를 눌렀습니다" + title: "여길 눌러보세요" + description: "여기를 눌렀다" _justPlainLucky: title: "그냥 운이 좋았어" - description: "매 10초마다 0.01%의 확률로 달성됩니다" + description: "매 10초마다 0.01%의 확률로 달성된다" _setNameToSyuilo: title: "신 콤플렉스" - description: "이름을 syuilo로 설정했습니다" + description: "이름을 syuilo로 설정했다" _passedSinceAccountCreated1: title: "1주년" - description: "계정을 생성하고 1년이 지났습니다" + description: "계정을 생성하고 1년이 지났다" _passedSinceAccountCreated2: title: "2주년" - description: "계정을 생성하고 2년이 지났습니다" + description: "계정을 생성하고 2년이 지났다" _passedSinceAccountCreated3: title: "3주년" - description: "계정을 생성하고 3년이 지났습니다" + description: "계정을 생성하고 3년이 지났다" _loggedInOnBirthday: title: "생일 축하합니다!" - description: "생일에 로그인했습니다" + description: "생일에 로그인했다" _loggedInOnNewYearsDay: title: "새해 복 많이 받으세요" - description: "새해 첫 날에 로그인했습니다" + description: "새해 첫 날에 로그인했다" flavor: "올해에도 저희 서버에 관심을 가져 주셔서 감사합니다" _cookieClicked: title: "쿠키를 클릭하는 게임" - description: "쿠키를 클릭했습니다" - flavor: "소프트웨어 착각하지 않으셨나요?" + description: "쿠키를 클릭했다" + flavor: "소프트웨어 착각하지 않았어?" _brainDiver: title: "Brain Diver" - description: "Brain Diver로의 링크를 첨부했습니다" + description: "Brain Diver로의 링크를 첨부했다" flavor: "Misskey-Misskey La-Tu-Ma" _smashTestNotificationButton: title: "테스트 과잉" - description: "매우 짧은 시간 안에 알림 테스트를 여러 번 수행했습니다" + description: "매우 짧은 시간 안에 알림 테스트를 여러 번 수행했다" _tutorialCompleted: title: "Misskey 입문자 과정 수료증" - description: "튜토리얼을 완료했습니다" + description: "튜토리얼을 완료했다" _bubbleGameExplodingHead: title: "🤯" description: "버블 게임에서 가장 큰 물건을 내놓았다" _bubbleGameDoubleExplodingHead: title: "더블 🤯" - description: "버블게임에서 가장 큰 물건 2개를 동시에 내놓았다." + description: "버블게임에서 가장 큰 물건 2개를 동시에 내놓았다" flavor: "이 정도만 도시락통에 🤯 🤯 조금만 더" _role: new: "새 역할 생성" @@ -1810,7 +1871,7 @@ _role: permission: "역할 권한" descriptionOfPermission: "조정자는 기본적인 조정 작업을 진행할 수 있습니다.\n관리자는 서버의 모든 설정을 변경할 수 있습니다." assignTarget: "할당 대상" - descriptionOfAssignTarget: "수동을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있습니다.\n조건부를 선택하면 조건을 설정해 일치하는 사용자를 자동으로 포함되게 할 수 있습니다." + descriptionOfAssignTarget: "수동을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있습니다.\n조건부를 선택하면 조건을 설정해 일치하는 유저를 자동으로 포함되게 할 수 있습니다." manual: "수동" manualRoles: "수동 역할" conditional: "조건부" @@ -1818,7 +1879,7 @@ _role: condition: "조건" isConditionalRole: "조건부 역할입니다." isPublic: "역할 공개" - descriptionOfIsPublic: "역할에 할당된 사용자를 누구나 볼 수 있습니다. 또한 사용자 프로필에 이 역할이 표시됩니다." + descriptionOfIsPublic: "역할에 할당된 유저를 누구나 볼 수 있습니다. 또한 유저 프로필에 이 역할이 표시됩니다." options: "옵션" policies: "정책" baseRole: "기본 역할" @@ -1831,8 +1892,10 @@ _role: descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다." displayOrder: "표시 순서" descriptionOfDisplayOrder: "값이 클 수록 UI에서 먼저 표시됩니다." + preserveAssignmentOnMoveAccount: "이전 대상 계정에도 할당 상태 전달" + preserveAssignmentOnMoveAccount_description: "켜면 이 역할이 부여된 계정이 이전될 때 마이그레이션 대상 계정에도 이 역할이 승계됩니다." canEditMembersByModerator: "모더레이터의 역할 수정 허용" - descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다." + descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 유저를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다." priority: "우선순위" _priority: low: "낮음" @@ -1858,8 +1921,8 @@ _role: webhookMax: "만들 수 있는 Webhook 수" clipMax: "만들 수 있는 클립 수" noteEachClipsMax: "클립에 넣을 수 있는 노트 수" - userListMax: "만들 수 있는 사용자 리스트 수" - userEachUserListsMax: "사용자 리스트에 넣을 수 있는 사용자 수" + userListMax: "만들 수 있는 유저 리스트 수" + userEachUserListsMax: "유저 리스트에 넣을 수 있는 유저 수" rateLimitFactor: "요청 빈도 제한" descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다." canHideAds: "광고 숨기기" @@ -1871,23 +1934,24 @@ _role: canImportFollowing: "팔로우 가져오기 허용" canImportMuting: "뮤트 목록 가져오기 허용" canImportUserLists: "리스트 목록 가져오기 허용" + chatAvailability: "채팅을 허락" _condition: roleAssignedTo: "수동 역할에 이미 할당됨" - isLocal: "로컬 사용자" - isRemote: "원격 사용자" - isCat: "고양이 사용자" - isBot: "봇 사용자" - isSuspended: "정지된 사용자" - isLocked: "잠금 계정 사용자" - isExplorable: "‘계정을 쉽게 발견하도록 하기’를 활성화한 사용자" + isLocal: "로컬 유저" + isRemote: "리모트 유저" + isCat: "고양이 유저" + isBot: "봇 유저" + isSuspended: "정지된 유저" + isLocked: "잠금 계정 유저" + isExplorable: "‘계정을 쉽게 발견하도록 하기’를 활성화한 유저" createdLessThan: "가입한 지 다음 일수 이내인 유저" createdMoreThan: "가입한 지 다음 일수 이상인 유저" followersLessThanOrEq: "팔로워 수가 다음 이하인 유저" - followersMoreThanOrEq: "팔로워 수가 다음보다 많은 사용자" + followersMoreThanOrEq: "팔로워 수가 다음보다 많은 유저" followingLessThanOrEq: "팔로잉 수가 다음 이하인 유저" - followingMoreThanOrEq: "팔로잉 수가 다음보다 많은 사용자" + followingMoreThanOrEq: "팔로잉 수가 다음보다 많은 유저" notesLessThanOrEq: "노트 수가 다음 이하인 유저" - notesMoreThanOrEq: "노트 수가 다음보다 많은 사용자" + notesMoreThanOrEq: "노트 수가 다음보다 많은 유저" and: "다음을 모두 만족" or: "다음을 하나라도 만족" not: "다음을 만족하지 않음" @@ -1929,7 +1993,7 @@ _ad: adsSettings: "광고 표시 설정" notesPerOneAd: "실시간으로 갱신되는 타임라인에서 광고를 노출시키는 간격 (노트 당)" setZeroToDisable: "0으로 지정하면 실시간 타임라인에서의 광고를 비활성화합니다" - adsTooClose: "광고의 표시 간격이 매우 작아, 사용자 경험에 부정적인 영향을 미칠 수 있습니다." + adsTooClose: "광고의 표시 간격이 매우 작아, 유저 경험에 부정적인 영향을 미칠 수 있습니다." _forgotPassword: enterEmail: "여기에 계정에 등록한 메일 주소를 입력해 주세요. 입력한 메일 주소로 비밀번호 재설정 링크를 발송합니다." ifNoEmail: "메일 주소를 등록하지 않은 경우, 관리자에 문의해 주십시오." @@ -2067,7 +2131,6 @@ _theme: header: "헤더" navBg: "사이드바 배경" navFg: "사이드바 텍스트" - navHoverFg: "사이드바 텍스트 (호버)" navActive: "사이드바 텍스트 (활성)" navIndicator: "사이드바 인디케이터" link: "링크" @@ -2090,17 +2153,15 @@ _theme: buttonHoverBg: "버튼 배경 (호버)" inputBorder: "입력 필드 테두리" driveFolderBg: "드라이브 폴더 배경" - wallpaperOverlay: "배경화면 오버레이" badge: "배지" messageBg: "대화 배경" - accentDarken: "강조 색상 (어두움)" - accentLighten: "강조 색상 (밝음)" fgHighlighted: "강조된 텍스트" _sfx: note: "새 노트" noteMy: "내 노트" notification: "알림" reaction: "리액션 선택" + chatMessage: "채팅 메시지" _soundSettings: driveFile: "드라이브에 있는 오디오를 사용" driveFileWarn: "드라이브에 있는 파일을 선택하세요." @@ -2187,7 +2248,7 @@ _permissions: "write:pages": "페이지를 수정합니다" "read:page-likes": "페이지의 좋아요를 확인합니다" "write:page-likes": "페이지에 좋아요를 추가하거나 취소합니다" - "read:user-groups": "사용자 그룹 보기" + "read:user-groups": "유저 그룹 보기" "write:user-groups": "유저 그룹을 만들거나, 초대하거나, 이름을 변경하거나, 양도하거나, 삭제합니다" "read:channels": "채널을 보기" "write:channels": "채널을 추가하거나 삭제합니다" @@ -2199,23 +2260,23 @@ _permissions: "write:flash": "Play를 조작합니다" "read:flash-likes": "Play의 좋아요를 봅니다" "write:flash-likes": "Play의 좋아요를 조작합니다" - "read:admin:abuse-user-reports": "사용자 신고 보기" - "write:admin:delete-account": "사용자 계정 삭제하기" - "write:admin:delete-all-files-of-a-user": "모든 사용자 파일 삭제하기" + "read:admin:abuse-user-reports": "유저 신고 보기" + "write:admin:delete-account": "유저 계정 삭제하기" + "write:admin:delete-all-files-of-a-user": "모든 유저 파일 삭제하기" "read:admin:index-stats": "데이터베이스 색인 정보 보기" "read:admin:table-stats": "데이터베이스 테이블 정보 보기" - "read:admin:user-ips": "사용자 IP 주소 보기" + "read:admin:user-ips": "유저 IP 주소 보기" "read:admin:meta": "인스턴스 메타데이터 보기" - "write:admin:reset-password": "사용자 비밀번호 재설정하기" - "write:admin:resolve-abuse-user-report": "사용자 신고 처리하기" + "write:admin:reset-password": "유저 비밀번호 재설정하기" + "write:admin:resolve-abuse-user-report": "유저 신고 처리하기" "write:admin:send-email": "이메일 보내기" "read:admin:server-info": "서버 정보 보기" "read:admin:show-moderation-log": "조정 기록 보기" - "read:admin:show-user": "사용자 개인정보 보기" - "write:admin:suspend-user": "사용자 정지하기" - "write:admin:unset-user-avatar": "사용자 아바타 삭제하기" - "write:admin:unset-user-banner": "사용자 배너 삭제하기" - "write:admin:unsuspend-user": "사용자 정지 해제하기" + "read:admin:show-user": "유저 개인정보 보기" + "write:admin:suspend-user": "유저 정지하기" + "write:admin:unset-user-avatar": "유저 아바타 삭제하기" + "write:admin:unset-user-banner": "유저 배너 삭제하기" + "write:admin:unsuspend-user": "유저 정지 해제하기" "write:admin:meta": "인스턴스 메타데이터 수정하기" "write:admin:user-note": "조정 기록 수정하기" "write:admin:roles": "역할 수정하기" @@ -2229,15 +2290,15 @@ _permissions: "write:admin:avatar-decorations": "아바타 꾸미기 수정하기" "read:admin:avatar-decorations": "아바타 꾸미기 보기" "write:admin:federation": "연합 정보 수정하기" - "write:admin:account": "사용자 계정 수정하기" - "read:admin:account": "사용자 정보 보기" + "write:admin:account": "유저 계정 수정하기" + "read:admin:account": "유저 정보 보기" "write:admin:emoji": "이모지 수정하기" "read:admin:emoji": "이모지 보기" "write:admin:queue": "작업 대기열 수정하기" "read:admin:queue": "작업 대기열 정보 보기" "write:admin:promo": "홍보 기록 수정하기" - "write:admin:drive": "사용자 드라이브 수정하기" - "read:admin:drive": "사용자 드라이브 정보 보기" + "write:admin:drive": "유저 드라이브 수정하기" + "read:admin:drive": "유저 드라이브 정보 보기" "read:admin:stream": "관리자용 Websocket API 사용하기" "write:admin:ad": "광고 수정하기" "read:admin:ad": "광고 보기" @@ -2248,6 +2309,7 @@ _permissions: "read:federation": "연합 정보 불러오기" "write:report-abuse": "위반 내용 신고하기" "write:chat": "대화를 시작하거나 메시지를 보냅니다" + "read:chat": "채팅 열람하기" _auth: shareAccessTitle: "어플리케이션의 접근 허가" shareAccess: "‘{name}’에서 계정에 접근하는 것을 허용하시겠습니까?" @@ -2258,7 +2320,7 @@ _auth: callback: "앱으로 돌아갑니다" accepted: "접근 권한이 부여되었습니다." denied: "접근이 거부되었습니다" - scopeUser: "다음 사용자로 활동하고 있습니다." + scopeUser: "다음 유저로 활동하고 있습니다." pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인하십시오." byClickingYouWillBeRedirectedToThisUrl: "접근을 허용하면 자동으로 다음 URL로 이동합니다." _antennaSources: @@ -2295,7 +2357,7 @@ _widgets: postForm: "글 입력란" slideshow: "슬라이드 쇼" button: "버튼" - onlineUsers: "온라인 사용자" + onlineUsers: "온라인 유저" jobQueue: "작업 대기열" serverMetric: "서버 통계" aiscript: "AiScript 콘솔" @@ -2305,7 +2367,7 @@ _widgets: _userList: chooseList: "리스트 선택" clicker: "클리커" - birthdayFollowings: "오늘이 생일인 사용자" + birthdayFollowings: "오늘이 생일인 유저" _cw: hide: "숨기기" show: "더 보기" @@ -2357,7 +2419,7 @@ _postForm: f: "작성해주시길 기다리고 있어요..." _profile: name: "이름" - username: "사용자 이름" + username: "유저명" description: "자기소개" youCanIncludeHashtags: "해시 태그를 포함할 수 있습니다." metadata: "추가 정보" @@ -2388,7 +2450,7 @@ _charts: apRequest: "요청" usersIncDec: "유저 수 증감" usersTotal: "유저 수 합계" - activeUsers: "활동 사용자 수" + activeUsers: "활동 유저 수" notesIncDec: "노트 수 증감" localNotesIncDec: "로컬 노트 수 증감" remoteNotesIncDec: "리모트 노트 수 증감" @@ -2399,8 +2461,8 @@ _charts: storageUsageTotal: "스토리지 사용량 합계" _instanceCharts: requests: "요청" - users: "사용자 수 차이" - usersTotal: "누적 사용자 수" + users: "유저 수 차이" + usersTotal: "누적 유저 수" notes: "노트 수 증감" notesTotal: "누적 노트 수" ff: "팔로잉/팔로워 증감" @@ -2496,13 +2558,14 @@ _notification: newNote: "새 게시물" unreadAntennaNote: "안테나 {name}" roleAssigned: "역할이 부여 되었습니다." + chatRoomInvitationReceived: "채팅 룸에 초대받았습니다" emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다" achievementEarned: "도전 과제를 달성했습니다" testNotification: "알림 테스트" checkNotificationBehavior: "알림 표시를 체크하기" sendTestNotification: "테스트 알림 보내기" notificationWillBeDisplayedLikeThis: "알림이 이렇게 표시됩니다" - reactedBySomeUsers: "{n}명이 반응했습니다" + reactedBySomeUsers: "{n}명이 리액션했습니다" likedBySomeUsers: "{n}명이 좋아요를 했습니다" renotedBySomeUsers: "{n}명이 리노트했습니다" followedBySomeUsers: "{n}명에게 팔로우됨" @@ -2513,17 +2576,18 @@ _notification: createTokenDescription: "만약 기억이 나지 않는다면 '{text}'를 통해 액세스 토큰을 삭제해 주세요." _types: all: "전부" - note: "사용자의 새 글" + note: "유저의 새 글" follow: "팔로잉" mention: "멘션" reply: "답글" renote: "리노트" quote: "인용" - reaction: "반응" + reaction: "리액션" pollEnded: "투표가 종료됨" receiveFollowRequest: "팔로우 요청을 받았을 때" followRequestAccepted: "팔로우 요청이 승인되었을 때" - roleAssigned: "역할이 부여 됨" + roleAssigned: "역할이 부여됨" + chatRoomInvitationReceived: "채팅 룸에 초대받음" achievementEarned: "도전 과제 획득" exportCompleted: "추출을 성공함" login: "로그인" @@ -2537,6 +2601,9 @@ _notification: _deck: alwaysShowMainColumn: "메인 칼럼 항상 표시" columnAlign: "칼럼 정렬" + columnGap: "칼럼 간 여백" + deckMenuPosition: "덱 메뉴 위치" + navbarPosition: "내비게이션 바 위치" addColumn: "칼럼 추가" newNoteNotificationSettings: "새 노트 알림 설정" configureColumn: "칼럼 설정" @@ -2608,10 +2675,10 @@ _abuseReport: mail: "이메일" webhook: "Webhook" _captions: - mail: "모더레이터 권한을 가진 사용자의 이메일 주소에 알림을 보냅니다 (신고를 받은 때에만)" + mail: "모더레이터 권한을 가진 유저의 이메일 주소에 알림을 보냅니다 (신고를 받은 때에만)" webhook: "지정한 SystemWebhook에 알림을 보냅니다 (신고를 받은 때와 해결했을 때에 송신)" keywords: "키워드" - notifiedUser: "알릴 사용자" + notifiedUser: "알릴 유저" notifiedWebhook: "사용할 Webhook" deleteConfirm: "수신자를 삭제하시겠습니까?" _moderationLogTypes: @@ -2630,11 +2697,11 @@ _moderationLogTypes: deleteDriveFile: "파일 삭제" deleteNote: "노트 삭제" createGlobalAnnouncement: "전역 공지사항 생성" - createUserAnnouncement: "사용자 공지사항 만들기" + createUserAnnouncement: "유저에게 공지사항 만들기" updateGlobalAnnouncement: "모든 공지사항 수정" - updateUserAnnouncement: "사용자 공지사항 수정" + updateUserAnnouncement: "유저의 공지사항 수정" deleteGlobalAnnouncement: "모든 공지사항 삭제" - deleteUserAnnouncement: "사용자 공지사항 삭제" + deleteUserAnnouncement: "유저의 공지사항 삭제" resetPassword: "비밀번호 재설정" suspendRemoteInstance: "리모트 서버를 정지" unsuspendRemoteInstance: "리모트 서버의 정지를 해제" @@ -2662,7 +2729,8 @@ _moderationLogTypes: deleteAccount: "계정을 삭제" deletePage: "페이지를 삭제" deleteFlash: "Play를 삭제" - deleteGalleryPost: "갤러리 포스트를 삭제" + deleteGalleryPost: "갤러리 게시물을 삭제" + deleteChatRoom: "채팅 룸 삭제" updateProxyAccountDescription: "프록시 계정의 설명 업데이트" _fileViewer: title: "파일 상세" @@ -2875,7 +2943,7 @@ _embedCodeGen: _selfXssPrevention: warning: "경고" title: "“이 화면에 뭔가를 붙여넣어라\"는 것은 모두 사기입니다." - description1: "여기에 무언가를 붙여넣으면 악의적인 사용자에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다." + description1: "여기에 무언가를 붙여넣으면 악의적인 유저에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다." description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오." description3: "자세한 내용은 여기를 확인해 주세요. {link}" _followRequest: @@ -2908,8 +2976,8 @@ _captcha: title: "CAPTCHA 검증을 실패했습니다." text: "설정이 올바른지 다시 한 번 확인해보세요." _unknown: - title: "CAPTCHA 에러" - text: "알 수 없는 에러가 발생했습니다." + title: "CAPTCHA 오류" + text: "알 수 없는 오류가 발생했습니다." _bootErrors: title: "로딩이 실패함" serverError: "잠시 기다렸다가 다시 로드해도 여전히 문제가 해결되지 않으면 아래 Error ID와 함께 서버 관리자에게 연락해 주세요." @@ -2926,7 +2994,7 @@ _search: searchScopeAll: "전체" searchScopeLocal: "로컬" searchScopeServer: "서버 지정" - searchScopeUser: "사용자 지정" + searchScopeUser: "유저 지정" pleaseEnterServerHost: "서버의 호스트를 입력해 주세요." pleaseSelectUser: "유저를 선택해주세요" serverHostPlaceholder: "예: misskey.example.com" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 38e4814373..bc8c86f2c3 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -5,6 +5,7 @@ introMisskey: "Welkom! Misskey is een open source, gedecentraliseerde microblogd poweredByMisskeyDescription: "{name} is één van de services die door het open source platform Misskey wordt geleverd (het wordt ook wel een \"Misskey server genmoemd\")." monthAndDay: "{day} {month}" search: "Zoeken" +reset: "Herstellen" notifications: "Meldingen" username: "Gebruikersnaam" password: "Wachtwoord" @@ -48,6 +49,7 @@ pin: "Vastmaken aan profielpagina" unpin: "Losmaken van profielpagina" copyContent: "Kopiëren inhoud" copyLink: "Kopiëren link" +copyRemoteLink: "Remote-link kopiëren" copyLinkRenote: "" delete: "Verwijderen" deleteAndEdit: "Verwijderen en bewerken" @@ -63,6 +65,7 @@ copyFileId: "Kopieer veld ID" copyFolderId: "Kopieer folder ID" copyProfileUrl: "Kopieer profiel URL" searchUser: "Zoeken een gebruiker" +searchThisUsersNotes: "Notities van deze gebruiker doorzoeken" reply: "Antwoord" loadMore: "Laad meer" showMore: "Toon meer" @@ -129,9 +132,12 @@ emojiPicker: "Emoji kiezer" pinnedEmojisForReactionSettingDescription: "Kies de emojis die als eerste getoond worden tijdens het reageren" pinnedEmojisSettingDescription: "Kies de emojis die als eerste getoond worden tijdens het reageren" emojiPickerDisplay: "Emoji kiezer weergave" +overwriteFromPinnedEmojisForReaction: "Overschrijven met reactieinstellingen" +overwriteFromPinnedEmojis: "Overschrijven met algemene instellingen" reactionSettingDescription2: "Sleep om opnieuw te ordenen, Klik om te verwijderen, Druk op \"+\" om toe te voegen" rememberNoteVisibility: "Vergeet niet de notitie zichtbaarheidsinstellingen" attachCancel: "Verwijder bijlage" +deleteFile: "Bestand verwijderen" markAsSensitive: "Markeren als NSFW" unmarkAsSensitive: "Geen NSFW" enterFileName: "Invoeren bestandsnaam" @@ -147,6 +153,7 @@ suspendConfirm: "Ben je zeker dat je deze account wil suspenderen?" unsuspendConfirm: "Ben je zeker dat je deze account wil opnieuw aanstellen?" selectList: "Kies een lijst." selectAntenna: "Kies een antenne" +createAntenna: "Antenne aanmaken" selectWidget: "Kies een widget" editWidgets: "Bewerk widgets" editWidgetsExit: "Klaar" @@ -158,6 +165,7 @@ emojiUrl: "URL emoji" addEmoji: "Toevoegen emoji" settingGuide: "Aanbevolen instellingen" cacheRemoteFiles: "Externe bestanden cachen" +cacheRemoteFilesDescription: "Als deze instelling uitgeschakeld is worden bestanden altijd direct van remote servers geladen. Hiermee wordt opslagruimte bespaard, maar doordat er geen thumbnails worden gegenereerd, zal netwerkverkeer toenemen." flagAsBot: "Markeer dit account als een robot." flagAsBotDescription: "Als dit account van een programma wordt beheerd, zet deze vlag aan. Het aanzetten helpt andere ontwikkelaars om bijvoorbeeld onbedoelde feedback loops te doorbreken of om Misskey meer geschikt te maken." flagAsCat: "Markeer dit account als een kat." @@ -168,6 +176,10 @@ autoAcceptFollowed: "Accepteer verzoeken om jezelf te volgen vanzelf als je de v addAccount: "Account toevoegen" loginFailed: "Aanmelding mislukt." showOnRemote: "Toon op de externe instantie." +continueOnRemote: "Verder op remote server" +chooseServerOnMisskeyHub: "Kies een server van de Misskey Hub" +specifyServerHost: "Serverhost uitkiezen" +inputHostName: "Domein invullen" general: "Algemeen" wallpaper: "Achtergrond" setWallpaper: "Achtergrond instellen" @@ -178,6 +190,7 @@ followConfirm: "Weet je zeker dat je {name} wilt volgen?" proxyAccount: "Proxy account" proxyAccountDescription: "Een proxy-account is een account dat onder bepaalde voorwaarden fungeert als externe volger voor gebruikers. Als een gebruiker bijvoorbeeld een externe gebruiker aan de lijst toevoegt, wordt de activiteit van de externe gebruiker niet aan de server geleverd als geen lokale gebruiker die gebruiker volgt, dus het proxy-account volgt in plaats daarvan." host: "Server" +selectSelf: "Mezelf kiezen" selectUser: "Kies een gebruiker" recipient: "Ontvanger" annotation: "Reacties" @@ -192,6 +205,7 @@ perHour: "Per uur" perDay: "Per dag" stopActivityDelivery: "Stop met versturen activiteiten" blockThisInstance: "Blokkeer deze server" +mediaSilenceThisInstance: "Media van deze server dempen" operations: "Verwerkingen" software: "Software" version: "Versie" @@ -211,6 +225,11 @@ clearCachedFiles: "Cache opschonen" clearCachedFilesConfirm: "Weet je zeker dat je alle externe bestanden in de cache wilt verwijderen?" blockedInstances: "Geblokkeerde servers" blockedInstancesDescription: "Maak een lijst van de servers die moeten worden geblokkeerd, gescheiden door regeleinden. Geblokkeerde servers kunnen niet meer communiceren met deze server." +silencedInstancesDescription: "Geef de hostnamen van de servers die je wil dempen op, elk op hun eigen regel. Alle accounts die bij de opgegeven servers horen worden als gedempt behandeld, kunnen alleen maar volgverzoeken maken, en kunnen lokale accounts niet vermelden als ze niet gevolgd worden. Geblokkeerde servers worden hier niet door beïnvloed." +mediaSilencedInstances: "Media-gedempte servers" +mediaSilencedInstancesDescription: "Geef de hostnamen van de servers die je wil media-dempen op, elk op hun eigen regel. Alle accounts die bij de opgegeven servers horen worden als gedempt behandeld, en kunnen geen eigen emojis gebruiken. Geblokkeerde servers worden hier niet door beïnvloed." +federationAllowedHosts: "Servers die mogen federeren " +federationAllowedHostsDescription: "Geef de hostnamen van de servers die mogen federeren op, elk op hun eigen regel." muteAndBlock: "Gedempt en geblokkeerd" mutedUsers: "Gedempte gebruikers" blockedUsers: "Geblokkeerde gebruikers" @@ -255,6 +274,7 @@ removed: "Succesvol verwijderd" removeAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" deleteAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" resetAreYouSure: "Resetten?" +areYouSure: "Weet je het zeker?" saved: "Opgeslagen" upload: "Uploaden" keepOriginalUploading: "Origineel beeld behouden." @@ -268,6 +288,7 @@ uploadFromUrlMayTakeTime: "Het kan even duren voordat het uploaden voltooid is." explore: "Verkennen" messageRead: "Lezen" noMoreHistory: "Er is geen verdere geschiedenis" +startChat: "Chat starten" nUsersRead: "gelezen door {n}" agreeTo: "Ik stem in met {0}" start: "Aan de slag" @@ -294,12 +315,15 @@ selectFile: "Kies een bestand" selectFiles: "Selecteer bestanden" selectFolder: "Kies een map" selectFolders: "Kies mappen" +fileNotSelected: "Geen bestand geselecteerd" renameFile: "Wijzig bestandsnaam" folderName: "Mapnaam" createFolder: "Map aanmaken" renameFolder: "Map hernoemen" deleteFolder: "Map verwijderen" +folder: "Map" addFile: "Bestand toevoegen" +showFile: "Bestanden weergeven" emptyDrive: "Jouw Drive is leeg." emptyFolder: "Deze map is leeg" unableToDelete: "Kan niet worden verwijderd" @@ -355,8 +379,11 @@ hcaptcha: "hCaptcha" enableHcaptcha: "Inschakelen hCaptcha" hcaptchaSiteKey: "Site sleutel" hcaptchaSecretKey: "Geheime sleutel" +mcaptcha: "mCaptcha" +enableMcaptcha: "mCaptcha activeren" mcaptchaSiteKey: "Site sleutel" mcaptchaSecretKey: "Geheime sleutel" +mcaptchaInstanceUrl: "mCaptcha server-URL" recaptcha: "reCAPTCHA" enableRecaptcha: "Inschakelen reCAPTCHA" recaptchaSiteKey: "Site sleutel" @@ -371,6 +398,7 @@ name: "Naam" antennaSource: "Bron antenne" antennaKeywords: "Sleutelwoorden" antennaExcludeKeywords: "Blokkeerwoorden" +antennaExcludeBots: "Bot-accounts uitsluiten" withReplies: "Antwoorden toevoegen" connectedTo: "De volgende accounts zijn verbonden" notesAndReplies: "Berichten en reacties" @@ -421,7 +449,13 @@ retype: "Opnieuw invoeren" noteOf: "Notitie van {user}" quoteAttached: "Citaat" quoteQuestion: "Toevoegen als citaat?" +signinOrContinueOnRemote: "Ga naar je eigen instantie of registreer je/log in op deze server om door te gaan." invitations: "Uitnodigen" +menuStyle: "Menustijl" +style: "Stijl" +drawer: "Lade" +popup: "Pop-up" +showReactionsCount: "Zie het aantal reacties op notities" dashboard: "Overzicht" local: "Lokaal" remote: "Remote" @@ -437,16 +471,41 @@ numberOfDays: "Aantal dagen" hideThisNote: "Verberg deze notitie" showFeaturedNotesInTimeline: "Laat featured notities in tijdlijn zien" sound: "Geluid" +notUseSound: "Geluid uitschakelen" +useSoundOnlyWhenActive: "Geluid alleen inschakelen wanneer Misskey actief is" +uiInspector: "UI-inspecteur" +unsetUserAvatar: "Avatar verwijderen" +unsetUserAvatarConfirm: "Weet je zeker dat je je avatar wil verwijderen?" +unsetUserBanner: "Banner verwijderen" +unsetUserBannerConfirm: "Weet je zeker dat je je banner wil verwijderen?" +expandTweet: "Notitie uitklappen" +adminPermission: "Administratorrechten" smtpHost: "Server" smtpUser: "Gebruikersnaam" smtpPass: "Wachtwoord" +wordMuteDescription: "Minimaliseert notities die het gespecificeerde woord of zin bevatten. Geminimaliseerde notities kunnen worden weergegeven door er op te klikken." +hardWordMute: "Harde woorddemping" +showMutedWord: "Gedempte woorden weergeven" +hardWordMuteDescription: "Verbert notities die het gespecificeerde woord of zin bevatten. In tegenstelling tot woorddemping wordt de notitie volledig verborgen." +userSaysSomethingAbout: "{name} zei iets over '{word}'" +copiedToClipboard: "Naar het klembord gekopieerd" +theKeywordWhenSearchingForCustomEmoji: "Dit is het keyword dat gebruikt wordt bij het zoeken naar eigen emojis." +fillAbuseReportDescription: "Vul s.v.p. de details in over deze melding. Geef, als het over een specifieke notitie gaat, ook de URL op." +reloadToApplySetting: "Deze instelling gaat pas in nadat de pagina herladen is. Nu herladen?" clearCache: "Cache opschonen" info: "Over" user: "Gebruikers" +noInquiryUrlWarning: "Contact-URL niet opgegeven" muteThread: "Discussies dempen " unmuteThread: "Dempen van discussie ongedaan maken" +followingVisibility: "Zichtbaarheid van gevolgden" +followersVisibility: "Zichtbaarheid van volgers" +incorrectTotp: "Het eenmalige wachtwoord is incorrect of verlopen" hide: "Verbergen" searchByGoogle: "Zoeken" +threeMonths: "3 maanden" +oneYear: "1 jaar" +threeDays: "3 dagen" cropImage: "Afbeelding bijsnijden" cropImageAsk: "Bijsnijdengevraagd" file: "Bestanden" @@ -457,9 +516,28 @@ pushNotificationAlreadySubscribed: "Pushberichtrn al ingeschakeld" windowMaximize: "Maximaliseren" windowRestore: "Herstellen" loggedInAsBot: "Momenteel als bot ingelogd" +correspondingSourceIsAvailable: "De bijbehorende broncode is beschikbaar bij {anchor}" +invalidParamErrorDescription: "De aanvraagparameters zijn ongeldig. Dit komt meestal door een bug, maar kan ook omdat de invoer te lang is of iets dergelijks." +collapseRenotes: "Renotes die je al gezien hebt, inklappen" +collapseRenotesDescription: "Klapt notities in waar je al op gereageerd hebt of die je al gerenotet hebt." +prohibitedWords: "Verboden woorden" +prohibitedWordsDescription: "Activeert een foutmelding als er geprobeerd wordt een notitie met de ingestelde woorden te plaatsen. Meerdere woorden kunnen worden ingesteld, elk op hun eigen regel." +hiddenTags: "Verborgen hashtags" +hiddenTagsDescription: "Selecteer tags die niet worden weergegeven in de trends. Meerdere tags kunnen worden geregistreerd, elk op hun eigen regel." +enableStatsForFederatedInstances: "Statistieken van remote servers ontvangen" +limitWidthOfReaction: "Limiteert de maximale breedte van reacties en geef ze verkleind weer" +audio: "Audio" +audioFiles: "Audio" +archived: "Gearchiveerd" +unarchive: "Dearchiveren" +lookupConfirm: "Weet je zeker dat je dit wil opzoeken?" +openTagPageConfirm: "Wil je deze hashtagpagina openen?" +specifyHost: "Specificeer host" icon: "Avatar" replies: "Antwoord" renotes: "Herdelen" +followingOrFollower: "Gevolgd of volger" +confirmShowRepliesAll: "Dit is een onomkeerbare operatie. Weet je zeker dat reacties op anderen van iedereen die je volgt, wil weergeven in je tijdlijn?" information: "Over" _chat: invitations: "Uitnodigen" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index d52473b8c1..46f8939137 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1212,7 +1212,6 @@ _theme: header: "Nagłówek" navBg: "Tło paska bocznego" navFg: "Tekst paska bocznego" - navHoverFg: "Tekst paska bocznego (zbliżenie)" navActive: "Tekst paska bocznego (aktywny)" navIndicator: "Wskaźnik paska bocznego" link: "Odnośnik" @@ -1235,11 +1234,8 @@ _theme: buttonHoverBg: "Tło przycisku (po najechaniu)" inputBorder: "Obramowanie pola wejścia" driveFolderBg: "Tło folderu na dysku" - wallpaperOverlay: "Nakładka tapety" badge: "Odznaka" messageBg: "Tło czatu" - accentDarken: "Akcent (ciemniejszy)" - accentLighten: "Akcent (jaśniejszy)" fgHighlighted: "Wyróżniony tekst" _sfx: note: "Wpisy" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 1660969e15..e899685d9f 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -1997,7 +1997,6 @@ _theme: header: "Cabeçalho" navBg: "Plano de fundo da barra lateral" navFg: "Texto da barra lateral" - navHoverFg: "Texto da coluna lateral (Selecionado)" navActive: "Texto da coluna lateral (Ativa)" navIndicator: "Indicador da coluna lateral" link: "Link" @@ -2020,11 +2019,8 @@ _theme: buttonHoverBg: "Plano de fundo de botão (Selecionado)" inputBorder: "Borda de campo digitável" driveFolderBg: "Plano de fundo da pasta no Drive" - wallpaperOverlay: "Sobreposição do papel de parede." badge: "Emblema" messageBg: "Plano de fundo do chat" - accentDarken: "Cor de destaque (Escurecida)" - accentLighten: "Cor de destaque (Esclarecida)" fgHighlighted: "Texto Destacado" _sfx: note: "Posts" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 496bd147ae..e81af534e7 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -1689,7 +1689,6 @@ _theme: header: "Заголовок" navBg: "Фон боковой панели" navFg: "Текст на боковой панели" - navHoverFg: "Текст на боковой панели (под указателем)" navActive: "Текст на боковой панели (активирован)" navIndicator: "Индикатор на боковой панели" link: "Ссылка" @@ -1712,11 +1711,8 @@ _theme: buttonHoverBg: "Текст кнопки" inputBorder: "Рамка поля ввода" driveFolderBg: "Фон папки «Диска»" - wallpaperOverlay: "Слой обоев" badge: "Значок" messageBg: "Фон беседы" - accentDarken: "Фон (затемнённый)" - accentLighten: "Фон (осветлённый)" fgHighlighted: "Подсвеченный текст" _sfx: note: "Заметки" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 7f7827202f..1638fd293e 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -1089,7 +1089,6 @@ _theme: header: "Hlavička" navBg: "Pozadie bočného panela" navFg: "Text bočného panela" - navHoverFg: "Text bočného panela (pod kurzorom)" navActive: "Text bočného panela (aktívny)" navIndicator: "Indikátor bočného panela" link: "Odkaz" @@ -1112,11 +1111,8 @@ _theme: buttonHoverBg: "Pozadie tlačidla (pod kurzorom)" inputBorder: "Okraj vstupného poľa" driveFolderBg: "Pozadie priečinu disku" - wallpaperOverlay: "Vrstvenie pozadia" badge: "Odznak" messageBg: "Pozadie chatu" - accentDarken: "Akcent (stmavené)" - accentLighten: "Akcent (zosvetlené)" fgHighlighted: "Zvýraznený text" _sfx: note: "Poznámky" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index e93d65b5c6..06f68c85fe 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -1974,7 +1974,6 @@ _theme: header: "ส่วนหัว" navBg: "พื้นหลังแถบด้านข้าง" navFg: "ข้อความแถบด้านข้าง" - navHoverFg: "ข้อความแถบด้านข้าง (โฮเวอร์)" navActive: "ข้อความแถบด้านข้าง (ใช้งานอยู่)" navIndicator: "ตัวระบุแถบด้านข้าง" link: "ลิงก์" @@ -1997,11 +1996,8 @@ _theme: buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)" inputBorder: "เส้นขอบของช่องป้อนข้อมูล" driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์" - wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ" badge: "ตรา" messageBg: "พื้นหลังแชท" - accentDarken: "สีหลัก (มืด)" - accentLighten: "สีหลัก (สว่าง)" fgHighlighted: "ข้อความที่ไฮไลต์" _sfx: note: "โน้ต" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 01849dc484..6a970fadfb 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -1283,7 +1283,6 @@ _theme: header: "Заголовок" navBg: "Фон бокової панелі" navFg: "Текст бокової панелі" - navHoverFg: "Текст бокової панелі (під курсором)" navActive: "Текст бокової панелі (активне)" navIndicator: "Індикатор бокової панелі" link: "Посилання" @@ -1306,11 +1305,8 @@ _theme: buttonHoverBg: "Фон кнопки (при наведенні)" inputBorder: "Край поля вводу" driveFolderBg: "Фон папки на диску" - wallpaperOverlay: "Накладання шпалер" badge: "Значок" messageBg: "Фон переписки" - accentDarken: "Акцент (Затемлений)" - accentLighten: "Акцент (Освітлений)" fgHighlighted: "Виділений текст" _sfx: note: "Нотатки" diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index daa268f1c4..8289a6d60c 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -907,8 +907,6 @@ _theme: mention: "Murojat" renote: "Qayta qayd etish" divider: "Ajratrmoq" - accentDarken: "Urg'u (Qoraytirilgan)" - accentLighten: "Urg'u (Yoritilgan)" fgHighlighted: "Belgilangan matn" _sfx: note: "Qaydlar" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 3a2df8d83e..16917ebf06 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1530,7 +1530,6 @@ _theme: header: "Ảnh bìa" navBg: "Nền thanh bên" navFg: "Chữ thanh bên" - navHoverFg: "Chữ thanh bên (Khi chạm)" navActive: "Chữ thanh bên (Khi chọn)" navIndicator: "Chỉ báo thanh bên" link: "Đường dẫn" @@ -1553,11 +1552,8 @@ _theme: buttonHoverBg: "Nền nút (Chạm)" inputBorder: "Đường viền khung soạn thảo" driveFolderBg: "Nền thư mục Ổ đĩa" - wallpaperOverlay: "Lớp phủ hình nền" badge: "Huy hiệu" messageBg: "Nền chat" - accentDarken: "Màu phụ (Tối)" - accentLighten: "Màu phụ (Sáng)" fgHighlighted: "Chữ nổi bật" _sfx: note: "Tút" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 8371473c94..b313c4ce1f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "排除机器人账户" antennaKeywordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" notifyAntenna: "开启通知" withFileAntenna: "仅带有附件的帖子" +excludeNotesInSensitiveChannel: "排除敏感频道内的帖子" enableServiceworker: "启用 ServiceWorker" antennaUsersDescription: "指定用户名,一行一个" caseSensitive: "区分大小写" @@ -1339,6 +1340,10 @@ compress: "压缩" right: "右" bottom: "下" top: "上" +embed: "嵌入" +settingsMigrating: "正在迁移设置,请稍候。(之后也可以在设置 → 其它 → 迁移旧设置来手动迁移)" +readonly: "只读" +goToDeck: "返回至 Deck" _chat: noMessagesYet: "还没有消息" newMessage: "新消息" @@ -1368,6 +1373,7 @@ _chat: muteThisRoom: "静音此房间" deleteRoom: "删除房间" chatNotAvailableForThisAccountOrServer: "此服务器或者账户还未开启聊天功能。" + chatIsReadOnlyForThisAccountOrServer: "此服务器或者账户内的聊天为只读。无法发布新信息或创建及加入群聊。" chatNotAvailableInOtherAccount: "对方账户目前处于无法使用聊天的状态。" cannotChatWithTheUser: "无法与此用户聊天" cannotChatWithTheUser_description: "可能现在无法使用聊天,或者对方未开启聊天。" @@ -1885,6 +1891,8 @@ _role: descriptionOfIsExplorable: "打开后将公开角色时间线。如果角色不是公开的,就无法公开时间线。" displayOrder: "显示顺序" descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" + preserveAssignmentOnMoveAccount: "将分配状态继承到目标账户" + preserveAssignmentOnMoveAccount_description: "启用后,当迁移具有该角色的账户时,目标账户也会继承该角色。" canEditMembersByModerator: "允许监察员编辑成员" descriptionOfCanEditMembersByModerator: "如果选中,监察员和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" priority: "优先级" @@ -1925,7 +1933,7 @@ _role: canImportFollowing: "允许导入关注列表" canImportMuting: "允许导入隐藏列表" canImportUserLists: "允许导入用户列表" - canChat: "允许聊天" + chatAvailability: "允许聊天" _condition: roleAssignedTo: "已分配给手动角色" isLocal: "是本地用户" @@ -2122,7 +2130,6 @@ _theme: header: "顶栏" navBg: "侧边栏背景" navFg: "侧栏文本" - navHoverFg: "侧栏文本(悬停)" navActive: "侧栏文本(活动)" navIndicator: "侧栏标记" link: "链接" @@ -2145,11 +2152,8 @@ _theme: buttonHoverBg: "按钮背景(悬停)" inputBorder: "输入框边框" driveFolderBg: "网盘的文件夹背景" - wallpaperOverlay: "壁纸叠加层" badge: "徽章" messageBg: "聊天背景" - accentDarken: "强调色(深)" - accentLighten: "强调色(浅)" fgHighlighted: "高亮显示文本" _sfx: note: "帖子" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index b04e1daab6..57b7e84b8a 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -1339,6 +1339,9 @@ compress: "壓縮" right: "右" bottom: "下" top: "上" +embed: "嵌入" +settingsMigrating: "正在移轉設定。請稍候……(之後也可以到「設定 → 其他 → 舊設定資訊移轉」中手動進行移轉)" +readonly: "唯讀" _chat: noMessagesYet: "尚無訊息" newMessage: "新訊息" @@ -1368,6 +1371,7 @@ _chat: muteThisRoom: "此聊天室已靜音" deleteRoom: "刪除聊天室" chatNotAvailableForThisAccountOrServer: "這個伺服器或這個帳號的聊天功能尚未啟用。" + chatIsReadOnlyForThisAccountOrServer: "在此伺服器或此帳戶上的聊天是唯讀的。您無法發布新訊息、建立或加入聊天室。" chatNotAvailableInOtherAccount: "對方的帳號無法使用聊天功能。" cannotChatWithTheUser: "無法與此使用者聊天" cannotChatWithTheUser_description: "聊天功能目前無法使用,或對方尚未開放聊天功能。" @@ -1413,6 +1417,7 @@ _settings: showNavbarSubButtons: "在導覽列顯示輔助按鈕" ifOn: "開啟時" ifOff: "關閉時" + enableSyncThemesBetweenDevices: "在裝置之間同步已安裝的主題" _chat: showSenderName: "顯示發送者的名稱" sendOnEnter: "按下 Enter 發送訊息" @@ -1432,7 +1437,7 @@ _preferencesBackup: _accountSettings: requireSigninToViewContents: "須登入以顯示內容" requireSigninToViewContentsDescription1: "必須登入才會顯示您建立的貼文等內容。可望有效防止資訊被爬蟲蒐集。" - requireSigninToViewContentsDescription2: "來自不支援 URL 預覽 (OGP)、 網頁嵌入和引用貼文的伺服器,也將停止顯示。" + requireSigninToViewContentsDescription2: "針對您貼文的 URL 預覽 (OGP) 與網頁嵌入功能將會無法使用。而不支援引用貼文的伺服器,也將停止顯示。" requireSigninToViewContentsDescription3: "這些限制可能不適用於被聯邦發送至遠端伺服器的內容。" makeNotesFollowersOnlyBefore: "讓過去的貼文僅對追隨者顯示" makeNotesFollowersOnlyBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對追隨者顯示。 如果您再次停用它,貼文的公開狀態也會恢復原狀。" @@ -1885,6 +1890,8 @@ _role: descriptionOfIsExplorable: "若開啟則公開角色時間軸。若角色不是公開的,則無法公開時間軸。" displayOrder: "顯示順序" descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" + preserveAssignmentOnMoveAccount: "將指派狀態承接至轉移後的帳戶" + preserveAssignmentOnMoveAccount_description: "開啟此選項後,當具備此角色的帳戶被移轉時,該角色也會承接至轉移後的帳戶。" canEditMembersByModerator: "允許編輯審查員的成員" descriptionOfCanEditMembersByModerator: "如果開啟,管理員與審查員都可以為使用者指派/解除指派該角色。如果關閉,則只有管理員可以執行。" priority: "優先級" @@ -1925,7 +1932,7 @@ _role: canImportFollowing: "允許匯入追隨名單" canImportMuting: "允許匯入靜音名單" canImportUserLists: "允許匯入清單" - canChat: "允許聊天" + chatAvailability: "允許聊天" _condition: roleAssignedTo: "手動指派角色完成" isLocal: "本地使用者" @@ -2122,7 +2129,6 @@ _theme: header: "標題" navBg: "側邊欄的背景 " navFg: "側邊欄的文字" - navHoverFg: "側邊欄文字(懸浮) " navActive: "側邊欄文字(活動)" navIndicator: "側邊欄指示符" link: "連結" @@ -2145,11 +2151,8 @@ _theme: buttonHoverBg: "按鈕背景 (漂浮)" inputBorder: "輸入框邊框" driveFolderBg: "雲端硬碟文件夾背景" - wallpaperOverlay: "壁紙覆蓋層" badge: "徽章" messageBg: "私訊背景" - accentDarken: "強調色(黑暗)" - accentLighten: "強調色(明亮)" fgHighlighted: "突顯文字" _sfx: note: "貼文" diff --git a/package.json b/package.json index 633a0a0293..3e59baf982 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.4.0-beta.0", + "version": "2025.4.0", "codename": "nasubi", "repository": { "type": "git", @@ -24,7 +24,6 @@ "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", "build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api", - "build-frontend-search-index": "pnpm --filter frontend build-search-index", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", @@ -85,7 +84,8 @@ "@aiscript-dev/aiscript-languageserver": "-" }, "patchedDependencies": { - "re2": "scripts/dependency-patches/re2.patch" + "re2": "scripts/dependency-patches/re2.patch", + "vite": "scripts/dependency-patches/vite.patch" } } } diff --git a/packages/backend/migration/1736230492103-addAntennaHideNotesInSensitiveChannel.js b/packages/backend/migration/1736230492103-addAntennaHideNotesInSensitiveChannel.js new file mode 100644 index 0000000000..74225de96a --- /dev/null +++ b/packages/backend/migration/1736230492103-addAntennaHideNotesInSensitiveChannel.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddAntennaHideNotesInSensitiveChannel1736230492103 { + name = 'AddAntennaHideNotesInSensitiveChannel1736230492103' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" ADD "hideNotesInSensitiveChannel" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "hideNotesInSensitiveChannel"`); + } +} diff --git a/packages/backend/migration/1743558299182-RoleCopyOnMoveAccount.js b/packages/backend/migration/1743558299182-RoleCopyOnMoveAccount.js new file mode 100644 index 0000000000..ff4f7a051b --- /dev/null +++ b/packages/backend/migration/1743558299182-RoleCopyOnMoveAccount.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RoleCopyOnMoveAccount1743558299182 { + name = 'RoleCopyOnMoveAccount1743558299182' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "preserveAssignmentOnMoveAccount" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "preserveAssignmentOnMoveAccount"`); + } +} diff --git a/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js b/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js new file mode 100644 index 0000000000..1e8faafbc4 --- /dev/null +++ b/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ExcludeNotesInSensitiveChannel1744075766000 { + name = 'ExcludeNotesInSensitiveChannel1744075766000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" RENAME COLUMN "hideNotesInSensitiveChannel" TO "excludeNotesInSensitiveChannel"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" RENAME COLUMN "excludeNotesInSensitiveChannel" TO "hideNotesInSensitiveChannel"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index bcaa6357ce..5e8529c422 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -37,17 +37,17 @@ }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.11.11", - "@swc/core-darwin-x64": "1.11.11", + "@swc/core-darwin-arm64": "1.11.18", + "@swc/core-darwin-x64": "1.11.18", "@swc/core-freebsd-x64": "1.3.11", - "@swc/core-linux-arm-gnueabihf": "1.11.11", - "@swc/core-linux-arm64-gnu": "1.11.11", - "@swc/core-linux-arm64-musl": "1.11.11", - "@swc/core-linux-x64-gnu": "1.11.11", - "@swc/core-linux-x64-musl": "1.11.11", - "@swc/core-win32-arm64-msvc": "1.11.11", - "@swc/core-win32-ia32-msvc": "1.11.11", - "@swc/core-win32-x64-msvc": "1.11.11", + "@swc/core-linux-arm-gnueabihf": "1.11.18", + "@swc/core-linux-arm64-gnu": "1.11.18", + "@swc/core-linux-arm64-musl": "1.11.18", + "@swc/core-linux-x64-gnu": "1.11.18", + "@swc/core-linux-x64-musl": "1.11.18", + "@swc/core-win32-arm64-msvc": "1.11.18", + "@swc/core-win32-ia32-msvc": "1.11.18", + "@swc/core-win32-x64-msvc": "1.11.18", "@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs-node": "4.22.0", "bufferutil": "4.0.9", @@ -67,8 +67,8 @@ "utf-8-validate": "6.0.5" }, "dependencies": { - "@aws-sdk/client-s3": "3.772.0", - "@aws-sdk/lib-storage": "3.772.0", + "@aws-sdk/client-s3": "3.782.0", + "@aws-sdk/lib-storage": "3.782.0", "@discordapp/twemoji": "15.1.0", "@fastify/accepts": "5.0.2", "@fastify/cookie": "11.0.2", @@ -78,12 +78,12 @@ "@fastify/multipart": "9.0.3", "@fastify/static": "8.1.1", "@fastify/view": "10.0.2", - "@misskey-dev/sharp-read-bmp": "1.2.0", + "@misskey-dev/sharp-read-bmp": "1.3.0", "@misskey-dev/summaly": "5.2.0", - "@napi-rs/canvas": "0.1.68", - "@nestjs/common": "11.0.12", - "@nestjs/core": "11.0.12", - "@nestjs/testing": "11.0.12", + "@napi-rs/canvas": "0.1.69", + "@nestjs/common": "11.0.16", + "@nestjs/core": "11.0.15", + "@nestjs/testing": "11.0.15", "@peertube/http-signature": "1.7.0", "@sentry/node": "8.55.0", "@sentry/profiling-node": "8.55.0", @@ -91,7 +91,7 @@ "@sinonjs/fake-timers": "11.3.1", "@smithy/node-http-handler": "2.5.0", "@swc/cli": "0.6.0", - "@swc/core": "1.11.11", + "@swc/core": "1.11.18", "@twemoji/parser": "15.1.1", "accepts": "1.3.8", "ajv": "8.17.1", @@ -100,7 +100,7 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.3", - "bullmq": "5.44.1", + "bullmq": "5.48.1", "cacheable-lookup": "7.0.0", "cbor": "9.0.2", "chalk": "5.4.1", @@ -111,13 +111,13 @@ "content-disposition": "0.5.4", "date-fns": "2.30.0", "deep-email-validator": "0.1.21", - "fastify": "5.2.1", + "fastify": "5.2.2", "fastify-raw-body": "5.0.0", "feed": "4.2.2", "file-type": "19.6.0", "fluent-ffmpeg": "2.1.3", "form-data": "4.0.2", - "got": "14.4.6", + "got": "14.4.7", "happy-dom": "16.8.1", "hpagent": "1.2.0", "htmlescape": "1.1.1", @@ -148,7 +148,7 @@ "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.3.6", + "otpauth": "9.4.0", "parse5": "7.2.1", "pg": "8.14.1", "pkce-challenge": "4.1.0", @@ -166,17 +166,17 @@ "rxjs": "7.8.2", "sanitize-html": "2.15.0", "secure-json-parse": "3.0.2", - "sharp": "0.33.5", + "sharp": "0.34.1", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "systeminformation": "5.25.11", "tinycolor2": "1.6.0", "tmp": "0.2.3", - "tsc-alias": "1.8.11", + "tsc-alias": "1.8.15", "tsconfig-paths": "4.2.0", - "typeorm": "0.3.21", - "typescript": "5.8.2", + "typeorm": "0.3.22", + "typescript": "5.8.3", "ulid": "2.4.0", "vary": "1.1.2", "web-push": "3.6.7", @@ -186,6 +186,7 @@ "devDependencies": { "@jest/globals": "29.7.0", "@nestjs/platform-express": "10.4.15", + "@sentry/vue": "9.12.0", "@simplewebauthn/types": "12.0.0", "@swc/jest": "0.2.37", "@types/accepts": "1.3.7", @@ -204,7 +205,7 @@ "@types/jsrsasign": "10.5.15", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "22.13.10", + "@types/node": "22.14.0", "@types/nodemailer": "6.4.17", "@types/oauth": "0.9.6", "@types/oauth2orize": "1.11.5", @@ -215,17 +216,17 @@ "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", - "@types/sanitize-html": "2.13.0", - "@types/semver": "7.5.8", + "@types/sanitize-html": "2.15.0", + "@types/semver": "7.7.0", "@types/simple-oauth2": "5.0.7", "@types/sinonjs__fake-timers": "8.1.5", "@types/tinycolor2": "1.4.6", "@types/tmp": "0.2.6", "@types/vary": "1.1.3", "@types/web-push": "3.6.4", - "@types/ws": "8.18.0", - "@typescript-eslint/eslint-plugin": "8.27.0", - "@typescript-eslint/parser": "8.27.0", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", "aws-sdk-client-mock": "4.1.0", "cross-env": "7.0.3", "eslint-plugin-import": "2.31.0", diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 32ea700748..646fa07911 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -7,7 +7,8 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; -import * as Sentry from '@sentry/node'; +import type * as Sentry from '@sentry/node'; +import type * as SentryVue from '@sentry/vue'; import type { RedisOptions } from 'ioredis'; type RedisOptionsSource = Partial & { @@ -62,7 +63,12 @@ type Source = { scope?: 'local' | 'global' | string[]; }; sentryForBackend?: { options: Partial; enableNodeProfiling: boolean; }; - sentryForFrontend?: { options: Partial }; + sentryForFrontend?: { + options: Partial & { dsn: string }; + vueIntegration?: SentryVue.VueIntegrationOptions | null; + browserTracingIntegration?: Parameters[0] | null; + replayIntegration?: Parameters[0] | null; + }; publishTarballInsteadOfProvideRepositoryUrl?: boolean; @@ -198,7 +204,12 @@ export type Config = { redisForTimelines: RedisOptions & RedisOptionsSource; redisForReactions: RedisOptions & RedisOptionsSource; sentryForBackend: { options: Partial; enableNodeProfiling: boolean; } | undefined; - sentryForFrontend: { options: Partial } | undefined; + sentryForFrontend: { + options: Partial & { dsn: string }; + vueIntegration?: SentryVue.VueIntegrationOptions | null; + browserTracingIntegration?: Parameters[0] | null; + replayIntegration?: Parameters[0] | null; + } | undefined; perChannelMaxNoteCacheCount: number; perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 0fbb9bcd80..406563bee8 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -24,6 +24,7 @@ import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { RoleService } from '@/core/RoleService.js'; @Injectable() export class AccountMoveService { @@ -61,6 +62,7 @@ export class AccountMoveService { private relayService: RelayService, private queueService: QueueService, private systemAccountService: SystemAccountService, + private roleService: RoleService, ) { } @@ -119,6 +121,7 @@ export class AccountMoveService { await Promise.all([ this.copyBlocking(src, dst), this.copyMutings(src, dst), + this.copyRoles(src, dst), this.updateLists(src, dst), ]); } catch { @@ -201,6 +204,32 @@ export class AccountMoveService { await this.mutingsRepository.insert(arrayToInsert); } + @bindThis + public async copyRoles(src: ThinUser, dst: ThinUser): Promise { + // Insert new roles with the same values except userId + // role service may have cache for roles so retrieve roles from service + const [oldRoleAssignments, roles] = await Promise.all([ + this.roleService.getUserAssigns(src.id), + this.roleService.getRoles(), + ]); + + if (oldRoleAssignments.length === 0) return; + + // No promise all since the only async operation is writing to the database + for (const oldRoleAssignment of oldRoleAssignments) { + const role = roles.find(x => x.id === oldRoleAssignment.roleId); + if (role == null) continue; // Very unlikely however removing role may cause this case + if (!role.preserveAssignmentOnMoveAccount) continue; + + try { + await this.roleService.assign(dst.id, role.id, oldRoleAssignment.expiresAt); + } catch (e) { + if (e instanceof RoleService.AlreadyAssignedError) continue; + throw e; + } + } + } + /** * Update lists while moving accounts. * - No removal of the old account from the lists diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index e827ffa68c..7db3e69155 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -5,18 +5,19 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import * as Acct from '@/misc/acct.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; import type { MiAntenna } from '@/models/Antenna.js'; import type { MiNote } from '@/models/Note.js'; import type { MiUser } from '@/models/User.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import * as Acct from '@/misc/acct.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { CacheService } from './CacheService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -37,6 +38,7 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.userListMembershipsRepository) private userListMembershipsRepository: UserListMembershipsRepository, + private cacheService: CacheService, private utilityService: UtilityService, private globalEventService: GlobalEventService, private fanoutTimelineService: FanoutTimelineService, @@ -111,8 +113,7 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise { - if (note.visibility === 'specified') return false; - if (note.visibility === 'followers') return false; + if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false; if (antenna.excludeBots && noteUser.isBot) return false; @@ -120,6 +121,18 @@ export class AntennaService implements OnApplicationShutdown { if (!antenna.withReplies && note.replyId != null) return false; + if (note.visibility === 'specified') { + if (note.userId !== antenna.userId) { + if (note.visibleUserIds == null) return false; + if (!note.visibleUserIds.includes(antenna.userId)) return false; + } + } + + if (note.visibility === 'followers') { + const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId); + if (!isFollowing && antenna.userId !== note.userId) return false; + } + if (antenna.src === 'home') { // TODO } else if (antenna.src === 'list') { diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts index df1c384b54..b0e8cfb61c 100644 --- a/packages/backend/src/core/ChatService.ts +++ b/packages/backend/src/core/ChatService.ts @@ -94,12 +94,46 @@ export class ChatService { ) { } + @bindThis + public async getChatAvailability(userId: MiUser['id']): Promise<{ read: boolean; write: boolean; }> { + const policies = await this.roleService.getUserPolicies(userId); + + switch (policies.chatAvailability) { + case 'available': + return { + read: true, + write: true, + }; + case 'readonly': + return { + read: true, + write: false, + }; + case 'unavailable': + return { + read: false, + write: false, + }; + default: + throw new Error('invalid chat availability (unreachable)'); + } + } + + /** getChatAvailabilityの糖衣。主にAPI呼び出し時に走らせて、権限的に問題ない場合はそのまま続行する */ + @bindThis + public async checkChatAvailability(userId: MiUser['id'], permission: 'read' | 'write') { + const policy = await this.getChatAvailability(userId); + if (policy[permission] === false) { + throw new Error('ROLE_PERMISSION_DENIED'); + } + } + @bindThis public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { text?: string | null; file?: MiDriveFile | null; uri?: string | null; - }): Promise> { + }): Promise> { if (fromUser.id === toUser.id) { throw new Error('yourself'); } @@ -140,7 +174,7 @@ export class ChatService { } } - if (!(await this.roleService.getUserPolicies(toUser.id)).canChat) { + if (!(await this.getChatAvailability(toUser.id)).write) { throw new Error('recipient is cannot chat (policy)'); } @@ -210,10 +244,16 @@ export class ChatService { text?: string | null; file?: MiDriveFile | null; uri?: string | null; - }): Promise> { - const memberships = await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id }); + }): Promise> { + const memberships = (await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id })).map(m => ({ + userId: m.userId, + isMuted: m.isMuted, + })).concat({ // ownerはmembershipレコードを作らないため + userId: toRoom.ownerId, + isMuted: false, + }); - if (toRoom.ownerId !== fromUser.id && !memberships.some(member => member.userId === fromUser.id)) { + if (!memberships.some(member => member.userId === fromUser.id)) { throw new Error('you are not a member of the room'); } diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index 10df6ef266..223a8de678 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -7,13 +7,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { ulid } from 'ulid'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { genAid, isSafeAidT, parseAid } from '@/misc/id/aid.js'; -import { genAidx, isSafeAidxT, parseAidx } from '@/misc/id/aidx.js'; -import { genMeid, isSafeMeidT, parseMeid } from '@/misc/id/meid.js'; -import { genMeidg, isSafeMeidgT, parseMeidg } from '@/misc/id/meidg.js'; -import { genObjectId, isSafeObjectIdT, parseObjectId } from '@/misc/id/object-id.js'; +import { genAid, isSafeAidT, parseAid, parseAidFull } from '@/misc/id/aid.js'; +import { genAidx, isSafeAidxT, parseAidx, parseAidxFull } from '@/misc/id/aidx.js'; +import { genMeid, isSafeMeidT, parseMeid, parseMeidFull } from '@/misc/id/meid.js'; +import { genMeidg, isSafeMeidgT, parseMeidg, parseMeidgFull } from '@/misc/id/meidg.js'; +import { genObjectId, isSafeObjectIdT, parseObjectId, parseObjectIdFull } from '@/misc/id/object-id.js'; import { bindThis } from '@/decorators.js'; -import { parseUlid } from '@/misc/id/ulid.js'; +import { parseUlid, parseUlidFull } from '@/misc/id/ulid.js'; @Injectable() export class IdService { @@ -70,4 +70,18 @@ export class IdService { default: throw new Error('unrecognized id generation method'); } } + + // Note: additional is at most 64 bits + @bindThis + public parseFull(id: string): { date: number; additional: bigint; } { + switch (this.method) { + case 'aid': return parseAidFull(id); + case 'aidx': return parseAidxFull(id); + case 'objectid': return parseObjectIdFull(id); + case 'meid': return parseMeidFull(id); + case 'meidg': return parseMeidgFull(id); + case 'ulid': return parseUlidFull(id); + default: throw new Error('unrecognized id generation method'); + } + } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 8f416f398c..1ddb2b173d 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -532,7 +532,10 @@ export class NoteCreateService implements OnApplicationShutdown { this.pushToTl(note, user); - this.antennaService.addNoteToAntennas(note, user); + this.antennaService.addNoteToAntennas({ + ...note, + channel: data.channel ?? null, + }, user); if (data.reply) { this.saveReply(data.reply, note); diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 68ad92f396..eeade4569b 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; +import { ReplyError } from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; @@ -19,7 +20,7 @@ import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { UserListService } from '@/core/UserListService.js'; -import type { FilterUnionByProperty } from '@/types.js'; +import { FilterUnionByProperty, groupedNotificationTypes, obsoleteNotificationTypes } from '@/types.js'; import { trackPromise } from '@/misc/promise-tracker.js'; @Injectable() @@ -145,21 +146,36 @@ export class NotificationService implements OnApplicationShutdown { } } - const notification = { - id: this.idService.gen(), - createdAt: new Date(), - type: type, - ...(notifierId ? { - notifierId, - } : {}), - ...data, - } as any as FilterUnionByProperty; + const createdAt = new Date(); + let notification: FilterUnionByProperty; + let redisId: string; - const redisIdPromise = this.redisClient.xadd( - `notificationTimeline:${notifieeId}`, - 'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(), - '*', - 'data', JSON.stringify(notification)); + do { + notification = { + id: this.idService.gen(), + createdAt, + type: type, + ...(notifierId ? { + notifierId, + } : {}), + ...data, + } as unknown as FilterUnionByProperty; + + try { + redisId = (await this.redisClient.xadd( + `notificationTimeline:${notifieeId}`, + 'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(), + this.toXListId(notification.id), + 'data', JSON.stringify(notification)))!; + } catch (e) { + // The ID specified in XADD is equal or smaller than the target stream top item で失敗することがあるのでリトライ + if (e instanceof ReplyError) continue; + throw e; + } + + break; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } while (true); const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); @@ -173,7 +189,7 @@ export class NotificationService implements OnApplicationShutdown { const interval = notification.type === 'test' ? 0 : 2000; setTimeout(interval, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); - if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return; + if (latestReadNotificationId && (latestReadNotificationId >= redisId)) return; this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); @@ -228,6 +244,79 @@ export class NotificationService implements OnApplicationShutdown { this.#shutdownController.abort(); } + private toXListId(id: string): string { + const { date, additional } = this.idService.parseFull(id); + return date.toString() + '-' + additional.toString(); + } + + @bindThis + public async getNotifications( + userId: MiUser['id'], + { + sinceId, + untilId, + limit = 20, + includeTypes, + excludeTypes, + }: { + sinceId?: string, + untilId?: string, + limit?: number, + // any extra types are allowed, those are no-op + includeTypes?: (MiNotification['type'] | string)[], + excludeTypes?: (MiNotification['type'] | string)[], + }, + ): Promise { + let sinceTime = sinceId ? this.toXListId(sinceId) : null; + let untilTime = untilId ? this.toXListId(untilId) : null; + + let notifications: MiNotification[]; + for (;;) { + let notificationsRes: [id: string, fields: string[]][]; + + // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 + if (sinceTime && !untilTime) { + notificationsRes = await this.redisClient.xrange( + `notificationTimeline:${userId}`, + '(' + sinceTime, + '+', + 'COUNT', limit); + } else { + notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + untilTime ? '(' + untilTime : '+', + sinceTime ? '(' + sinceTime : '-', + 'COUNT', limit); + } + + if (notificationsRes.length === 0) { + return []; + } + + notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[]; + + if (includeTypes && includeTypes.length > 0) { + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); + } else if (excludeTypes && excludeTypes.length > 0) { + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); + } + + if (notifications.length !== 0) { + // 通知が1件以上ある場合は返す + break; + } + + // フィルタしたことで通知が0件になった場合、次のページを取得する + if (sinceId && !untilId) { + sinceTime = notificationsRes[notificationsRes.length - 1][0]; + } else { + untilTime = notificationsRes[notificationsRes.length - 1][0]; + } + } + + return notifications; + } + @bindThis public onApplicationShutdown(signal?: string | undefined): void { this.dispose(); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 86f8a5caa1..601959cc96 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -63,7 +63,7 @@ export type RolePolicies = { canImportFollowing: boolean; canImportMuting: boolean; canImportUserLists: boolean; - canChat: boolean; + chatAvailability: 'available' | 'readonly' | 'unavailable'; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -98,7 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canImportFollowing: true, canImportMuting: true, canImportUserLists: true, - canChat: true, + chatAvailability: 'available', }; @Injectable() @@ -370,6 +370,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value)); } + function aggregateChatAvailability(vs: RolePolicies['chatAvailability'][]) { + if (vs.some(v => v === 'available')) return 'available'; + if (vs.some(v => v === 'readonly')) return 'readonly'; + return 'unavailable'; + } + return { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), @@ -402,7 +408,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), - canChat: calc('canChat', vs => vs.some(v => v === true)), + chatAvailability: calc('chatAvailability', aggregateChatAvailability), }; } @@ -630,6 +636,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { isModerator: values.isModerator, isExplorable: values.isExplorable, asBadge: values.asBadge, + preserveAssignmentOnMoveAccount: values.preserveAssignmentOnMoveAccount, canEditMembersByModerator: values.canEditMembersByModerator, displayOrder: values.displayOrder, policies: values.policies, diff --git a/packages/backend/src/core/SystemAccountService.ts b/packages/backend/src/core/SystemAccountService.ts index 1e050c3054..53c047dd74 100644 --- a/packages/backend/src/core/SystemAccountService.ts +++ b/packages/backend/src/core/SystemAccountService.ts @@ -5,11 +5,14 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; +import type { OnApplicationShutdown } from '@nestjs/common'; import { DataSource, IsNull } from 'typeorm'; +import * as Redis from 'ioredis'; import bcrypt from 'bcryptjs'; import { MiLocalUser, MiUser } from '@/models/User.js'; import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js'; import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { MemoryKVCache } from '@/misc/cache.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -20,10 +23,13 @@ import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const; @Injectable() -export class SystemAccountService { +export class SystemAccountService implements OnApplicationShutdown { private cache: MemoryKVCache; constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.db) private db: DataSource, @@ -42,6 +48,31 @@ export class SystemAccountService { private idService: IdService, ) { this.cache = new MemoryKVCache(1000 * 60 * 10); // 10m + + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'metaUpdated': { + if (body.before != null && body.before.name !== body.after.name) { + for (const account of SYSTEM_ACCOUNT_TYPES) { + await this.updateCorrespondingUserProfile(account, { + name: body.after.name, + }); + } + } + break; + } + default: + break; + } + } } @bindThis @@ -145,7 +176,7 @@ export class SystemAccountService { @bindThis public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: { - name?: string; + name?: string | null; description?: MiUserProfile['description']; }): Promise { const user = await this.fetch(type); @@ -169,4 +200,15 @@ export class SystemAccountService { return updated; } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + this.cache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index e770028af3..1f8c8ae3e8 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -41,6 +41,7 @@ export class AntennaEntityService { excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, + excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel, isActive: antenna.isActive, hasUnreadNote: false, // TODO notify: false, // 後方互換性のため diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts index 099a9e3ad2..da112d5444 100644 --- a/packages/backend/src/core/entities/ChatEntityService.ts +++ b/packages/backend/src/core/entities/ChatEntityService.ts @@ -128,7 +128,7 @@ export class ChatEntityService { packedFiles: Map | null>; }; }, - ): Promise> { + ): Promise> { const packedFiles = options?._hint_?.packedFiles; const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); @@ -147,7 +147,7 @@ export class ChatEntityService { createdAt: this.idService.parse(message.id).date.toISOString(), text: message.text, fromUserId: message.fromUserId, - toUserId: message.toUserId, + toUserId: message.toUserId!, fileId: message.fileId, file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, reactions, @@ -177,7 +177,7 @@ export class ChatEntityService { packedUsers: Map>; }; }, - ): Promise> { + ): Promise> { const packedFiles = options?._hint_?.packedFiles; const packedUsers = options?._hint_?.packedUsers; @@ -199,7 +199,7 @@ export class ChatEntityService { text: message.text, fromUserId: message.fromUserId, fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId), - toRoomId: message.toRoomId, + toRoomId: message.toRoomId!, fileId: message.fileId, file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, reactions, diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 08717bd066..02783dc450 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -127,6 +127,7 @@ export class MetaEntityService { policies: { ...DEFAULT_POLICIES, ...instance.policies }, + sentryForFrontend: this.config.sentryForFrontend ?? null, mediaProxy: this.config.mediaProxy, enableUrlPreview: instance.urlPreviewEnabled, noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 2a7dc37bce..3fa38c9521 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -13,6 +13,7 @@ import type { MiRole } from '@/models/Role.js'; import { bindThis } from '@/decorators.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; +import { Packed } from '@/misc/json-schema.js'; @Injectable() export class RoleEntityService { @@ -31,7 +32,7 @@ export class RoleEntityService { public async pack( src: MiRole['id'] | MiRole, me?: { id: MiUser['id'] } | null | undefined, - ) { + ): Promise> { const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') @@ -67,6 +68,7 @@ export class RoleEntityService { isModerator: role.isModerator, isExplorable: role.isExplorable, asBadge: role.asBadge, + preserveAssignmentOnMoveAccount: role.preserveAssignmentOnMoveAccount, canEditMembersByModerator: role.canEditMembersByModerator, displayOrder: role.displayOrder, policies: policies, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index ad8052711c..e252ff509e 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -557,7 +557,7 @@ export class UserEntityService implements OnModuleInit { followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, chatScope: user.chatScope, - canChat: this.roleService.getUserPolicies(user.id).then(r => r.canChat), + canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'), roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, diff --git a/packages/backend/src/misc/bigint.ts b/packages/backend/src/misc/bigint.ts new file mode 100644 index 0000000000..efa1527ec9 --- /dev/null +++ b/packages/backend/src/misc/bigint.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +function parseBigIntChunked(str: string, base: number, chunkSize: number, powerOfChunkSize: bigint): bigint { + const chunks = []; + while (str.length > 0) { + chunks.unshift(str.slice(-chunkSize)); + str = str.slice(0, -chunkSize); + } + let result = 0n; + for (const chunk of chunks) { + result *= powerOfChunkSize; + const int = parseInt(chunk, base); + if (Number.isNaN(int)) { + throw new Error('Invalid base36 string'); + } + result += BigInt(int); + } + return result; +} + +export function parseBigInt36(str: string): bigint { + // log_36(Number.MAX_SAFE_INTEGER) => 10.251599391715352 + // so we process 10 chars at once + return parseBigIntChunked(str, 36, 10, 36n ** 10n); +} + +export function parseBigInt16(str: string): bigint { + // log_16(Number.MAX_SAFE_INTEGER) => 13.25 + // so we process 13 chars at once + return parseBigIntChunked(str, 16, 13, 16n ** 13n); +} + +export function parseBigInt32(str: string): bigint { + // log_32(Number.MAX_SAFE_INTEGER) => 10.6 + // so we process 10 chars at once + return parseBigIntChunked(str, 32, 10, 32n ** 10n); +} diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 60ba788e44..c0e8478db5 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -7,6 +7,7 @@ // 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ2の[ノイズ文字列] import * as crypto from 'node:crypto'; +import { parseBigInt36 } from '@/misc/bigint.js'; export const aidRegExp = /^[0-9a-z]{10}$/; @@ -35,6 +36,12 @@ export function parseAid(id: string): { date: Date; } { return { date: new Date(time) }; } +export function parseAidFull(id: string): { date: number; additional: bigint; } { + const date = parseInt(id.slice(0, 8), 36) + TIME2000; + const additional = parseBigInt36(id.slice(8, 10)); + return { date, additional }; +} + export function isSafeAidT(t: number): boolean { return t > TIME2000; } diff --git a/packages/backend/src/misc/id/aidx.ts b/packages/backend/src/misc/id/aidx.ts index 1b087e70af..006673a6d0 100644 --- a/packages/backend/src/misc/id/aidx.ts +++ b/packages/backend/src/misc/id/aidx.ts @@ -9,6 +9,7 @@ // https://misskey.m544.net/notes/71899acdcc9859ec5708ac24 import { customAlphabet } from 'nanoid'; +import { parseBigInt36 } from '@/misc/bigint.js'; export const aidxRegExp = /^[0-9a-z]{16}$/; @@ -16,6 +17,7 @@ const TIME2000 = 946684800000; const TIME_LENGTH = 8; const NODE_LENGTH = 4; const NOISE_LENGTH = 4; +const AIDX_LENGTH = TIME_LENGTH + NODE_LENGTH + NOISE_LENGTH; const nodeId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', NODE_LENGTH)(); let counter = 0; @@ -42,6 +44,12 @@ export function parseAidx(id: string): { date: Date; } { return { date: new Date(time) }; } +export function parseAidxFull(id: string): { date: number; additional: bigint; } { + const date = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000; + const additional = parseBigInt36(id.slice(TIME_LENGTH, AIDX_LENGTH)); + return { date, additional }; +} + export function isSafeAidxT(t: number): boolean { return t > TIME2000; } diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts index dfab48a369..563e07ed8f 100644 --- a/packages/backend/src/misc/id/meid.ts +++ b/packages/backend/src/misc/id/meid.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { parseBigInt16 } from '@/misc/bigint.js'; + const CHARS = '0123456789abcdef'; // same as object-id @@ -39,6 +41,13 @@ export function parseMeid(id: string): { date: Date; } { }; } +export function parseMeidFull(id: string): { date: number; additional: bigint; } { + return { + date: parseInt(id.slice(0, 12), 16) - 0x800000000000, + additional: parseBigInt16(id.slice(12, 24)), + }; +} + export function isSafeMeidT(t: number): boolean { return t > 0; } diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts index b9c0cc3dda..b825807114 100644 --- a/packages/backend/src/misc/id/meidg.ts +++ b/packages/backend/src/misc/id/meidg.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { parseBigInt16 } from '@/misc/bigint.js'; + const CHARS = '0123456789abcdef'; // 4bit Fixed hex value 'g' @@ -39,6 +41,13 @@ export function parseMeidg(id: string): { date: Date; } { }; } +export function parseMeidgFull(id: string): { date: number; additional: bigint; } { + return { + date: parseInt(id.slice(1, 12), 16), + additional: parseBigInt16(id.slice(12, 24)), + }; +} + export function isSafeMeidgT(t: number): boolean { return t > 0; } diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts index 243f92bbac..68409c7a61 100644 --- a/packages/backend/src/misc/id/object-id.ts +++ b/packages/backend/src/misc/id/object-id.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { parseBigInt16 } from '@/misc/bigint.js'; + const CHARS = '0123456789abcdef'; // same as meid @@ -39,6 +41,13 @@ export function parseObjectId(id: string): { date: Date; } { }; } +export function parseObjectIdFull(id: string): { date: number; additional: bigint; } { + return { + date: parseInt(id.slice(0, 8), 16) * 1000, + additional: parseBigInt16(id.slice(8, 24)), + }; +} + export function isSafeObjectIdT(t: number): boolean { return t > 0; } diff --git a/packages/backend/src/misc/id/ulid.ts b/packages/backend/src/misc/id/ulid.ts index fc3654d6d2..8b81702d19 100644 --- a/packages/backend/src/misc/id/ulid.ts +++ b/packages/backend/src/misc/id/ulid.ts @@ -5,15 +5,27 @@ // Crockford's Base32 // https://github.com/ulid/spec#encoding +import { parseBigInt32 } from '@/misc/bigint.js'; + const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/; -export function parseUlid(id: string): { date: Date; } { - const timestamp = id.slice(0, 10); +function parseBase32(timestamp: string) { let time = 0; - for (let i = 0; i < 10; i++) { + for (let i = 0; i < timestamp.length; i++) { time = time * 32 + CHARS.indexOf(timestamp[i]); } - return { date: new Date(time) }; + return time; +} + +export function parseUlid(id: string): { date: Date; } { + return { date: new Date(parseBase32(id.slice(0, 10))) }; +} + +export function parseUlidFull(id: string): { date: number; additional: bigint; } { + return { + date: parseBase32(id.slice(0, 10)), + additional: parseBigInt32(id.slice(10, 26)), + }; } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index bc9308ca9b..27aa3d89de 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -63,7 +63,7 @@ import { } from '@/models/json-schema/meta.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; -import { packedChatMessageSchema, packedChatMessageLiteSchema } from '@/models/json-schema/chat-message.js'; +import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessageLiteForRoomSchema, packedChatMessageLiteFor1on1Schema } from '@/models/json-schema/chat-message.js'; import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js'; import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js'; import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js'; @@ -126,6 +126,8 @@ export const refs = { AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, ChatMessage: packedChatMessageSchema, ChatMessageLite: packedChatMessageLiteSchema, + ChatMessageLiteFor1on1: packedChatMessageLiteFor1on1Schema, + ChatMessageLiteForRoom: packedChatMessageLiteForRoomSchema, ChatRoom: packedChatRoomSchema, ChatRoomInvitation: packedChatRoomInvitationSchema, ChatRoomMembership: packedChatRoomMembershipSchema, diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index 33e6f48189..17ec0c0f79 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -100,4 +100,9 @@ export class MiAntenna { default: false, }) public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public excludeNotesInSensitiveChannel: boolean; } diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts index a173971b2c..4c7da252bd 100644 --- a/packages/backend/src/models/Role.ts +++ b/packages/backend/src/models/Role.ts @@ -248,6 +248,11 @@ export class MiRole { }) public isExplorable: boolean; + @Column('boolean', { + default: false, + }) + public preserveAssignmentOnMoveAccount: boolean; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index e852b302f3..e1ea2a2604 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -3,29 +3,48 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; +import { + FindOneOptions, + InsertQueryBuilder, + ObjectLiteral, + QueryRunner, + Repository, + SelectQueryBuilder, +} from 'typeorm'; +import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; -import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; -import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import { + RawSqlResultsToEntityTransformer, +} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; +import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; -import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; +import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiBlocking } from '@/models/Blocking.js'; -import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; +import { MiChannel } from '@/models/Channel.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; +import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +import { MiChatApproval } from '@/models/ChatApproval.js'; +import { MiChatMessage } from '@/models/ChatMessage.js'; +import { MiChatRoom } from '@/models/ChatRoom.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; +import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; import { MiClip } from '@/models/Clip.js'; -import { MiClipNote } from '@/models/ClipNote.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; +import { MiClipNote } from '@/models/ClipNote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiFlash } from '@/models/Flash.js'; +import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -35,7 +54,6 @@ import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; -import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; @@ -50,42 +68,38 @@ import { MiPromoRead } from '@/models/PromoRead.js'; import { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; import { MiRegistryItem } from '@/models/RegistryItem.js'; import { MiRelay } from '@/models/Relay.js'; +import { MiRenoteMuting } from '@/models/RenoteMuting.js'; +import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; +import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiRole } from '@/models/Role.js'; +import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiSignin } from '@/models/Signin.js'; import { MiSwSubscription } from '@/models/SwSubscription.js'; import { MiSystemAccount } from '@/models/SystemAccount.js'; +import { MiSystemWebhook } from '@/models/SystemWebhook.js'; import { MiUsedUsername } from '@/models/UsedUsername.js'; import { MiUser } from '@/models/User.js'; import { MiUserIp } from '@/models/UserIp.js'; import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserList } from '@/models/UserList.js'; +import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiUserListMembership } from '@/models/UserListMembership.js'; +import { MiUserMemo } from '@/models/UserMemo.js'; import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserPending } from '@/models/UserPending.js'; import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; -import { MiUserMemo } from '@/models/UserMemo.js'; import { MiWebhook } from '@/models/Webhook.js'; -import { MiSystemWebhook } from '@/models/SystemWebhook.js'; -import { MiChannel } from '@/models/Channel.js'; -import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; -import { MiRole } from '@/models/Role.js'; -import { MiRoleAssignment } from '@/models/RoleAssignment.js'; -import { MiFlash } from '@/models/Flash.js'; -import { MiFlashLike } from '@/models/FlashLike.js'; -import { MiUserListFavorite } from '@/models/UserListFavorite.js'; -import { MiChatMessage } from '@/models/ChatMessage.js'; -import { MiChatRoom } from '@/models/ChatRoom.js'; -import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; -import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; -import { MiChatApproval } from '@/models/ChatApproval.js'; -import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; -import { MiReversiGame } from '@/models/ReversiGame.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; export interface MiRepository { createTableColumnNames(this: Repository & MiRepository): string[]; + insertOne(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>): Promise; + + insertOneImpl(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>, queryRunner?: QueryRunner): Promise; + selectAliasColumnNames(this: Repository & MiRepository, queryBuilder: InsertQueryBuilder, builder: SelectQueryBuilder): void; } @@ -94,6 +108,21 @@ export const miRepository = { return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName); }, async insertOne(entity, findOptions?) { + const opt = this.manager.connection.options as PostgresConnectionOptions; + if (opt.replication) { + const queryRunner = this.manager.connection.createQueryRunner('master'); + try { + return this.insertOneImpl(entity, findOptions, queryRunner); + } finally { + await queryRunner.release(); + } + } else { + return this.insertOneImpl(entity, findOptions); + } + }, + async insertOneImpl(entity, findOptions?, queryRunner?) { + // ---- insert + returningの結果を共通テーブル式(CTE)に保持するクエリを生成 ---- + const queryBuilder = this.createQueryBuilder().insert().values(entity); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const mainAlias = queryBuilder.expressionMap.mainAlias!; @@ -101,7 +130,9 @@ export const miRepository = { mainAlias.name = 't'; const columnNames = this.createTableColumnNames(); queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2)); - const builder = this.createQueryBuilder().addCommonTableExpression(queryBuilder, 'cte', { columnNames }); + + // ---- 共通テーブル式(CTE)から結果を取得 ---- + const builder = this.createQueryBuilder(undefined, queryRunner).addCommonTableExpression(queryBuilder, 'cte', { columnNames }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion builder.expressionMap.mainAlias!.tablePath = 'cte'; this.selectAliasColumnNames(queryBuilder, builder); @@ -204,7 +235,9 @@ export { }; export type AbuseUserReportsRepository = Repository & MiRepository; -export type AbuseReportNotificationRecipientRepository = Repository & MiRepository; +export type AbuseReportNotificationRecipientRepository = + Repository + & MiRepository; export type AccessTokensRepository = Repository & MiRepository; export type AdsRepository = Repository & MiRepository; export type AnnouncementsRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts index b5b9a5b42c..eca7563066 100644 --- a/packages/backend/src/models/json-schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -100,5 +100,10 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, + excludeNotesInSensitiveChannel: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/chat-message.ts b/packages/backend/src/models/json-schema/chat-message.ts index 44b7298702..3b5e85ab69 100644 --- a/packages/backend/src/models/json-schema/chat-message.ts +++ b/packages/backend/src/models/json-schema/chat-message.ts @@ -72,7 +72,7 @@ export const packedChatMessageSchema = { }, user: { type: 'object', - optional: true, nullable: true, + optional: false, nullable: false, ref: 'UserLite', }, }, @@ -144,3 +144,113 @@ export const packedChatMessageLiteSchema = { }, }, } as const; + +export const packedChatMessageLiteFor1on1Schema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + toUserId: { + type: 'string', + optional: false, nullable: false, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, + }, +} as const; + +export const packedChatMessageLiteForRoomSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + toRoomId: { + type: 'string', + optional: false, nullable: false, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 1e25c355ca..2cd7620af0 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -211,6 +211,38 @@ export const packedMetaLiteSchema = { type: 'boolean', optional: false, nullable: false, }, + sentryForFrontend: { + type: 'object', + optional: false, nullable: true, + properties: { + options: { + type: 'object', + optional: false, nullable: false, + properties: { + dsn: { + type: 'string', + optional: false, nullable: false, + }, + }, + additionalProperties: true, + }, + vueIntegration: { + type: 'object', + optional: true, nullable: true, + additionalProperties: true, + }, + browserTracingIntegration: { + type: 'object', + optional: true, nullable: true, + additionalProperties: true, + }, + replayIntegration: { + type: 'object', + optional: true, nullable: true, + additionalProperties: true, + }, + }, + }, mediaProxy: { type: 'string', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 1685a806c9..1cfcb830e0 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -292,9 +292,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, - canChat: { - type: 'boolean', + chatAvailability: { + type: 'string', optional: false, nullable: false, + enum: ['available', 'readonly', 'unavailable'], }, }, } as const; @@ -389,6 +390,11 @@ export const packedRoleSchema = { optional: false, nullable: false, example: false, }, + preserveAssignmentOnMoveAccount: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, canEditMembersByModerator: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 0f69a0958d..be225ef1ed 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -5,7 +5,7 @@ // https://github.com/typeorm/typeorm/issues/2400 import pg from 'pg'; -import { DataSource, EntityManager, Logger } from 'typeorm'; +import { DataSource, EntityManager, Logger, type QueryRunner } from 'typeorm'; import * as highlight from 'cli-highlight'; import { entities as charts } from '@/core/chart/entities.js'; import { Config } from '@/config.js'; @@ -96,6 +96,7 @@ const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); export type LoggerProps = { disableQueryTruncation?: boolean; enableQueryParamLogging?: boolean; + printReplicationMode?: boolean, }; function highlightSql(sql: string) { @@ -121,8 +122,10 @@ class MyCustomLogger implements Logger { } @bindThis - private transformQueryLog(sql: string) { - let modded = sql; + private transformQueryLog(sql: string, opts?: { + prefix?: string; + }) { + let modded = opts?.prefix ? opts.prefix + sql : sql; if (!this.props.disableQueryTruncation) { modded = truncateSql(modded); } @@ -140,18 +143,27 @@ class MyCustomLogger implements Logger { } @bindThis - public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); } @bindThis - public logQueryError(error: string, query: string, parameters?: any[]) { - sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + sqlLogger.error(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); } @bindThis - public logQuerySlow(time: number, query: string, parameters?: any[]) { - sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + sqlLogger.warn(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); } @bindThis @@ -325,6 +337,7 @@ export function createPostgresDataSource(config: Config) { ? new MyCustomLogger({ disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, + printReplicationMode: !!config.dbReplications, }) : undefined, maxQueryExecutionTime: 300, diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 48c80e5e61..72d57a9b1b 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -735,7 +735,7 @@ export class ActivityPubServerService { const acct = Acct.parse(request.params.acct); const user = await this.usersRepository.findOneBy({ - usernameLower: acct.username, + usernameLower: acct.username.toLowerCase(), host: acct.host ?? IsNull(), isSuspended: false, }); diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index d106be5bc8..ebfd1a421d 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -138,7 +138,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => { const fromAcct = (acct: Acct.Acct): FindOptionsWhere | number => !acct.host || acct.host === this.config.host.toLowerCase() ? { - usernameLower: acct.username, + usernameLower: acct.username.toLowerCase(), host: IsNull(), isSuspended: false, } : 422; diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts index e0c02f7a5d..f92f7ebaeb 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -36,6 +36,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility asBadge: { type: 'boolean' }, + preserveAssignmentOnMoveAccount: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, policies: { diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index 465ad7aaaf..175adcb63f 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -41,6 +41,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean' }, asBadge: { type: 'boolean' }, + preserveAssignmentOnMoveAccount: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, policies: { @@ -78,6 +79,7 @@ export default class extends Endpoint { // eslint- isAdministrator: ps.isAdministrator, isExplorable: ps.isExplorable, asBadge: ps.asBadge, + preserveAssignmentOnMoveAccount: ps.preserveAssignmentOnMoveAccount, canEditMembersByModerator: ps.canEditMembersByModerator, displayOrder: ps.displayOrder, policies: ps.policies, diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index e0c8ddcc84..c075608491 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -73,6 +73,7 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, + excludeNotesInSensitiveChannel: { type: 'boolean' }, }, required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const; @@ -133,6 +134,7 @@ export default class extends Endpoint { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, + excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, }); this.globalEventService.publishInternalEvent('antennaCreated', antenna); diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 4b8543c2d1..a44eb6720b 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -108,6 +108,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 + // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 10f26b1912..53fc4db1b7 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -72,6 +72,7 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, + excludeNotesInSensitiveChannel: { type: 'boolean' }, }, required: ['antennaId'], } as const; @@ -129,6 +130,7 @@ export default class extends Endpoint { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, + excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, isActive: true, lastUsedAt: new Date(), }); diff --git a/packages/backend/src/server/api/endpoints/chat/history.ts b/packages/backend/src/server/api/endpoints/chat/history.ts index 7553a751e0..fdd9055106 100644 --- a/packages/backend/src/server/api/endpoints/chat/history.ts +++ b/packages/backend/src/server/api/endpoints/chat/history.ts @@ -46,6 +46,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit); const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts index 1f334d5750..ad2b82e219 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts @@ -16,7 +16,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', prohibitMoved: true, @@ -30,7 +29,7 @@ export const meta = { res: { type: 'object', optional: false, nullable: false, - ref: 'ChatMessageLite', + ref: 'ChatMessageLiteForRoom', }, errors: { @@ -74,6 +73,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.findRoomById(ps.toRoomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts index 6b77a026fb..fa34a7d558 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts @@ -16,7 +16,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', prohibitMoved: true, @@ -30,7 +29,7 @@ export const meta = { res: { type: 'object', optional: false, nullable: false, - ref: 'ChatMessageLite', + ref: 'ChatMessageLiteFor1on1', }, errors: { @@ -86,6 +85,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + let file = null; if (ps.fileId != null) { file = await this.driveFilesRepository.findOneBy({ diff --git a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts index 959599ddcf..63b75fb6a7 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts @@ -42,6 +42,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const message = await this.chatService.findMyMessageById(me.id, ps.messageId); if (message == null) { throw new ApiError(meta.errors.noSuchMessage); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/react.ts b/packages/backend/src/server/api/endpoints/chat/messages/react.ts index 561e36ed19..5f61e7e992 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/react.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/react.ts @@ -43,6 +43,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.react(ps.messageId, me.id, ps.reaction); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts index 7aef35db04..c0e344b889 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts @@ -23,7 +23,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - ref: 'ChatMessageLite', + ref: 'ChatMessageLiteForRoom', }, }, @@ -54,6 +54,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/search.ts b/packages/backend/src/server/api/endpoints/chat/messages/search.ts index 4c989e5ca9..682597f76d 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/search.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/search.ts @@ -54,6 +54,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + if (ps.roomId != null) { const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { diff --git a/packages/backend/src/server/api/endpoints/chat/messages/show.ts b/packages/backend/src/server/api/endpoints/chat/messages/show.ts index 371f7a7071..9a2bbb8742 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/show.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/show.ts @@ -50,6 +50,8 @@ export default class extends Endpoint { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const message = await this.chatService.findMessageById(ps.messageId); if (message == null) { throw new ApiError(meta.errors.noSuchMessage); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts index 4eb25259fb..6784bb6ecf 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts @@ -43,6 +43,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.unreact(ps.messageId, me.id, ps.reaction); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts index 9d308d79b0..a057e2e088 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts @@ -24,7 +24,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - ref: 'ChatMessageLite', + ref: 'ChatMessageLiteFor1on1', }, }, @@ -56,6 +56,8 @@ export default class extends Endpoint { // eslint- private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const other = await this.getterService.getUser(ps.userId).catch(err => { if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); throw err; diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts index fa4cc8ceb4..68a53f0886 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts @@ -15,7 +15,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', prohibitMoved: true, @@ -52,6 +51,8 @@ export default class extends Endpoint { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.createRoom(me, { name: ps.name, description: ps.description ?? '', diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts index 1d77a06dd8..82a8e1f30d 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts @@ -42,6 +42,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts index 5da4a1a772..b1f049f2b9 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts @@ -15,7 +15,6 @@ export const meta = { tags: ['chat'], requireCredential: true, - requiredRolePolicy: 'canChat', prohibitMoved: true, @@ -57,6 +56,8 @@ export default class extends Endpoint { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts index 8c017f7d01..b8a228089b 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts @@ -42,6 +42,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.ignoreRoomInvitation(me.id, ps.roomId); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts index 07337480fc..8a02d1c704 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts @@ -47,6 +47,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); return this.chatEntityService.packRoomInvitations(invitations, me); }); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts index 12d496e94b..0702ba086c 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts @@ -55,6 +55,8 @@ export default class extends Endpoint { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts index dbd4d1ea5a..d561f9e03f 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts @@ -42,6 +42,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.joinToRoom(me.id, ps.roomId); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts index c4c6253236..ba9242c762 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts @@ -47,6 +47,8 @@ export default class extends Endpoint { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId); return this.chatEntityService.packRoomMemberships(memberships, me, { diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts index 724ad61f7e..a3ad0c2d6f 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts @@ -42,6 +42,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.leaveRoom(me.id, ps.roomId); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts index 407bfe74f1..f5ffa21d32 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts @@ -54,6 +54,8 @@ export default class extends Endpoint { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts index 5208b8a253..11cbe7b8b9 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts @@ -43,6 +43,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + await this.chatService.muteRoom(me.id, ps.roomId, ps.mute); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts index 6516120bca..accf7e1bee 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts @@ -47,6 +47,8 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); return this.chatEntityService.packRooms(rooms, me); }); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts index 547618ee7d..50da210d81 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts @@ -47,6 +47,8 @@ export default class extends Endpoint { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + const room = await this.chatService.findRoomById(ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts index 6f2a9c10b5..0cd62cb040 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts @@ -49,6 +49,8 @@ export default class extends Endpoint { // eslint- private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); if (room == null) { throw new ApiError(meta.errors.noSuchRoom); diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index 88d7f51c26..b9c41b057d 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -7,7 +7,12 @@ import { In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js'; +import { + obsoleteNotificationTypes, + groupedNotificationTypes, + FilterUnionByProperty, + notificationTypes, +} from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationService } from '@/core/NotificationService.js'; @@ -47,10 +52,10 @@ export const paramDef = { markAsRead: { type: 'boolean', default: true }, // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { - type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], } }, excludeTypes: { type: 'array', items: { - type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], } }, }, required: [], @@ -74,31 +79,20 @@ export default class extends Endpoint { // eslint- return []; } // excludeTypes に全指定されている場合はクエリしない - if (groupedNotificationTypes.every(type => ps.excludeTypes?.includes(type))) { + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { return []; } const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][]; - const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const notificationsRes = await this.redisClient.xrevrange( - `notificationTimeline:${me.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', - 'COUNT', limit); - - if (notificationsRes.length === 0) { - return []; - } - - let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[]; - - if (includeTypes && includeTypes.length > 0) { - notifications = notifications.filter(notification => includeTypes.includes(notification.type)); - } else if (excludeTypes && excludeTypes.length > 0) { - notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); - } + const notifications = await this.notificationService.getNotifications(me.id, { + sinceId: ps.sinceId, + untilId: ps.untilId, + limit: ps.limit, + includeTypes, + excludeTypes, + }); if (notifications.length === 0) { return []; diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index be8d0cfb34..f5a48b2f69 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -82,52 +82,13 @@ export default class extends Endpoint { // eslint- const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - let sinceTime = ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime().toString() : null; - let untilTime = ps.untilId ? this.idService.parse(ps.untilId).date.getTime().toString() : null; - - let notifications: MiNotification[]; - for (;;) { - let notificationsRes: [id: string, fields: string[]][]; - - // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 - if (sinceTime && !untilTime) { - notificationsRes = await this.redisClient.xrange( - `notificationTimeline:${me.id}`, - '(' + sinceTime, - '+', - 'COUNT', ps.limit); - } else { - notificationsRes = await this.redisClient.xrevrange( - `notificationTimeline:${me.id}`, - untilTime ? '(' + untilTime : '+', - sinceTime ? '(' + sinceTime : '-', - 'COUNT', ps.limit); - } - - if (notificationsRes.length === 0) { - return []; - } - - notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[]; - - if (includeTypes && includeTypes.length > 0) { - notifications = notifications.filter(notification => includeTypes.includes(notification.type)); - } else if (excludeTypes && excludeTypes.length > 0) { - notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); - } - - if (notifications.length !== 0) { - // 通知が1件以上ある場合は返す - break; - } - - // フィルタしたことで通知が0件になった場合、次のページを取得する - if (ps.sinceId && !ps.untilId) { - sinceTime = notificationsRes[notificationsRes.length - 1][0]; - } else { - untilTime = notificationsRes[notificationsRes.length - 1][0]; - } - } + const notifications = await this.notificationService.getNotifications(me.id, { + sinceId: ps.sinceId, + untilId: ps.untilId, + limit: ps.limit, + includeTypes, + excludeTypes, + }); // Mark all as read if (ps.markAsRead) { diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index ed56fe0d40..795980821b 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -50,6 +50,9 @@ class GlobalTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; + if (note.user.requireSigninToViewContents && this.user == null) return; + if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; + if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 491029f5de..2984e18774 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -53,6 +53,9 @@ class LocalTimelineChannel extends Channel { if (note.user.host !== null) return; if (note.visibility !== 'public') return; if (note.channelId != null) return; + if (note.user.requireSigninToViewContents && this.user == null) return; + if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; + if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; // 関係ない返信は除外 if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) { diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index a544db955a..4dbeacf925 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -6,7 +6,6 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { api, failedApiCall, @@ -19,6 +18,7 @@ import { userList, } from '../utils.js'; import type * as misskey from 'misskey-js'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { return selector(a).localeCompare(selector(b)); @@ -146,6 +146,7 @@ describe('アンテナ', () => { caseSensitive: false, createdAt: new Date(response.createdAt).toISOString(), excludeKeywords: [['']], + excludeNotesInSensitiveChannel: false, hasUnreadNote: false, isActive: true, keywords: [['keyword']], @@ -217,6 +218,8 @@ describe('アンテナ', () => { { parameters: () => ({ withReplies: true }) }, { parameters: () => ({ withFile: false }) }, { parameters: () => ({ withFile: true }) }, + { parameters: () => ({ excludeNotesInSensitiveChannel: false }) }, + { parameters: () => ({ excludeNotesInSensitiveChannel: true }) }, ]; test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { const response = await successfulApiCall({ @@ -232,12 +235,12 @@ describe('アンテナ', () => { await failedApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] }, - user: alice + user: alice, }, { status: 400, code: 'EMPTY_KEYWORD', - id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a' - }) + id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a', + }); }); //#endregion //#region 更新(antennas/update) @@ -271,12 +274,12 @@ describe('アンテナ', () => { await failedApiCall({ endpoint: 'antennas/update', parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] }, - user: alice + user: alice, }, { status: 400, code: 'EMPTY_KEYWORD', - id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4' - }) + id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4', + }); }); //#endregion @@ -372,14 +375,23 @@ describe('アンテナ', () => { ], }, { - // https://github.com/misskey-dev/misskey/issues/9025 - label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。', + label: 'フォロワー限定投稿とDM投稿を含む', parameters: () => ({}), posts: [ { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true }, { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) }, + { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }), included: true }, + ], + }, + { + label: 'フォロワー限定投稿とDM投稿を含まない', + parameters: () => ({}), + posts: [ + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'public' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'home' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'followers' }) }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [carol.id] }) }, ], }, { @@ -626,6 +638,42 @@ describe('アンテナ', () => { assert.deepStrictEqual(response, expected); }); + test('が取得できること(センシティブチャンネルのノートを除く)', async () => { + const keyword = 'キーワード'; + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[keyword]], excludeNotesInSensitiveChannel: true }, + user: alice, + }); + const nonSensitiveChannel = await successfulApiCall({ + endpoint: 'channels/create', + parameters: { name: 'test', isSensitive: false }, + user: alice, + }); + const sensitiveChannel = await successfulApiCall({ + endpoint: 'channels/create', + parameters: { name: 'test', isSensitive: true }, + user: alice, + }); + + const noteInLocal = await post(bob, { text: `test ${keyword}` }); + const noteInNonSensitiveChannel = await post(bob, { text: `test ${keyword}`, channelId: nonSensitiveChannel.id }); + await post(bob, { text: `test ${keyword}`, channelId: sensitiveChannel.id }); + + const response = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + // 最後に投稿したものが先頭に来る。 + const expected = [ + noteInNonSensitiveChannel, + noteInLocal, + ]; + assert.deepStrictEqual(response, expected); + }); + + test.skip('が取得でき、日付指定のPaginationに一貫性があること', async () => { }); test.each([ { label: 'ID指定', offsetBy: 'id' }, diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index 81da0fac31..a79655c9aa 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -44,7 +44,7 @@ describe('AnnouncementService', () => { return usersRepository.insert({ id: genAidx(Date.now()), username: un, - usernameLower: un, + usernameLower: un.toLowerCase(), ...data, }) .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts index bae2b88c60..0687ed8437 100644 --- a/packages/backend/test/unit/SigninWithPasskeyApiService.ts +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -89,8 +89,8 @@ describe('SigninWithPasskeyApiService', () => { app = await Test.createTestingModule({ imports: [GlobalModule, CoreModule], providers: [ - SigninWithPasskeyApiService, - { provide: RateLimiterService, useClass: FakeLimiter }, + SigninWithPasskeyApiService, + { provide: RateLimiterService, useClass: FakeLimiter }, { provide: SigninService, useClass: FakeSigninService }, ], }).useMocker((token) => { @@ -115,7 +115,7 @@ describe('SigninWithPasskeyApiService', () => { jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify); const dummyUser = { - id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null, + id: uid, username: uid, usernameLower: uid.toLowerCase(), uri: null, host: null, }; const dummyProfile = { userId: uid, diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index 6b7eedff55..ce3f931bb0 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -74,7 +74,7 @@ describe('UserEntityService', () => { ...userData, id: genAidx(Date.now()), username: un, - usernameLower: un, + usernameLower: un.toLowerCase(), }) .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 22b59c5a92..07da959921 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -25,13 +25,13 @@ "misskey-js": "workspace:*", "frontend-shared": "workspace:*", "punycode.js": "2.3.1", - "rollup": "4.36.0", - "sass": "1.86.0", - "shiki": "3.2.1", + "rollup": "4.39.0", + "sass": "1.86.3", + "shiki": "3.2.2", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.11", + "tsc-alias": "1.8.15", "tsconfig-paths": "4.2.0", - "typescript": "5.8.2", + "typescript": "5.8.3", "uuid": "11.1.0", "json5": "2.2.3", "vite": "6.2.4", @@ -40,15 +40,15 @@ "devDependencies": { "@misskey-dev/summaly": "5.2.0", "@testing-library/vue": "8.1.0", - "@types/estree": "1.0.6", + "@types/estree": "1.0.7", "@types/micromatch": "4.0.9", - "@types/node": "22.13.11", + "@types/node": "22.14.0", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", - "@types/ws": "8.18.0", - "@typescript-eslint/eslint-plugin": "8.27.0", - "@typescript-eslint/parser": "8.27.0", - "@vitest/coverage-v8": "3.0.9", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", + "@vitest/coverage-v8": "3.1.1", "@vue/runtime-core": "3.5.13", "acorn": "8.14.1", "cross-env": "7.0.3", @@ -64,7 +64,7 @@ "start-server-and-test": "2.0.11", "vite-plugin-turbosnap": "1.0.3", "vue-component-type-helpers": "2.2.8", - "vue-eslint-parser": "10.1.1", + "vue-eslint-parser": "10.1.3", "vue-tsc": "2.2.8" } } diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index fa60476b69..de65c3db97 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -108,7 +108,7 @@ export const ROLE_POLICIES = [ 'canImportFollowing', 'canImportMuting', 'canImportUserLists', - 'canChat', + 'chatAvailability', ] as const; export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error.jpg'; diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index e72baf48e2..b22bdc713b 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -21,14 +21,14 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "22.13.11", - "@typescript-eslint/eslint-plugin": "8.27.0", - "@typescript-eslint/parser": "8.27.0", - "esbuild": "0.25.1", + "@types/node": "22.14.0", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", + "esbuild": "0.25.2", "eslint-plugin-vue": "10.0.0", "nodemon": "3.1.9", - "typescript": "5.8.2", - "vue-eslint-parser": "10.1.1" + "typescript": "5.8.3", + "vue-eslint-parser": "10.1.3" }, "files": [ "js-built" diff --git a/packages/frontend-shared/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5 index cefc5edb72..924be27455 100644 --- a/packages/frontend-shared/themes/_dark.json5 +++ b/packages/frontend-shared/themes/_dark.json5 @@ -15,8 +15,6 @@ focus: ':alpha<0.3<@accent', bg: '#000', fg: '#dadada', - fgTransparentWeak: ':alpha<0.75<@fg', - fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':lighten<3<@fg', fgOnAccent: '#fff', fgOnWhite: '#333', @@ -26,7 +24,6 @@ panelHighlight: ':lighten<3<@panel', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--MI_THEME-divider)', windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', diff --git a/packages/frontend-shared/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5 index 25e6e5ab7f..e3c62ff543 100644 --- a/packages/frontend-shared/themes/_light.json5 +++ b/packages/frontend-shared/themes/_light.json5 @@ -15,8 +15,6 @@ focus: ':alpha<0.3<@accent', bg: '#fff', fg: '#5f5f5f', - fgTransparentWeak: ':alpha<0.75<@fg', - fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':darken<3<@fg', fgOnAccent: '#fff', fgOnWhite: '#333', @@ -26,7 +24,6 @@ panelHighlight: ':darken<3<@panel', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--MI_THEME-divider)', windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', diff --git a/packages/frontend-shared/themes/d-astro.json5 b/packages/frontend-shared/themes/d-astro.json5 index 48839241bd..6d34665528 100644 --- a/packages/frontend-shared/themes/d-astro.json5 +++ b/packages/frontend-shared/themes/d-astro.json5 @@ -7,9 +7,9 @@ bg: '#232125', fg: '#efdab9', link: '#78b0a0', - warn: '#ecb637', + warn: '#ffd152', badge: '#31b1ce', - error: '#ec4137', + error: '#ff6652', focus: ':alpha<0.3<@accent', navBg: '@panel', navFg: '@fg', @@ -24,7 +24,7 @@ hashtag: '#ff9156', mention: '#ffd152', modalBg: 'rgba(0, 0, 0, 0.5)', - success: '#86b300', + success: '#78b07f', indicator: '@accent', mentionMe: '#fb5d38', messageBg: '@bg', @@ -46,7 +46,6 @@ fgOnWhite: '@accent', panelHighlight: ':lighten<3<@panel', scrollbarHandle: 'rgba(255, 255, 255, 0.2)', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', }, } diff --git a/packages/frontend-shared/themes/d-botanical.json5 b/packages/frontend-shared/themes/d-botanical.json5 index 62208d2378..5a57a14f13 100644 --- a/packages/frontend-shared/themes/d-botanical.json5 +++ b/packages/frontend-shared/themes/d-botanical.json5 @@ -14,7 +14,6 @@ fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.14)', panel: 'rgb(47, 47, 44)', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', header: ':alpha<0.7<@panel', navBg: '#363636', renote: '@accent', @@ -22,5 +21,8 @@ mentionMe: 'rgb(212, 210, 76)', hashtag: '#5bcbb0', link: '@accent', + success: '@accent', + warn: 'rgb(255, 213, 82)', + error: 'rgb(255, 105, 82)', }, } diff --git a/packages/frontend-shared/themes/d-dark.json5 b/packages/frontend-shared/themes/d-dark.json5 index ae4f7d53f5..67d49aa861 100644 --- a/packages/frontend-shared/themes/d-dark.json5 +++ b/packages/frontend-shared/themes/d-dark.json5 @@ -14,7 +14,6 @@ fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.14)', panel: '#2d2d2d', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', header: ':alpha<0.7<@panel', navBg: '#363636', renote: '@accent', diff --git a/packages/frontend-shared/themes/d-future.json5 b/packages/frontend-shared/themes/d-future.json5 index f2c1f3eb86..6a66f2eca9 100644 --- a/packages/frontend-shared/themes/d-future.json5 +++ b/packages/frontend-shared/themes/d-future.json5 @@ -15,7 +15,6 @@ fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.1)', panel: '#18181c', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', renote: '@accent', mention: '#f2c97d', mentionMe: '@accent', diff --git a/packages/frontend-shared/themes/d-green-lime.json5 b/packages/frontend-shared/themes/d-green-lime.json5 index ca4e688fdb..fcd6651197 100644 --- a/packages/frontend-shared/themes/d-green-lime.json5 +++ b/packages/frontend-shared/themes/d-green-lime.json5 @@ -15,7 +15,6 @@ fgOnWhite: '@accent', divider: '#e7fffb24', panel: '#192320', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', popup: '#293330', renote: '@accent', mentionMe: '#ffaa00', diff --git a/packages/frontend-shared/themes/d-green-orange.json5 b/packages/frontend-shared/themes/d-green-orange.json5 index c2539816e2..aef3897329 100644 --- a/packages/frontend-shared/themes/d-green-orange.json5 +++ b/packages/frontend-shared/themes/d-green-orange.json5 @@ -15,7 +15,6 @@ fgOnWhite: '@accent', divider: '#e7fffb24', panel: '#192320', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', popup: '#293330', renote: '@accent', mentionMe: '#b4e900', diff --git a/packages/frontend-shared/themes/d-persimmon.json5 b/packages/frontend-shared/themes/d-persimmon.json5 index 0ab6523dd7..538e3b7e70 100644 --- a/packages/frontend-shared/themes/d-persimmon.json5 +++ b/packages/frontend-shared/themes/d-persimmon.json5 @@ -22,5 +22,8 @@ mentionMe: '#de6161', hashtag: '#68bad0', link: '#a1c758', + error: '#ce5441', + warn: '#d0b868', + success: '#a1c758', }, } diff --git a/packages/frontend-shared/themes/d-u0.json5 b/packages/frontend-shared/themes/d-u0.json5 index ddce06649c..4f6c04b906 100644 --- a/packages/frontend-shared/themes/d-u0.json5 +++ b/packages/frontend-shared/themes/d-u0.json5 @@ -49,7 +49,6 @@ navIndicator: '@indicator', driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', - fgTransparent: ':alpha<0.5<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', buttonGradateA: '@accent', @@ -58,8 +57,6 @@ panelHighlight: ':lighten<3<@panel', scrollbarHandle: 'rgba(255, 255, 255, 0.2)', inputBorderHover: 'rgba(255, 255, 255, 0.2)', - fgTransparentWeak: ':alpha<0.75<@fg', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', deckBg: '#142022', }, diff --git a/packages/frontend-shared/themes/l-botanical.json5 b/packages/frontend-shared/themes/l-botanical.json5 index 17e9ca246f..2fbae4fbae 100644 --- a/packages/frontend-shared/themes/l-botanical.json5 +++ b/packages/frontend-shared/themes/l-botanical.json5 @@ -13,18 +13,17 @@ fgHighlighted: '#6bc9a0', fgOnWhite: '@accent', divider: '#cfcfcf', - panel: '@X14', + panel: '#ebe7e5', panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', header: ':alpha<0.7<@panel', - navBg: '@X14', + navBg: '#ebe7e5', renote: '#229e92', mention: '#da6d35', mentionMe: '#d44c4c', hashtag: '#4cb8d4', link: '@accent', buttonGradateB: ':hue<-70<@accent', - success: '#86b300', - X14: '#ebe7e5' + success: '@accent', + error: '#da5635', }, } diff --git a/packages/frontend-shared/themes/l-coffee.json5 b/packages/frontend-shared/themes/l-coffee.json5 index b64cc73583..df3a12a37b 100644 --- a/packages/frontend-shared/themes/l-coffee.json5 +++ b/packages/frontend-shared/themes/l-coffee.json5 @@ -18,5 +18,8 @@ mention: '@accent', mentionMe: 'rgb(170, 149, 98)', hashtag: '@accent', + error: '#db9184', + warn: '#dbc184', + success: '#a3c975', }, } diff --git a/packages/frontend-shared/themes/l-light.json5 b/packages/frontend-shared/themes/l-light.json5 index 63c2e6d278..55f2d2f004 100644 --- a/packages/frontend-shared/themes/l-light.json5 +++ b/packages/frontend-shared/themes/l-light.json5 @@ -15,7 +15,6 @@ header: ':alpha<0.7<@panel', navBg: '#fff', panel: '#fff', - panelHeaderDivider: '@divider', mentionMe: 'rgb(0, 179, 70)', }, } diff --git a/packages/frontend-shared/themes/l-rainy.json5 b/packages/frontend-shared/themes/l-rainy.json5 index e7d1d5af00..d7c31bda8d 100644 --- a/packages/frontend-shared/themes/l-rainy.json5 +++ b/packages/frontend-shared/themes/l-rainy.json5 @@ -13,7 +13,6 @@ fgOnWhite: '@accent', panel: '#fff', divider: 'rgb(230 233 234)', - panelHeaderDivider: '@divider', renote: '@accent', link: '@accent', mention: '@accent', diff --git a/packages/frontend-shared/themes/l-u0.json5 b/packages/frontend-shared/themes/l-u0.json5 index d8e403c961..35241986df 100644 --- a/packages/frontend-shared/themes/l-u0.json5 +++ b/packages/frontend-shared/themes/l-u0.json5 @@ -51,7 +51,6 @@ buttonHoverBg: '#0000001a', driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', - fgTransparent: ':alpha<0.5<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', buttonGradateA: '@accent', @@ -60,8 +59,6 @@ panelHighlight: ':lighten<3<@panel', scrollbarHandle: '#74747433', inputBorderHover: 'rgba(255, 255, 255, 0.2)', - fgTransparentWeak: ':alpha<0.75<@fg', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', }, } diff --git a/packages/frontend-shared/themes/l-vivid.json5 b/packages/frontend-shared/themes/l-vivid.json5 index d69f024a6b..5ad8d60728 100644 --- a/packages/frontend-shared/themes/l-vivid.json5 +++ b/packages/frontend-shared/themes/l-vivid.json5 @@ -41,15 +41,12 @@ navIndicator: '@accent', driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':darken<3<@fg', - fgTransparent: ':alpha<0.5<@fg', fgOnWhite: '@accent', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', htmlThemeColor: '@bg', panelHighlight: ':darken<3<@panel', scrollbarHandle: 'rgba(0, 0, 0, 0.2)', - fgTransparentWeak: ':alpha<0.75<@fg', - panelHeaderDivider: '@divider', scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', }, } diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index 269bc4fb9a..fb855c1410 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -83,7 +83,7 @@ queueMicrotask(() => { widgets(app); misskeyOS = os; if (isChromatic()) { - prefer.set('animation', false); + prefer.commit('animation', false); } }); }); diff --git a/packages/frontend/lib/vite-plugin-create-search-index.ts b/packages/frontend/lib/vite-plugin-create-search-index.ts index d506e84bb6..97f4e589a3 100644 --- a/packages/frontend/lib/vite-plugin-create-search-index.ts +++ b/packages/frontend/lib/vite-plugin-create-search-index.ts @@ -3,78 +3,67 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +/// + import { parse as vueSfcParse } from 'vue/compiler-sfc'; -import type { LogOptions, Plugin } from 'vite'; +import { + createLogger, + EnvironmentModuleGraph, + type LogErrorOptions, + type LogOptions, + normalizePath, + type Plugin, + type PluginOption +} from 'vite'; import fs from 'node:fs'; import { glob } from 'glob'; import JSON5 from 'json5'; -import MagicString from 'magic-string'; +import MagicString, { SourceMap } from 'magic-string'; import path from 'node:path' import { hash, toBase62 } from '../vite.config'; -import { createLogger } from 'vite'; +import { minimatch } from 'minimatch'; +import { + type AttributeNode, + type DirectiveNode, + type ElementNode, + ElementTypes, + NodeTypes, + type RootNode, + type SimpleExpressionNode, + type TemplateChildNode, +} from '@vue/compiler-core'; -interface VueAstNode { - type: number; - tag?: string; - loc?: { - start: { offset: number, line: number, column: number }, - end: { offset: number, line: number, column: number }, - source?: string - }; - props?: Array<{ - name: string; - type: number; - value?: { content?: string }; - arg?: { content?: string }; - exp?: { content?: string; loc?: any }; - }>; - children?: VueAstNode[]; - content?: any; - __markerId?: string; - __children?: string[]; -} - -export type AnalysisResult = { - filePath: string; - usage: SearchIndexItem[]; -} - -export type SearchIndexItem = { +export interface SearchIndexItem { id: string; + parentId?: string; path?: string; label: string; - keywords: string | string[]; + keywords: string[]; icon?: string; inlining?: string[]; - children?: SearchIndexItem[]; -}; +} export type Options = { targetFilePaths: string[], - exportFilePath: string, + mainVirtualModule: string, + modulesToHmrOnUpdate: string[], + fileVirtualModulePrefix?: string, + fileVirtualModuleSuffix?: string, verbose?: boolean, }; -// 関連するノードタイプの定数化 -const NODE_TYPES = { - ELEMENT: 1, - EXPRESSION: 2, - TEXT: 3, - INTERPOLATION: 5, // Mustache -}; - // マーカー関係を表す型 interface MarkerRelation { parentId?: string; markerId: string; - node: VueAstNode; + node: ElementNode; } // ロガー let logger = { info: (msg: string, options?: LogOptions) => { }, warn: (msg: string, options?: LogOptions) => { }, - error: (msg: string, options?: LogOptions) => { }, + error: (msg: string, options?: LogErrorOptions | unknown) => { }, }; let loggerInitialized = false; @@ -99,1227 +88,474 @@ function initLogger(options: Options) { } } +//region AST Utility + +type WalkVueNode = RootNode | TemplateChildNode | SimpleExpressionNode; + /** - * 解析結果をTypeScriptファイルとして出力する + * Walks the Vue AST. + * @param nodes + * @param context The context value passed to callback. you can update context for children by returning value in callback + * @param callback Returns false if you don't want to walk inner tree */ -function outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void { - logger.info(`Processing ${analysisResults.length} files for output`); - - // 新しいツリー構造を構築 - const allMarkers = new Map(); - - // 1. すべてのマーカーを一旦フラットに収集 - for (const file of analysisResults) { - logger.info(`Processing file: ${file.filePath} with ${file.usage.length} markers`); - - for (const marker of file.usage) { - if (marker.id) { - // キーワードとchildren処理を共通化 - const processedMarker = { - ...marker, - keywords: processMarkerProperty(marker.keywords, 'keywords'), - children: processMarkerProperty(marker.children || [], 'children') - }; - - allMarkers.set(marker.id, processedMarker); - } +function walkVueElements(nodes: WalkVueNode[], context: C, callback: (node: ElementNode, context: C) => C | undefined | void | false): void { + for (const node of nodes) { + let currentContext = context; + if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); + if (node.type === NodeTypes.ELEMENT) { + const result = callback(node, context); + if (result === false) return; + if (result !== undefined) currentContext = result; + } + if ('children' in node) { + walkVueElements(node.children, currentContext, callback); } } - - logger.info(`Collected total ${allMarkers.size} unique markers`); - - // 2. 子マーカーIDの収集 - const childIds = collectChildIds(allMarkers); - logger.info(`Found ${childIds.size} child markers`); - - // 3. ルートマーカーの特定(他の誰かの子でないマーカー) - const rootMarkers = identifyRootMarkers(allMarkers, childIds); - logger.info(`Found ${rootMarkers.length} root markers`); - - // 4. 子マーカーの参照を解決 - const resolvedRootMarkers = resolveChildReferences(rootMarkers, allMarkers); - - // 5. デバッグ情報を生成 - const { totalMarkers, totalChildren } = countMarkers(resolvedRootMarkers); - logger.info(`Total markers in tree: ${totalMarkers} (${resolvedRootMarkers.length} roots + ${totalChildren} nested children)`); - - // 6. 結果をTS形式で出力 - writeOutputFile(outputPath, resolvedRootMarkers); } -/** - * マーカーのプロパティ(keywordsやchildren)を処理する - */ -function processMarkerProperty(propValue: any, propType: 'keywords' | 'children'): any { - // 文字列の配列表現を解析 - if (typeof propValue === 'string' && propValue.startsWith('[') && propValue.endsWith(']')) { - try { - // JSON5解析を試みる - return JSON5.parse(propValue.replace(/'/g, '"')); - } catch (e) { - // 解析に失敗した場合 - logger.warn(`Could not parse ${propType}: ${propValue}, using ${propType === 'children' ? 'empty array' : 'as is'}`); - return propType === 'children' ? [] : propValue; +function findAttribute(props: Array, name: string): AttributeNode | DirectiveNode | null { + for (const prop of props) { + switch (prop.type) { + case NodeTypes.ATTRIBUTE: + if (prop.name === name) { + return prop; + } + break; + case NodeTypes.DIRECTIVE: + if (prop.name === 'bind' && prop.arg && 'content' in prop.arg && prop.arg.content === name) { + return prop; + } + break; } } - - return propValue; + return null; } -/** - * 全マーカーから子IDを収集する - */ -function collectChildIds(allMarkers: Map): Set { - const childIds = new Set(); - - allMarkers.forEach((marker, id) => { - // 通常のchildren処理 - const children = marker.children; - if (Array.isArray(children)) { - children.forEach(childId => { - if (typeof childId === 'string') { - if (!allMarkers.has(childId)) { - logger.warn(`Warning: Child marker ID ${childId} referenced but not found`); - } else { - childIds.add(childId); - } - } - }); - } - - // inlining処理を追加 - if (marker.inlining) { - let inliningIds: string[] = []; - - // 文字列の場合は配列に変換 - if (typeof marker.inlining === 'string') { - try { - const inliningStr = (marker.inlining as string).trim(); - if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) { - inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"')); - logger.info(`Parsed inlining string to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`); - } else { - inliningIds = [inliningStr]; - } - } catch (e) { - logger.error(`Failed to parse inlining string: ${marker.inlining}`, e); - } - } - // 既に配列の場合 - else if (Array.isArray(marker.inlining)) { - inliningIds = marker.inlining; - } - - // inliningで指定されたIDを子セットに追加 - for (const inlineId of inliningIds) { - if (typeof inlineId === 'string') { - if (!allMarkers.has(inlineId)) { - logger.warn(`Warning: Inlining marker ID ${inlineId} referenced but not found`); - } else { - // inliningで参照されているマーカーも子として扱う - childIds.add(inlineId); - logger.info(`Added inlined marker ${inlineId} as child in collectChildIds`); - } - } - } - } - }); - - return childIds; -} - -/** - * ルートマーカー(他の子でないマーカー)を特定する - */ -function identifyRootMarkers( - allMarkers: Map, - childIds: Set -): SearchIndexItem[] { - const rootMarkers: SearchIndexItem[] = []; - - allMarkers.forEach((marker, id) => { - if (!childIds.has(id)) { - rootMarkers.push(marker); - logger.info(`Added root marker to output: ${id} with label ${marker.label}`); - } - }); - - return rootMarkers; -} - -/** - * 子マーカーの参照をIDから実際のオブジェクトに解決する - */ -function resolveChildReferences( - rootMarkers: SearchIndexItem[], - allMarkers: Map -): SearchIndexItem[] { - function resolveChildrenForMarker(marker: SearchIndexItem): SearchIndexItem { - // マーカーのディープコピーを作成 - const resolvedMarker = { ...marker }; - // 明示的に子マーカー配列を作成 - const resolvedChildren: SearchIndexItem[] = []; - - // 通常のchildren処理 - if (Array.isArray(marker.children)) { - for (const childId of marker.children) { - if (typeof childId === 'string') { - const childMarker = allMarkers.get(childId); - if (childMarker) { - // 子マーカーの子も再帰的に解決 - const resolvedChild = resolveChildrenForMarker(childMarker); - resolvedChildren.push(resolvedChild); - logger.info(`Resolved regular child ${childId} for parent ${marker.id}`); - } - } - } - } - - // inlining属性の処理 - let inliningIds: string[] = []; - - // 文字列の場合は配列に変換。例: "['2fa']" -> ['2fa'] - if (typeof marker.inlining === 'string') { - try { - // 文字列形式の配列を実際の配列に変換 - const inliningStr = (marker.inlining as string).trim(); - if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) { - inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"')); - logger.info(`Converted string inlining to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`); - } else { - // 単一値の場合は配列に - inliningIds = [inliningStr]; - logger.info(`Converted single string inlining to array: ${inliningStr}`); - } - } catch (e) { - logger.error(`Failed to parse inlining string: ${marker.inlining}`, e); - } - } - // 既に配列の場合はそのまま使用 - else if (Array.isArray(marker.inlining)) { - inliningIds = marker.inlining; - } - - // インライン指定されたマーカーを子として追加 - for (const inlineId of inliningIds) { - if (typeof inlineId === 'string') { - const inlineMarker = allMarkers.get(inlineId); - if (inlineMarker) { - // インライン指定されたマーカーを再帰的に解決 - const resolvedInline = resolveChildrenForMarker(inlineMarker); - delete resolvedInline.path - resolvedChildren.push(resolvedInline); - logger.info(`Added inlined marker ${inlineId} as child to ${marker.id}`); - } else { - logger.warn(`Inlining target not found: ${inlineId} referenced by ${marker.id}`); - } - } - } - - // 解決した子が存在する場合のみchildrenプロパティを設定 - if (resolvedChildren.length > 0) { - resolvedMarker.children = resolvedChildren; - } else { - delete resolvedMarker.children; - } - - return resolvedMarker; - } - - // すべてのルートマーカーの子を解決 - return rootMarkers.map(marker => resolveChildrenForMarker(marker)); -} - -/** - * マーカー数を数える(デバッグ用) - */ -function countMarkers(markers: SearchIndexItem[]): { totalMarkers: number, totalChildren: number } { - let totalMarkers = markers.length; - let totalChildren = 0; - - function countNested(items: SearchIndexItem[]): void { - for (const marker of items) { - if (marker.children && Array.isArray(marker.children)) { - totalChildren += marker.children.length; - totalMarkers += marker.children.length; - countNested(marker.children as SearchIndexItem[]); - } - } - } - - countNested(markers); - return { totalMarkers, totalChildren }; -} - -/** - * 最終的なTypeScriptファイルを出力 - */ -function writeOutputFile(outputPath: string, resolvedRootMarkers: SearchIndexItem[]): void { - try { - const tsOutput = generateTypeScriptCode(resolvedRootMarkers); - fs.writeFileSync(outputPath, tsOutput, 'utf-8'); - // 強制的に出力させるためにViteロガーを使わない - console.log(`Successfully wrote search index to ${outputPath} with ${resolvedRootMarkers.length} root entries`); - } catch (error) { - logger.error('[create-search-index]: error writing output: ', error); +function findEndOfStartTagAttributes(node: ElementNode): number { + if (node.children.length > 0) { + // 子要素がある場合、最初の子要素の開始位置を基準にする + const nodeStart = node.loc.start.offset; + const firstChildStart = node.children[0].loc.start.offset; + const endOfStartTag = node.loc.source.lastIndexOf('>', firstChildStart - nodeStart); + if (endOfStartTag === -1) throw new Error("Bug: Failed to find end of start tag"); + return nodeStart + endOfStartTag; + } else { + // 子要素がない場合、自身の終了位置から逆算 + return node.isSelfClosing ? node.loc.end.offset - 1 : node.loc.end.offset; } } +//endregion + /** * TypeScriptコード生成 */ -function generateTypeScriptCode(resolvedRootMarkers: SearchIndexItem[]): string { - return ` -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// This file was automatically generated by create-search-index. -// Do not edit this file. - -import { i18n } from '@/i18n.js'; - -export type SearchIndexItem = { - id: string; - path?: string; - label: string; - keywords: string[]; - icon?: string; - children?: SearchIndexItem[]; -}; - -export const searchIndexes: SearchIndexItem[] = ${customStringify(resolvedRootMarkers)} as const; - -export type SearchIndex = typeof searchIndexes; -`; +function generateJavaScriptCode(resolvedRootMarkers: SearchIndexItem[]): string { + return `import { i18n } from '@/i18n.js';\n` + + `export const searchIndexes = ${customStringify(resolvedRootMarkers)};\n`; } /** * オブジェクトを特殊な形式の文字列に変換する * i18n参照を保持しつつ適切な形式に変換 */ -function customStringify(obj: any, depth = 0): string { - const INDENT_STR = '\t'; +function customStringify(obj: unknown): string { + return JSON.stringify(obj).replaceAll(/"(.*?)"/g, (all, group) => { + // propertyAccessProxy が i18n 参照を "${i18n.xxx}"のような形に変換してるので、これをそのまま`${i18n.xxx}` + // のような形にすると、実行時にi18nのプロパティにアクセスするようになる。 + // objectのkeyでは``が使えないので、${ が使われている場合にのみ``に置き換えるようにする + return group.includes('${') ? '`' + group + '`' : all; + }); +} - // 配列の処理 - if (Array.isArray(obj)) { - if (obj.length === 0) return '[]'; - const indent = INDENT_STR.repeat(depth); - const childIndent = INDENT_STR.repeat(depth + 1); +// region extractElementText - // 配列要素の処理 - const items = obj.map(item => { - // オブジェクト要素 - if (typeof item === 'object' && item !== null) { - return `${childIndent}${customStringify(item, depth + 1)}`; +/** + * 要素のノードの中身のテキストを抽出する + */ +function extractElementText(node: ElementNode, id: string): string | null { + return extractElementTextChecked(node, node.tag, id); +} + +function extractElementTextChecked(node: ElementNode, processingNodeName: string, id: string): string | null { + const result: string[] = []; + for (const child of node.children) { + const text = extractElementText2Inner(child, processingNodeName, id); + if (text == null) return null; + result.push(text); + } + return result.join(''); +} + +function extractElementText2Inner(node: TemplateChildNode, processingNodeName: string, id: string): string | null { + if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); + + switch (node.type) { + case NodeTypes.INTERPOLATION: { + const expr = node.content; + if (expr.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error(`Unexpected COMPOUND_EXPRESSION`); + const exprResult = evalExpression(expr.content); + if (typeof exprResult !== 'string') { + logger.error(`Result of interpolation node is not string at line ${id}:${node.loc.start.line}`); + return null; } - - // i18n参照を含む文字列要素 - if (typeof item === 'string' && item.includes('i18n.ts.')) { - return `${childIndent}${item}`; // クォートなしでそのまま出力 + return exprResult; + } + case NodeTypes.ELEMENT: + if (node.tagType === ElementTypes.ELEMENT) { + return extractElementTextChecked(node, processingNodeName, id); + } else { + logger.error(`Unexpected ${node.tag} extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`); + return null; } - - // その他の要素 - return `${childIndent}${JSON5.stringify(item)}`; - }).join(',\n'); - - return `[\n${items},\n${indent}]`; + case NodeTypes.TEXT: + return node.content; + case NodeTypes.COMMENT: + // We skip comments + return ''; + case NodeTypes.IF: + case NodeTypes.IF_BRANCH: + case NodeTypes.FOR: + case NodeTypes.TEXT_CALL: + logger.error(`Unexpected controlflow element extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`); + return null; } - - // null または非オブジェクト - if (obj === null || typeof obj !== 'object') { - return JSON5.stringify(obj); - } - - // オブジェクトの処理 - const indent = INDENT_STR.repeat(depth); - const childIndent = INDENT_STR.repeat(depth + 1); - - const entries = Object.entries(obj) - // 不要なプロパティを除去 - .filter(([key, value]) => { - if (value === undefined) return false; - if (key === 'children' && Array.isArray(value) && value.length === 0) return false; - if (key === 'inlining') return false; - return true; - }) - // 各プロパティを変換 - .map(([key, value]) => { - // 子要素配列の特殊処理 - if (key === 'children' && Array.isArray(value) && value.length > 0) { - return `${childIndent}${key}: ${customStringify(value, depth + 1)}`; - } - - // ラベルやその他プロパティを処理 - return `${childIndent}${key}: ${formatSpecialProperty(key, value)}`; - }); - - if (entries.length === 0) return '{}'; - return `{\n${entries.join(',\n')},\n${indent}}`; } -/** - * 特殊プロパティの書式設定 - */ -function formatSpecialProperty(key: string, value: any): string { - // 値がundefinedの場合は空文字列を返す - if (value === undefined) { - return '""'; - } +// endregion - // childrenが配列の場合は特別に処理 - if (key === 'children' && Array.isArray(value)) { - return customStringify(value); - } - - // keywordsが配列の場合、特別に処理 - if (key === 'keywords' && Array.isArray(value)) { - return `[${formatArrayForOutput(value)}]`; - } - - // 文字列値の場合の特別処理 - if (typeof value === 'string') { - // i18n.ts 参照を含む場合 - クォートなしでそのまま出力 - if (isI18nReference(value)) { - logger.info(`Preserving i18n reference in output: ${value}`); - return value; - } - - // keywords が配列リテラルの形式の場合 - if (key === 'keywords' && value.startsWith('[') && value.endsWith(']')) { - return value; - } - } - - // 上記以外は通常の JSON5 文字列として返す - return JSON5.stringify(value); -} +// region extractUsageInfoFromTemplateAst /** - * 配列式の文字列表現を生成 + * SearchLabel/SearchKeyword/SearchIconを探して抽出する関数 */ -function formatArrayForOutput(items: any[]): string { - return items.map(item => { - // i18n.ts. 参照の文字列はそのままJavaScript式として出力 - if (typeof item === 'string' && isI18nReference(item)) { - logger.info(`Preserving i18n reference in array: ${item}`); - return item; // クォートなしでそのまま - } - - // その他の値はJSON5形式で文字列化 - return JSON5.stringify(item); - }).join(', '); -} - -/** - * 要素ノードからテキスト内容を抽出する - * 各抽出方法を分離して可読性を向上 - */ -function extractElementText(node: VueAstNode): string | null { - if (!node) return null; - - logger.info(`Extracting text from node type=${node.type}, tag=${node.tag || 'unknown'}`); - - // 1. 直接コンテンツの抽出を試行 - const directContent = extractDirectContent(node); - if (directContent) return directContent; - - // 子要素がない場合は終了 - if (!node.children || !Array.isArray(node.children)) { - return null; - } - - // 2. インターポレーションノードを検索 - const interpolationContent = extractInterpolationContent(node.children); - if (interpolationContent) return interpolationContent; - - // 3. 式ノードを検索 - const expressionContent = extractExpressionContent(node.children); - if (expressionContent) return expressionContent; - - // 4. テキストノードを検索 - const textContent = extractTextContent(node.children); - if (textContent) return textContent; - - // 5. 再帰的に子ノードを探索 - return extractNestedContent(node.children); -} -/** - * ノードから直接コンテンツを抽出 - */ -function extractDirectContent(node: VueAstNode): string | null { - if (!node.content) return null; - - const content = typeof node.content === 'string' - ? node.content.trim() - : (node.content.content ? node.content.content.trim() : null); - - if (!content) return null; - - logger.info(`Direct node content found: ${content}`); - - // Mustache構文のチェック - const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/; - const mustacheMatch = content.match(mustachePattern); - - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - const extractedContent = mustacheMatch[1].trim(); - logger.info(`Extracted i18n reference from mustache: ${extractedContent}`); - return extractedContent; - } - - // 直接i18n参照を含む場合 - if (isI18nReference(content)) { - logger.info(`Direct i18n reference found: ${content}`); - return content; - } - - // その他のコンテンツ - return content; -} - -/** - * インターポレーションノード(Mustache)からコンテンツを抽出 - */ -function extractInterpolationContent(children: VueAstNode[]): string | null { - for (const child of children) { - if (child.type === NODE_TYPES.INTERPOLATION) { - logger.info(`Found interpolation node (Mustache): ${JSON.stringify(child.content).substring(0, 100)}...`); - - if (child.content && child.content.type === 4 && child.content.content) { - const content = child.content.content.trim(); - logger.info(`Interpolation content: ${content}`); - - if (isI18nReference(content)) { - return content; - } - } else if (child.content && typeof child.content === 'object') { - // オブジェクト形式のcontentを探索 - logger.info(`Complex interpolation node: ${JSON.stringify(child.content).substring(0, 100)}...`); - - if (child.content.content) { - const content = child.content.content.trim(); - - if (isI18nReference(content)) { - logger.info(`Found i18n reference in complex interpolation: ${content}`); - return content; - } - } - } - } - } - - return null; -} - -/** - * 式ノードからコンテンツを抽出 - */ -function extractExpressionContent(children: VueAstNode[]): string | null { - // i18n.ts. 参照パターンを持つものを優先 - for (const child of children) { - if (child.type === NODE_TYPES.EXPRESSION && child.content) { - const expr = child.content.trim(); - - if (isI18nReference(expr)) { - logger.info(`Found i18n reference in expression node: ${expr}`); - return expr; - } - } - } - - // その他の式 - for (const child of children) { - if (child.type === NODE_TYPES.EXPRESSION && child.content) { - const expr = child.content.trim(); - logger.info(`Found expression: ${expr}`); - return expr; - } - } - - return null; -} - -/** - * テキストノードからコンテンツを抽出 - */ -function extractTextContent(children: VueAstNode[]): string | null { - for (const child of children) { - if (child.type === NODE_TYPES.TEXT && child.content) { - const text = child.content.trim(); - - if (text) { - logger.info(`Found text node: ${text}`); - - // Mustache構文のチェック - const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/; - const mustacheMatch = text.match(mustachePattern); - - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - logger.info(`Extracted i18n ref from text mustache: ${mustacheMatch[1]}`); - return mustacheMatch[1].trim(); - } - - return text; - } - } - } - - return null; -} - -/** - * 子ノードを再帰的に探索してコンテンツを抽出 - */ -function extractNestedContent(children: VueAstNode[]): string | null { - for (const child of children) { - if (child.children && Array.isArray(child.children) && child.children.length > 0) { - const nestedContent = extractElementText(child); - - if (nestedContent) { - logger.info(`Found nested content: ${nestedContent}`); - return nestedContent; - } - } else if (child.type === NODE_TYPES.ELEMENT) { - // childrenがなくても内部を調査 - const nestedContent = extractElementText(child); - - if (nestedContent) { - logger.info(`Found content in childless element: ${nestedContent}`); - return nestedContent; - } - } - } - - return null; -} - - -/** - * SearchLabelとSearchKeywordを探して抽出する関数 - */ -function extractLabelsAndKeywords(nodes: VueAstNode[]): { label: string | null, keywords: any[] } { - let label: string | null = null; - const keywords: any[] = []; +function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null, keywords: string[], icon: string | null } { + let label: string | null | undefined = undefined; + let icon: string | null | undefined = undefined; + const keywords: string[] = []; logger.info(`Extracting labels and keywords from ${nodes.length} nodes`); - // 再帰的にSearchLabelとSearchKeywordを探索(ネストされたSearchMarkerは処理しない) - function findComponents(nodes: VueAstNode[]) { - for (const node of nodes) { - if (node.type === NODE_TYPES.ELEMENT) { - logger.info(`Checking element: ${node.tag}`); - - // SearchMarkerの場合は、その子要素は別スコープなのでスキップ - if (node.tag === 'SearchMarker') { - logger.info(`Found nested SearchMarker - skipping its content to maintain scope isolation`); - continue; // このSearchMarkerの中身は処理しない (スコープ分離) + walkVueElements(nodes, null, (node) => { + switch (node.tag) { + case 'SearchMarker': + return false; // SearchMarkerはスキップ + case 'SearchLabel': + if (label !== undefined) { + logger.warn(`Duplicate SearchLabel found, ignoring the second one at ${id}:${node.loc.start.line}`); + break; // 2つ目のSearchLabelは無視 } - // SearchLabelの処理 - if (node.tag === 'SearchLabel') { - logger.info(`Found SearchLabel node, structure: ${JSON.stringify(node).substring(0, 200)}...`); - - // まず完全なノード内容の抽出を試みる - const content = extractElementText(node); - if (content) { - label = content; - logger.info(`SearchLabel content extracted: ${content}`); - } else { - logger.info(`SearchLabel found but extraction failed, trying direct children inspection`); - - // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認 - if (node.children && Array.isArray(node.children)) { - for (const child of node.children) { - // Mustacheインターポレーション - if (child.type === NODE_TYPES.INTERPOLATION && child.content) { - // content内の式を取り出す - const expression = child.content.content || - (child.content.type === 4 ? child.content.content : null) || - JSON.stringify(child.content); - - logger.info(`Interpolation expression: ${expression}`); - if (typeof expression === 'string' && isI18nReference(expression)) { - label = expression.trim(); - logger.info(`Found i18n in interpolation: ${label}`); - break; - } - } - // 式ノード - else if (child.type === NODE_TYPES.EXPRESSION && child.content && isI18nReference(child.content)) { - label = child.content.trim(); - logger.info(`Found i18n in expression: ${label}`); - break; - } - // テキストノードでもMustache構文を探す - else if (child.type === NODE_TYPES.TEXT && child.content) { - const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/); - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - label = mustacheMatch[1].trim(); - logger.info(`Found i18n in text mustache: ${label}`); - break; - } - } - } - } - } + label = extractElementText(node, id); + return; + case 'SearchKeyword': + const content = extractElementText(node, id); + if (content) { + keywords.push(content); } - // SearchKeywordの処理 - else if (node.tag === 'SearchKeyword') { - logger.info(`Found SearchKeyword node`); - - // まず完全なノード内容の抽出を試みる - const content = extractElementText(node); - if (content) { - keywords.push(content); - logger.info(`SearchKeyword content extracted: ${content}`); - } else { - logger.info(`SearchKeyword found but extraction failed, trying direct children inspection`); - - // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認 - if (node.children && Array.isArray(node.children)) { - for (const child of node.children) { - // Mustacheインターポレーション - if (child.type === NODE_TYPES.INTERPOLATION && child.content) { - // content内の式を取り出す - const expression = child.content.content || - (child.content.type === 4 ? child.content.content : null) || - JSON.stringify(child.content); - - logger.info(`Keyword interpolation: ${expression}`); - if (typeof expression === 'string' && isI18nReference(expression)) { - const keyword = expression.trim(); - keywords.push(keyword); - logger.info(`Found i18n keyword in interpolation: ${keyword}`); - break; - } - } - // 式ノード - else if (child.type === NODE_TYPES.EXPRESSION && child.content && isI18nReference(child.content)) { - const keyword = child.content.trim(); - keywords.push(keyword); - logger.info(`Found i18n keyword in expression: ${keyword}`); - break; - } - // テキストノードでもMustache構文を探す - else if (child.type === NODE_TYPES.TEXT && child.content) { - const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/); - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - const keyword = mustacheMatch[1].trim(); - keywords.push(keyword); - logger.info(`Found i18n keyword in text mustache: ${keyword}`); - break; - } - } - } - } - } + return; + case 'SearchIcon': + if (icon !== undefined) { + logger.warn(`Duplicate SearchIcon found, ignoring the second one at ${id}:${node.loc.start.line}`); + break; // 2つ目のSearchIconは無視 } - // 子要素を再帰的に調査(ただしSearchMarkerは除外) - if (node.children && Array.isArray(node.children)) { - findComponents(node.children); + if (node.children.length !== 1) { + logger.error(`SearchIcon must have exactly one child at ${id}:${node.loc.start.line}`); + return; } - } + + const iconNode = node.children[0]; + if (iconNode.type !== NodeTypes.ELEMENT) { + logger.error(`SearchIcon must have a child element at ${id}:${node.loc.start.line}`); + return; + } + icon = getStringProp(findAttribute(iconNode.props, 'class'), id); + return; } - } - findComponents(nodes); + return; + }); // デバッグ情報 - logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}]`); - return { label, keywords }; + logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}, icon=${icon}]`); + return { label: label ?? null, keywords, icon: icon ?? null }; } +function getStringProp(attr: AttributeNode | DirectiveNode | null, id: string): string | null { + switch (attr?.type) { + case null: + case undefined: + return null; + case NodeTypes.ATTRIBUTE: + return attr.value?.content ?? null; + case NodeTypes.DIRECTIVE: + if (attr.exp == null) return null; + if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION'); + const value = evalExpression(attr.exp.content ?? ''); + if (typeof value !== 'string') { + logger.error(`Expected string value, got ${typeof value} at ${id}:${attr.loc.start.line}`); + return null; + } + return value; + } +} + +function getStringArrayProp(attr: AttributeNode | DirectiveNode | null, id: string): string[] | null { + switch (attr?.type) { + case null: + case undefined: + return null; + case NodeTypes.ATTRIBUTE: + logger.error(`Expected directive, got attribute at ${id}:${attr.loc.start.line}`); + return null; + case NodeTypes.DIRECTIVE: + if (attr.exp == null) return null; + if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION'); + const value = evalExpression(attr.exp.content ?? ''); + if (!Array.isArray(value) || !value.every(x => typeof x === 'string')) { + logger.error(`Expected string array value, got ${typeof value} at ${id}:${attr.loc.start.line}`); + return null; + } + return value; + } +} function extractUsageInfoFromTemplateAst( - templateAst: any, + templateAst: RootNode | undefined, id: string, ): SearchIndexItem[] { const allMarkers: SearchIndexItem[] = []; const markerMap = new Map(); - const childrenIds = new Set(); - const normalizedId = id.replace(/\\/g, '/'); if (!templateAst) return allMarkers; - // マーカーの基本情報を収集 - function collectMarkers(node: VueAstNode, parentId: string | null = null) { - if (node.type === 1 && node.tag === 'SearchMarker') { - // マーカーID取得 - const markerIdProp = node.props?.find((p: any) => p.name === 'markerId'); - const markerId = markerIdProp?.value?.content || - node.__markerId; - - // SearchMarkerにマーカーIDがない場合はエラー - if (markerId == null) { - logger.error(`Marker ID not found for node: ${JSON.stringify(node)}`); - throw new Error(`Marker ID not found in file ${id}`); - } - - // マーカー基本情報 - const markerInfo: SearchIndexItem = { - id: markerId, - children: [], - label: '', // デフォルト値 - keywords: [], - }; - - // 静的プロパティを取得 - if (node.props && Array.isArray(node.props)) { - for (const prop of node.props) { - if (prop.type === 6 && prop.name && prop.name !== 'markerId') { - if (prop.name === 'path') markerInfo.path = prop.value?.content || ''; - else if (prop.name === 'icon') markerInfo.icon = prop.value?.content || ''; - else if (prop.name === 'label') markerInfo.label = prop.value?.content || ''; - } - } - } - - // バインドプロパティを取得 - const bindings = extractNodeBindings(node); - if (bindings.path) markerInfo.path = bindings.path; - if (bindings.icon) markerInfo.icon = bindings.icon; - if (bindings.label) markerInfo.label = bindings.label; - if (bindings.children) markerInfo.children = bindings.children; - if (bindings.inlining) { - markerInfo.inlining = bindings.inlining; - logger.info(`Added inlining ${JSON.stringify(bindings.inlining)} to marker ${markerId}`); - } - if (bindings.keywords) { - if (Array.isArray(bindings.keywords)) { - markerInfo.keywords = bindings.keywords; - } else { - markerInfo.keywords = bindings.keywords || []; - } - } - - //pathがない場合はファイルパスを設定 - if (markerInfo.path == null && parentId == null) { - markerInfo.path = normalizedId.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1]; - } - - // SearchLabelとSearchKeywordを抽出 (AST全体を探索) - if (node.children && Array.isArray(node.children)) { - logger.info(`Processing marker ${markerId} for labels and keywords`); - const extracted = extractLabelsAndKeywords(node.children); - - // SearchLabelからのラベル取得は最優先で適用 - if (extracted.label) { - markerInfo.label = extracted.label; - logger.info(`Using extracted label for ${markerId}: ${extracted.label}`); - } else if (markerInfo.label) { - logger.info(`Using existing label for ${markerId}: ${markerInfo.label}`); - } else { - markerInfo.label = 'Unnamed marker'; - logger.info(`No label found for ${markerId}, using default`); - } - - // SearchKeywordからのキーワード取得を追加 - if (extracted.keywords.length > 0) { - const existingKeywords = Array.isArray(markerInfo.keywords) ? - [...markerInfo.keywords] : - (markerInfo.keywords ? [markerInfo.keywords] : []); - - // i18n参照のキーワードは最優先で追加 - const combinedKeywords = [...existingKeywords]; - for (const kw of extracted.keywords) { - combinedKeywords.push(kw); - logger.info(`Added extracted keyword to ${markerId}: ${kw}`); - } - - markerInfo.keywords = combinedKeywords; - } - } - - // マーカーを登録 - markerMap.set(markerId, markerInfo); - allMarkers.push(markerInfo); - - // 親子関係を記録 - if (parentId) { - const parent = markerMap.get(parentId); - if (parent) { - childrenIds.add(markerId); - } - } - - // 子ノードを処理 - if (node.children && Array.isArray(node.children)) { - node.children.forEach((child: VueAstNode) => { - collectMarkers(child, markerId); - }); - } - - return markerId; - } - // SearchMarkerでない場合は再帰的に子ノードを処理 - else if (node.children && Array.isArray(node.children)) { - node.children.forEach((child: VueAstNode) => { - collectMarkers(child, parentId); - }); + walkVueElements([templateAst], null, (node, parentId) => { + if (node.tag !== 'SearchMarker') { + return; } - return null; - } + // マーカーID取得 + const markerIdProp = node.props?.find(p => p.name === 'markerId'); + const markerId = markerIdProp?.type == NodeTypes.ATTRIBUTE ? markerIdProp.value?.content : null; + + // SearchMarkerにマーカーIDがない場合はエラー + if (markerId == null) { + logger.error(`Marker ID not found for node: ${JSON.stringify(node)}`); + throw new Error(`Marker ID not found in file ${id}`); + } + + // マーカー基本情報 + const markerInfo: SearchIndexItem = { + id: markerId, + parentId: parentId ?? undefined, + label: '', // デフォルト値 + keywords: [], + }; + + // バインドプロパティを取得 + const path = getStringProp(findAttribute(node.props, 'path'), id) + const icon = getStringProp(findAttribute(node.props, 'icon'), id) + const label = getStringProp(findAttribute(node.props, 'label'), id) + const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id) + const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id) + + if (path) markerInfo.path = path; + if (icon) markerInfo.icon = icon; + if (label) markerInfo.label = label; + if (inlining) markerInfo.inlining = inlining; + if (keywords) markerInfo.keywords = keywords; + + //pathがない場合はファイルパスを設定 + if (markerInfo.path == null && parentId == null) { + markerInfo.path = id.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1]; + } + + // SearchLabelとSearchKeywordを抽出 (AST全体を探索) + { + const extracted = extractSugarTags(node.children, id); + if (extracted.label && markerInfo.label) logger.warn(`Duplicate label found for ${markerId} at ${id}:${node.loc.start.line}`); + if (extracted.icon && markerInfo.icon) logger.warn(`Duplicate icon found for ${markerId} at ${id}:${node.loc.start.line}`); + markerInfo.label = extracted.label ?? markerInfo.label ?? ''; + markerInfo.keywords = [...extracted.keywords, ...markerInfo.keywords]; + markerInfo.icon = extracted.icon ?? markerInfo.icon ?? undefined; + } + + if (!markerInfo.label) { + logger.warn(`No label found for ${markerId} at ${id}:${node.loc.start.line}`); + } + + // マーカーを登録 + markerMap.set(markerId, markerInfo); + allMarkers.push(markerInfo); + return markerId; + }); - // AST解析開始 - collectMarkers(templateAst); return allMarkers; } -// バインドプロパティの処理を修正する関数 -function extractNodeBindings(node: VueAstNode): Record { - const bindings: Record = {}; +//endregion - if (!node.props || !Array.isArray(node.props)) return bindings; +//region evalExpression - // バインド式を収集 - for (const prop of node.props) { - if (prop.type === 7 && prop.name === 'bind' && prop.arg?.content) { - const propName = prop.arg.content; - const propContent = prop.exp?.content || ''; - - logger.info(`Processing bind prop ${propName}: ${propContent}`); - - // inliningプロパティの処理を追加 - if (propName === 'inlining') { - try { - const content = propContent.trim(); - - // 配列式の場合 - if (content.startsWith('[') && content.endsWith(']')) { - // 配列要素を解析 - const elements = parseArrayExpression(content); - if (elements.length > 0) { - bindings.inlining = elements; - logger.info(`Parsed inlining array: ${JSON5.stringify(elements)}`); - } else { - bindings.inlining = []; - } - } - // 文字列の場合は配列に変換 - else if (content) { - bindings.inlining = [content]; // 単一の値を配列に - logger.info(`Converting inlining to array: [${content}]`); - } - } catch (e) { - logger.error(`Failed to parse inlining binding: ${propContent}`, e); - } - } - // keywordsの特殊処理 - if (propName === 'keywords') { - try { - const content = propContent.trim(); - - // 配列式の場合 - if (content.startsWith('[') && content.endsWith(']')) { - // i18n参照や特殊な式を保持するため、各要素を個別に解析 - const elements = parseArrayExpression(content); - if (elements.length > 0) { - bindings.keywords = elements; - logger.info(`Parsed keywords array: ${JSON5.stringify(elements)}`); - } else { - bindings.keywords = []; - logger.info('Empty keywords array'); - } - } - // その他の式(非配列) - else if (content) { - bindings.keywords = content; // 式をそのまま保持 - logger.info(`Keeping keywords as expression: ${content}`); - } else { - bindings.keywords = []; - logger.info('No keywords provided'); - } - } catch (e) { - logger.error(`Failed to parse keywords binding: ${propContent}`, e); - // エラーが起きても何らかの値を設定 - bindings.keywords = propContent || []; - } - } - // その他のプロパティ - else if (propName === 'label') { - // ラベルの場合も式として保持 - bindings[propName] = propContent; - logger.info(`Set label from bind expression: ${propContent}`); - } - else { - bindings[propName] = propContent; - } - } - } - - return bindings; +/** + * expr を実行します。 + * i18n はそのアクセスを保持するために propertyAccessProxy を使用しています。 + */ +function evalExpression(expr: string): unknown { + const rarResult = Function('i18n', `return ${expr}`)(i18nProxy); + // JSON.stringify を一回通すことで、 AccessProxy を文字列に変換する + // Walk してもいいんだけど横着してJSON.stringifyしてる。ビルド時にしか通らないのであんまりパフォーマンス気にする必要ないんで + return JSON.parse(JSON.stringify(rarResult)); } -// 配列式をパースする補助関数(文字列リテラル処理を改善) -function parseArrayExpression(expr: string): any[] { +const propertyAccessProxySymbol = Symbol('propertyAccessProxySymbol'); + +type AccessProxy = { + [propertyAccessProxySymbol]: string[], + [k: string]: AccessProxy, +} + +const propertyAccessProxyHandler: ProxyHandler = { + get(target: AccessProxy, p: string | symbol): any { + if (p in target) { + return (target as any)[p]; + } + if (p == "toJSON" || p == Symbol.toPrimitive) { + return propertyAccessProxyToJSON; + } + if (typeof p == 'string') { + return target[p] = propertyAccessProxy([...target[propertyAccessProxySymbol], p]); + } + return undefined; + } +} + +function propertyAccessProxyToJSON(this: AccessProxy, hint: string) { + const expression = this[propertyAccessProxySymbol].reduce((prev, current) => { + if (current.match(/^[a-z][0-9a-z]*$/i)) { + return `${prev}.${current}`; + } else { + return `${prev}['${current}']`; + } + }); + return '$\{' + expression + '}'; +} + +/** + * プロパティのアクセスを保持するための Proxy オブジェクトを作成します。 + * + * この関数で生成した proxy は JSON でシリアライズするか、`${}`のように string にすると、 ${property.path} のような形になる。 + * @param path + */ +function propertyAccessProxy(path: string[]): AccessProxy { + const target: AccessProxy = { + [propertyAccessProxySymbol]: path, + }; + return new Proxy(target, propertyAccessProxyHandler); +} + +const i18nProxy = propertyAccessProxy(['i18n']); + +export function collectFileMarkers(id: string, code: string): SearchIndexItem[] { try { - // 単純なケースはJSON5でパースを試みる - return JSON5.parse(expr.replace(/'/g, '"')); - } catch (e) { - // 複雑なケース(i18n.ts.xxx などの式を含む場合)は手動パース - logger.info(`Complex array expression, trying manual parsing: ${expr}`); + const { descriptor, errors } = vueSfcParse(code, { + filename: id, + }); - // "["と"]"を取り除く - const content = expr.substring(1, expr.length - 1).trim(); - if (!content) return []; - - const result: any[] = []; - let currentItem = ''; - let depth = 0; - let inString = false; - let stringChar = ''; - - // カンマで区切る(ただし文字列内や入れ子の配列内のカンマは無視) - for (let i = 0; i < content.length; i++) { - const char = content[i]; - - if (inString) { - if (char === stringChar && content[i - 1] !== '\\') { - inString = false; - } - currentItem += char; - } else if (char === '"' || char === "'") { - inString = true; - stringChar = char; - currentItem += char; - } else if (char === '[') { - depth++; - currentItem += char; - } else if (char === ']') { - depth--; - currentItem += char; - } else if (char === ',' && depth === 0) { - // 項目の区切りを検出 - const trimmed = currentItem.trim(); - - // 純粋な文字列リテラルの場合、実際の値に変換 - if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"'))) { - try { - result.push(JSON5.parse(trimmed)); - } catch (err) { - result.push(trimmed); - } - } else { - // それ以外の式はそのまま(i18n.ts.xxx など) - result.push(trimmed); - } - - currentItem = ''; - } else { - currentItem += char; - } + if (errors.length > 0) { + logger.error(`Compile Error: ${id}, ${errors}`); + return []; // エラーが発生したファイルはスキップ } - // 最後の項目を処理 - if (currentItem.trim()) { - const trimmed = currentItem.trim(); - - // 純粋な文字列リテラルの場合、実際の値に変換 - if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"'))) { - try { - result.push(JSON5.parse(trimmed)); - } catch (err) { - result.push(trimmed); - } - } else { - // それ以外の式はそのまま(i18n.ts.xxx など) - result.push(trimmed); - } - } - - logger.info(`Parsed complex array expression: ${expr} -> ${JSON.stringify(result)}`); - return result; - } -} - -export async function analyzeVueProps(options: Options & { - transformedCodeCache: Record, -}): Promise { - initLogger(options); - - const allMarkers: SearchIndexItem[] = []; - - // 対象ファイルパスを glob で展開 - const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => { - const matchedFiles = glob.sync(filePathPattern); - return [...acc, ...matchedFiles]; - }, []); - - logger.info(`Found ${filePaths.length} matching files to analyze`); - - for (const filePath of filePaths) { - const absolutePath = path.join(process.cwd(), filePath); - const id = absolutePath.replace(/\\/g, '/'); // 絶対パスに変換 - const code = options.transformedCodeCache[id]; // options 経由でキャッシュ参照 - if (!code) { // キャッシュミスの場合 - logger.error(`Error: No cached code found for: ${id}.`); // エラーログ - throw new Error(`No cached code found for: ${id}.`); // エラーを投げる - } - - try { - const { descriptor, errors } = vueSfcParse(options.transformedCodeCache[id], { - filename: filePath, - }); - - if (errors.length > 0) { - logger.error(`Compile Error: ${filePath}, ${errors}`); - continue; // エラーが発生したファイルはスキップ - } - - const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); - - if (fileMarkers && fileMarkers.length > 0) { - allMarkers.push(...fileMarkers); // すべてのマーカーを収集 - logger.info(`Successfully extracted ${fileMarkers.length} markers from ${filePath}`); - } else { - logger.info(`No markers found in ${filePath}`); - } - } catch (error) { - logger.error(`Error analyzing file ${filePath}:`, error); - } + return extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); + } catch (error) { + logger.error(`Error analyzing file ${id}:`, error); } - // 収集したすべてのマーカー情報を使用 - const analysisResult: AnalysisResult[] = [ - { - filePath: "combined-markers", // すべてのファイルのマーカーを1つのエントリとして扱う - usage: allMarkers, - } - ]; - - outputAnalysisResultAsTS(options.exportFilePath, analysisResult); // すべてのマーカー情報を渡す + return []; } -interface MarkerRelation { - parentId?: string; - markerId: string; - node: VueAstNode; -} +// endregion -async function processVueFile( +type TransformedCode = { code: string, - id: string, - options: Options, - transformedCodeCache: Record -): Promise<{ - code: string, - map: any, - transformedCodeCache: Record -}> { - const normalizedId = id.replace(/\\/g, '/'); // ファイルパスを正規化 + map: SourceMap, +}; - // 開発モード時はコード内容に変更があれば常に再処理する - // コード内容が同じ場合のみキャッシュを使用 - const isDevMode = process.env.NODE_ENV === 'development'; +export class MarkerIdAssigner { + // key: file id + private cache: Map; - const s = new MagicString(code); // magic-string のインスタンスを作成 - - if (!isDevMode && transformedCodeCache[normalizedId] && transformedCodeCache[normalizedId].includes('markerId=')) { - logger.info(`Using cached version for ${id}`); - return { - code: transformedCodeCache[normalizedId], - map: s.generateMap({ source: id, includeContent: true }), - transformedCodeCache - }; + constructor() { + this.cache = new Map(); } - // すでに処理済みのファイルでコードに変更がない場合はキャッシュを返す - if (transformedCodeCache[normalizedId] === code) { - logger.info(`Code unchanged for ${id}, using cached version`); - return { - code: transformedCodeCache[normalizedId], - map: s.generateMap({ source: id, includeContent: true }), - transformedCodeCache - }; + public onInvalidate(id: string) { + this.cache.delete(id); } - const parsed = vueSfcParse(code, { filename: id }); - if (!parsed.descriptor.template) { - return { - code, - map: s.generateMap({ source: id, includeContent: true }), - transformedCodeCache - }; + public processFile(id: string, code: string): TransformedCode { + // try cache first + if (this.cache.has(id)) { + return this.cache.get(id)!; + } + const transformed = this.#processImpl(id, code); + this.cache.set(id, transformed); + return transformed; } - const ast = parsed.descriptor.template.ast; // テンプレート AST を取得 - const markerRelations: MarkerRelation[] = []; // MarkerRelation 配列を初期化 - if (ast) { - function traverse(node: any, currentParent?: any) { - if (node.type === 1 && node.tag === 'SearchMarker') { - // 行番号はコード先頭からの改行数で取得 - const lineNumber = code.slice(0, node.loc.start.offset).split('\n').length; + #processImpl(id: string, code: string): TransformedCode { + const s = new MagicString(code); // magic-string のインスタンスを作成 + + const parsed = vueSfcParse(code, { filename: id }); + if (!parsed.descriptor.template) { + return { + code, + map: s.generateMap({ source: id, includeContent: true }), + }; + } + const ast = parsed.descriptor.template.ast; // テンプレート AST を取得 + const markerRelations: MarkerRelation[] = []; // MarkerRelation 配列を初期化 + + if (!ast) { + return { + code: s.toString(), // 変更後のコードを返す + map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) + }; + } + + walkVueElements([ast], null, (node, parentId) => { + if (node.tag !== 'SearchMarker') return; + + const markerIdProp = findAttribute(node.props, 'markerId'); + + let nodeMarkerId: string; + if (markerIdProp != null) { + if (markerIdProp.type !== NodeTypes.ATTRIBUTE) return logger.error(`markerId must be a attribute at ${id}:${markerIdProp.loc.start.line}`); + if (markerIdProp.value == null) return logger.error(`markerId must have a value at ${id}:${markerIdProp.loc.start.line}`); + nodeMarkerId = markerIdProp.value.content; + } else { // ファイルパスと行番号からハッシュ値を生成 // この際実行環境で差が出ないようにファイルパスを正規化 const idKey = id.replace(/\\/g, '/').split('packages/frontend/')[1] - const generatedMarkerId = toBase62(hash(`${idKey}:${lineNumber}`)); + const generatedMarkerId = toBase62(hash(`${idKey}:${node.loc.start.line}`)); - const props = node.props || []; - const hasMarkerIdProp = props.some((prop: any) => prop.type === 6 && prop.name === 'markerId'); - const nodeMarkerId = hasMarkerIdProp - ? props.find((prop: any) => prop.type === 6 && prop.name === 'markerId')?.value?.content as string - : generatedMarkerId; - node.__markerId = nodeMarkerId; + // markerId attribute を追加 + const endOfStartTag = findEndOfStartTagAttributes(node); + s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" data-in-app-search-marker-id="${generatedMarkerId}"`); - // 子マーカーの場合、親ノードに __children を設定しておく - if (currentParent && currentParent.type === 1 && currentParent.tag === 'SearchMarker') { - currentParent.__children = currentParent.__children || []; - currentParent.__children.push(nodeMarkerId); - } - - const parentMarkerId = currentParent && currentParent.__markerId; - markerRelations.push({ - parentId: parentMarkerId, - markerId: nodeMarkerId, - node: node, - }); - - if (!hasMarkerIdProp) { - const nodeStart = node.loc.start.offset; - let endOfStartTag; - - if (node.children && node.children.length > 0) { - // 子要素がある場合、最初の子要素の開始位置を基準にする - endOfStartTag = code.lastIndexOf('>', node.children[0].loc.start.offset); - } else if (node.loc.end.offset > nodeStart) { - // 子要素がない場合、自身の終了位置から逆算 - const nodeSource = code.substring(nodeStart, node.loc.end.offset); - // 自己終了タグか通常の終了タグかを判断 - if (nodeSource.includes('/>')) { - endOfStartTag = code.indexOf('/>', nodeStart) - 1; - } else { - endOfStartTag = code.indexOf('>', nodeStart); - } - } - - if (endOfStartTag !== undefined && endOfStartTag !== -1) { - // markerId が既に存在しないことを確認 - const tagText = code.substring(nodeStart, endOfStartTag + 1); - const markerIdRegex = /\s+markerId\s*=\s*["'][^"']*["']/; - - if (!markerIdRegex.test(tagText)) { - s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" data-in-app-search-marker-id="${generatedMarkerId}"`); - logger.info(`Adding markerId="${generatedMarkerId}" to ${id}:${lineNumber}`); - } else { - logger.info(`markerId already exists in ${id}:${lineNumber}`); - } - } - } + nodeMarkerId = generatedMarkerId; } - const newParent = node.type === 1 && node.tag === 'SearchMarker' ? node : currentParent; - if (node.children && Array.isArray(node.children)) { - node.children.forEach(child => traverse(child, newParent)); - } - } + markerRelations.push({ + parentId: parentId ?? undefined, + markerId: nodeMarkerId, + node: node, + }); - traverse(ast); // AST を traverse (1段階目: ID 生成と親子関係記録) + return nodeMarkerId; + }) // 2段階目: :children 属性の追加 // 最初に親マーカーごとに子マーカーIDを集約する処理を追加 @@ -1331,138 +567,102 @@ async function processVueFile( if (!parentChildrenMap.has(relation.parentId)) { parentChildrenMap.set(relation.parentId, []); } - parentChildrenMap.get(relation.parentId)?.push(relation.markerId); + parentChildrenMap.get(relation.parentId)!.push(relation.markerId); } }); // 2. 親ごとにまとめて :children 属性を処理 for (const [parentId, childIds] of parentChildrenMap.entries()) { const parentRelation = markerRelations.find(r => r.markerId === parentId); - if (!parentRelation || !parentRelation.node) continue; + if (!parentRelation) continue; const parentNode = parentRelation.node; - const childrenProp = parentNode.props?.find((prop: any) => prop.type === 7 && prop.name === 'bind' && prop.arg?.content === 'children'); + const childrenProp = findAttribute(parentNode.props, 'children'); + if (childrenProp != null) { + if (childrenProp.type !== NodeTypes.DIRECTIVE) { + console.error(`children prop should be directive (:children) at ${id}:${childrenProp.loc.start.line}`); + continue; + } - // 親ノードの開始位置を特定 - const parentNodeStart = parentNode.loc!.start.offset; - const endOfParentStartTag = parentNode.children && parentNode.children.length > 0 - ? code.lastIndexOf('>', parentNode.children[0].loc!.start.offset) - : code.indexOf('>', parentNodeStart); - - if (endOfParentStartTag === -1) continue; - - // 親タグのテキストを取得 - const parentTagText = code.substring(parentNodeStart, endOfParentStartTag + 1); - - if (childrenProp) { // AST で :children 属性が検出された場合、それを更新 - try { - const childrenStart = code.indexOf('[', childrenProp.exp!.loc.start.offset); - const childrenEnd = code.indexOf(']', childrenProp.exp!.loc.start.offset); - if (childrenStart !== -1 && childrenEnd !== -1) { - const childrenArrayStr = code.slice(childrenStart, childrenEnd + 1); - let childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"')); + const childrenValue = getStringArrayProp(childrenProp, id); + if (childrenValue == null) continue; - // 新しいIDを追加(重複は除外) - const newIds = childIds.filter(id => !childrenArray.includes(id)); - if (newIds.length > 0) { - childrenArray = [...childrenArray, ...newIds]; - const updatedChildrenArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'"); - s.overwrite(childrenStart, childrenEnd + 1, updatedChildrenArrayStr); - logger.info(`Added ${newIds.length} child markerIds to existing :children in ${id}`); - } + const newValue: string[] = [...childrenValue]; + for (const childId of [...childIds]) { + if (!newValue.includes(childId)) { + newValue.push(childId); } - } catch (e) { - logger.error('Error updating :children attribute:', e); } + + const expression = JSON.stringify(newValue).replaceAll(/"/g, "'"); + s.overwrite(childrenProp.exp!.loc.start.offset, childrenProp.exp!.loc.end.offset, expression); + logger.info(`Added ${childIds.length} child markerIds to existing :children in ${id}`); } else { - // AST では検出されなかった場合、タグテキストを調べる - const childrenRegex = /:children\s*=\s*["']\[(.*?)\]["']/; - const childrenMatch = parentTagText.match(childrenRegex); - - if (childrenMatch) { - // テキストから :children 属性値を解析して更新 - try { - const childrenContent = childrenMatch[1]; - const childrenArrayStr = `[${childrenContent}]`; - const childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"')); - - // 新しいIDを追加(重複は除外) - const newIds = childIds.filter(id => !childrenArray.includes(id)); - if (newIds.length > 0) { - childrenArray.push(...newIds); - - // :children="[...]" の位置を特定して上書き - const attrStart = parentTagText.indexOf(':children='); - if (attrStart > -1) { - const attrValueStart = parentTagText.indexOf('[', attrStart); - const attrValueEnd = parentTagText.indexOf(']', attrValueStart) + 1; - if (attrValueStart > -1 && attrValueEnd > -1) { - const absoluteStart = parentNodeStart + attrValueStart; - const absoluteEnd = parentNodeStart + attrValueEnd; - const updatedArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'"); - s.overwrite(absoluteStart, absoluteEnd, updatedArrayStr); - logger.info(`Updated existing :children in tag text for ${id}`); - } - } - } - } catch (e) { - logger.error('Error updating :children in tag text:', e); - } - } else { - // :children 属性がまだない場合、新規作成 - s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`); - logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`); - } + // :children 属性がまだない場合、新規作成 + const endOfParentStartTag = findEndOfStartTagAttributes(parentNode); + s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`); + logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`); } } + + return { + code: s.toString(), // 変更後のコードを返す + map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) + }; } - const transformedCode = s.toString(); // 変換後のコードを取得 - transformedCodeCache[normalizedId] = transformedCode; // 変換後のコードをキャッシュに保存 + async getOrLoad(id: string) { + // if there already exists a cache, return it + // note cahce will be invalidated on file change so the cache must be up to date + let code = this.getCached(id)?.code; + if (code != null) { + return code; + } - return { - code: transformedCode, // 変更後のコードを返す - map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) - transformedCodeCache // キャッシュも返す - }; -} + // if no cache found, read and parse the file + const originalCode = await fs.promises.readFile(id, 'utf-8'); -export async function generateSearchIndex(options: Options, transformedCodeCache: Record = {}) { - const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => { - const matchedFiles = glob.sync(filePathPattern); - return [...acc, ...matchedFiles]; - }, []); + // Other code may already parsed the file while we were waiting for the file to be read so re-check the cache + code = this.getCached(id)?.code; + if (code != null) { + return code; + } - for (const filePath of filePaths) { - const id = path.resolve(filePath); // 絶対パスに変換 - const code = fs.readFileSync(filePath, 'utf-8'); // ファイル内容を読み込む - const { transformedCodeCache: newCache } = await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す - transformedCodeCache = newCache; // キャッシュを更新 + // parse the file + code = this.processFile(id, originalCode)?.code; + return code; } - await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行 - - return transformedCodeCache; // キャッシュを返す + getCached(id: string) { + return this.cache.get(id); + } } // Rollup プラグインとして export -export default function pluginCreateSearchIndex(options: Options): Plugin { - let transformedCodeCache: Record = {}; // キャッシュオブジェクトをプラグインスコープで定義 - const isDevServer = process.env.NODE_ENV === 'development'; // 開発サーバーかどうか +export default function pluginCreateSearchIndex(options: Options): PluginOption { + const assigner = new MarkerIdAssigner(); + return [ + createSearchIndex(options, assigner), + pluginCreateSearchIndexVirtualModule(options, assigner), + ] +} +function createSearchIndex(options: Options, assigner: MarkerIdAssigner): Plugin { initLogger(options); // ロガーを初期化 + const root = normalizePath(process.cwd()); + + function isTargetFile(id: string): boolean { + const relativePath = path.posix.relative(root, id); + return options.targetFilePaths.some(pat => minimatch(relativePath, pat)) + } return { - name: 'createSearchIndex', + name: 'autoAssignMarkerId', enforce: 'pre', - async buildStart() { - if (!isDevServer) { - return; - } - - transformedCodeCache = await generateSearchIndex(options, transformedCodeCache); + watchChange(id) { + assigner.onInvalidate(id); }, async transform(code, id) { @@ -1470,52 +670,87 @@ export default function pluginCreateSearchIndex(options: Options): Plugin { return; } - // targetFilePaths にマッチするファイルのみ処理を行う - // glob パターンでマッチング - let isMatch = false; // isMatch の初期値を false に設定 - for (const pattern of options.targetFilePaths) { // パターンごとにマッチング確認 - const globbedFiles = glob.sync(pattern); - for (const globbedFile of globbedFiles) { - const normalizedGlobbedFile = path.resolve(globbedFile); // glob 結果を絶対パスに - const normalizedId = path.resolve(id); // id を絶対パスに - if (normalizedGlobbedFile === normalizedId) { // 絶対パス同士で比較 - isMatch = true; - break; // マッチしたらループを抜ける - } - } - if (isMatch) break; // いずれかのパターンでマッチしたら、outer loop も抜ける - } - - if (!isMatch) { + if (!isTargetFile(id)) { return; } - // ファイルの内容が変更された場合は再処理を行う - const normalizedId = id.replace(/\\/g, '/'); - const hasContentChanged = !transformedCodeCache[normalizedId] || transformedCodeCache[normalizedId] !== code; - - const transformed = await processVueFile(code, id, options, transformedCodeCache); - transformedCodeCache = transformed.transformedCodeCache; // キャッシュを更新 - - if (isDevServer && hasContentChanged) { - await analyzeVueProps({ ...options, transformedCodeCache }); // ファイルが変更されたときのみ分析を実行 - } - - return transformed; - }, - - async writeBundle() { - await analyzeVueProps({ ...options, transformedCodeCache }); // ビルド時にも analyzeVueProps を実行 + return assigner.processFile(id, code); }, }; } -// i18n参照を検出するためのヘルパー関数を追加 -function isI18nReference(text: string | null | undefined): boolean { - if (!text) return false; - // ドット記法(i18n.ts.something) - const dotPattern = /i18n\.ts\.\w+/; - // ブラケット記法(i18n.ts['something']) - const bracketPattern = /i18n\.ts\[['"][^'"]+['"]\]/; - return dotPattern.test(text) || bracketPattern.test(text); +export function pluginCreateSearchIndexVirtualModule(options: Options, asigner: MarkerIdAssigner): Plugin { + const searchIndexPrefix = options.fileVirtualModulePrefix ?? 'search-index-individual:'; + const searchIndexSuffix = options.fileVirtualModuleSuffix ?? '.ts'; + const allSearchIndexFile = options.mainVirtualModule; + const root = normalizePath(process.cwd()); + + function isTargetFile(id: string): boolean { + const relativePath = path.posix.relative(root, id); + return options.targetFilePaths.some(pat => minimatch(relativePath, pat)) + } + + function parseSearchIndexFileId(id: string): string | null { + const noQuery = id.split('?')[0]; + if (noQuery.startsWith(searchIndexPrefix) && noQuery.endsWith(searchIndexSuffix)) { + const filePath = id.slice(searchIndexPrefix.length).slice(0, -searchIndexSuffix.length); + if (isTargetFile(filePath)) { + return filePath; + } + } + return null; + } + + return { + name: 'generateSearchIndexVirtualModule', + // hotUpdate hook を vite:vue よりもあとに実行したいため enforce: post + enforce: 'post', + + async resolveId(id) { + if (id == allSearchIndexFile) { + return '\0' + allSearchIndexFile; + } + + const searchIndexFilePath = parseSearchIndexFileId(id); + if (searchIndexFilePath != null) { + return id; + } + return undefined; + }, + + async load(id) { + if (id == '\0' + allSearchIndexFile) { + const files = await Promise.all(options.targetFilePaths.map(async (filePathPattern) => await glob(filePathPattern))).then(paths => paths.flat()); + let generatedFile = ''; + let arrayElements = ''; + for (let file of files) { + const normalizedRelative = normalizePath(file); + const absoluteId = normalizePath(path.join(process.cwd(), normalizedRelative)) + searchIndexSuffix; + const variableName = normalizedRelative.replace(/[\/.-]/g, '_'); + generatedFile += `import { searchIndexes as ${variableName} } from '${searchIndexPrefix}${absoluteId}';\n`; + arrayElements += ` ...${variableName},\n`; + } + generatedFile += `export let searchIndexes = [\n${arrayElements}];\n`; + return generatedFile; + } + + const searchIndexFilePath = parseSearchIndexFileId(id); + if (searchIndexFilePath != null) { + // call load to update the index file when the file is changed + this.addWatchFile(searchIndexFilePath); + + const code = await asigner.getOrLoad(searchIndexFilePath); + return generateJavaScriptCode(collectFileMarkers(searchIndexFilePath, code)); + } + return null; + }, + + hotUpdate(this: { environment: { moduleGraph: EnvironmentModuleGraph } }, { file, modules }) { + if (isTargetFile(file)) { + const updateMods = options.modulesToHmrOnUpdate.map(id => this.environment.moduleGraph.getModuleById(path.posix.join(root, id))).filter(x => x != null); + return [...modules, ...updateMods]; + } + return modules; + } + }; } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index fe0abb173b..35d590dfe3 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -5,7 +5,6 @@ "scripts": { "watch": "vite", "build": "vite build", - "build-search-index": "vite-node --config \"./vite-node.config.ts\" \"./scripts/generate-search-index.ts\"", "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", "build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", "build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static", @@ -25,6 +24,7 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.2", "@rollup/pluginutils": "5.1.4", + "@sentry/vue": "9.12.0", "@syuilo/aiscript": "0.19.0", "@tabler/icons-webfont": "3.31.0", "@twemoji/parser": "15.1.1", @@ -33,7 +33,7 @@ "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", "analytics": "0.8.16", "astring": "1.9.0", - "broadcast-channel": "7.0.0", + "broadcast-channel": "7.1.0", "buraha": "0.0.1", "canvas-confetti": "1.9.3", "chart.js": "4.4.8", @@ -41,7 +41,7 @@ "chartjs-chart-matrix": "2.1.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.2.0", - "chromatic": "11.27.0", + "chromatic": "11.28.0", "compare-versions": "6.1.1", "cropperjs": "2.0.0", "date-fns": "4.1.0", @@ -60,18 +60,18 @@ "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", "punycode.js": "2.3.1", - "rollup": "4.36.0", + "rollup": "4.39.0", "sanitize-html": "2.15.0", - "sass": "1.86.0", - "shiki": "3.2.1", + "sass": "1.86.3", + "shiki": "3.2.2", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.174.0", + "three": "0.175.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.11", + "tsc-alias": "1.8.15", "tsconfig-paths": "4.2.0", - "typescript": "5.8.2", + "typescript": "5.8.3", "uuid": "11.1.0", "v-code-diff": "1.13.1", "vite": "6.2.4", @@ -81,65 +81,66 @@ }, "devDependencies": { "@misskey-dev/summaly": "5.2.0", - "@storybook/addon-actions": "8.6.7", - "@storybook/addon-essentials": "8.6.7", - "@storybook/addon-interactions": "8.6.7", - "@storybook/addon-links": "8.6.7", - "@storybook/addon-mdx-gfm": "8.6.7", - "@storybook/addon-storysource": "8.6.7", - "@storybook/blocks": "8.6.7", - "@storybook/components": "8.6.7", - "@storybook/core-events": "8.6.7", - "@storybook/manager-api": "8.6.7", - "@storybook/preview-api": "8.6.7", - "@storybook/react": "8.6.7", - "@storybook/react-vite": "8.6.7", - "@storybook/test": "8.6.7", - "@storybook/theming": "8.6.7", - "@storybook/types": "8.6.7", - "@storybook/vue3": "8.6.7", - "@storybook/vue3-vite": "8.6.7", + "@storybook/addon-actions": "8.6.12", + "@storybook/addon-essentials": "8.6.12", + "@storybook/addon-interactions": "8.6.12", + "@storybook/addon-links": "8.6.12", + "@storybook/addon-mdx-gfm": "8.6.12", + "@storybook/addon-storysource": "8.6.12", + "@storybook/blocks": "8.6.12", + "@storybook/components": "8.6.12", + "@storybook/core-events": "8.6.12", + "@storybook/manager-api": "8.6.12", + "@storybook/preview-api": "8.6.12", + "@storybook/react": "8.6.12", + "@storybook/react-vite": "8.6.12", + "@storybook/test": "8.6.12", + "@storybook/theming": "8.6.12", + "@storybook/types": "8.6.12", + "@storybook/vue3": "8.6.12", + "@storybook/vue3-vite": "8.6.12", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", - "@types/estree": "1.0.6", + "@types/estree": "1.0.7", "@types/matter-js": "0.19.8", "@types/micromatch": "4.0.9", - "@types/node": "22.13.11", + "@types/node": "22.14.0", "@types/punycode.js": "npm:@types/punycode@2.1.4", - "@types/sanitize-html": "2.13.0", + "@types/sanitize-html": "2.15.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@types/ws": "8.18.0", - "@typescript-eslint/eslint-plugin": "8.27.0", - "@typescript-eslint/parser": "8.27.0", - "@vitest/coverage-v8": "3.0.9", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", + "@vitest/coverage-v8": "3.1.1", + "@vue/compiler-core": "3.5.13", "@vue/runtime-core": "3.5.13", "acorn": "8.14.1", "cross-env": "7.0.3", - "cypress": "14.2.0", + "cypress": "14.3.0", "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "10.0.0", "fast-glob": "3.3.3", "happy-dom": "17.4.4", "intersection-observer": "0.12.2", "micromatch": "4.0.8", + "minimatch": "10.0.1", "msw": "2.7.3", "msw-storybook-addon": "2.0.4", "nodemon": "3.1.9", "prettier": "3.5.3", - "react": "19.0.0", - "react-dom": "19.0.0", + "react": "19.1.0", + "react-dom": "19.1.0", "seedrandom": "3.0.5", "start-server-and-test": "2.0.11", - "storybook": "8.6.7", + "storybook": "8.6.12", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", - "vite-node": "3.0.9", "vite-plugin-turbosnap": "1.0.3", - "vitest": "3.0.9", + "vitest": "3.1.1", "vitest-fetch-mock": "0.4.5", "vue-component-type-helpers": "2.2.8", - "vue-eslint-parser": "10.1.1", + "vue-eslint-parser": "10.1.3", "vue-tsc": "2.2.8" } } diff --git a/packages/frontend/scripts/generate-search-index.ts b/packages/frontend/scripts/generate-search-index.ts deleted file mode 100644 index cbb4bb8c51..0000000000 --- a/packages/frontend/scripts/generate-search-index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { searchIndexes } from '../vite.config.js'; -import { generateSearchIndex } from '../lib/vite-plugin-create-search-index.js'; - -async function main() { - for (const searchIndex of searchIndexes) { - await generateSearchIndex(searchIndex); - } -} - -main(); diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 7a88b938dd..c8098b6cf8 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -5,7 +5,7 @@ import { computed, watch, version as vueVersion } from 'vue'; import { compareVersions } from 'compare-versions'; -import { version, lang, updateLocale, locale } from '@@/js/config.js'; +import { version, lang, updateLocale, locale, apiUrl } from '@@/js/config.js'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-green-lime.json5'; import type { App } from 'vue'; @@ -291,6 +291,41 @@ export async function common(createVue: () => Promise>) { return root; })(); + if (instance.sentryForFrontend) { + const Sentry = await import('@sentry/vue'); + Sentry.init({ + app, + integrations: [ + ...(instance.sentryForFrontend.vueIntegration !== undefined ? [ + Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined), + ] : []), + ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [ + Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined), + ] : []), + ...(instance.sentryForFrontend.replayIntegration !== undefined ? [ + Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined), + ] : []), + ], + + // Set tracesSampleRate to 1.0 to capture 100% + tracesSampleRate: 1.0, + + // Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled + ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? { + tracePropagationTargets: [apiUrl], + } : {}), + + // Capture Replay for 10% of all sessions, + // plus for 100% of sessions with an error + ...(instance.sentryForFrontend.replayIntegration !== undefined ? { + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + } : {}), + + ...instance.sentryForFrontend.options, + }); + } + app.mount(rootEl); // boot.jsのやつを解除 diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index ac71618ee2..59099d54bd 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -39,6 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.localOnly }} {{ i18n.ts.caseSensitive }} {{ i18n.ts.withFileAntenna }} + {{ i18n.ts.excludeNotesInSensitiveChannel }}
@@ -53,6 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue index 061ce3f47d..ded1f9a28b 100644 --- a/packages/frontend/src/components/global/SearchMarker.vue +++ b/packages/frontend/src/components/global/SearchMarker.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -21,7 +21,7 @@ import { useTemplateRef, inject, } from 'vue'; -import type { Ref } from 'vue'; +import { DI } from '@/di.js'; const props = defineProps<{ markerId?: string; @@ -36,12 +36,13 @@ const rootEl = useTemplateRef('root'); const rootElMutationObserver = new MutationObserver(() => { checkChildren(); }); -const injectedSearchMarkerId = inject | null>('inAppSearchMarkerId', null); +const injectedSearchMarkerId = inject(DI.inAppSearchMarkerId, null); const searchMarkerId = computed(() => injectedSearchMarkerId?.value ?? window.location.hash.slice(1)); const highlighted = ref(props.markerId === searchMarkerId.value); +const isParentOfTarget = computed(() => props.children?.includes(searchMarkerId.value)); function checkChildren() { - if (props.children?.includes(searchMarkerId.value)) { + if (isParentOfTarget.value) { const el = window.document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`); highlighted.value = el == null; } @@ -105,8 +106,8 @@ onBeforeUnmount(dispose); @keyframes blink { 0%, 100% { - background: color(from var(--MI_THEME-accent) srgb r g b / 0.05); - border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.7); + background: color(from var(--MI_THEME-accent) srgb r g b / 0.1); + border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.75); } 50% { background: transparent; diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 6c6903c3a4..34cf598b84 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -30,6 +30,7 @@ import PageWithAnimBg from './global/PageWithAnimBg.vue'; import SearchMarker from './global/SearchMarker.vue'; import SearchLabel from './global/SearchLabel.vue'; import SearchKeyword from './global/SearchKeyword.vue'; +import SearchIcon from './global/SearchIcon.vue'; import type { App } from 'vue'; @@ -67,6 +68,7 @@ export const components = { SearchMarker: SearchMarker, SearchLabel: SearchLabel, SearchKeyword: SearchKeyword, + SearchIcon: SearchIcon, }; declare module '@vue/runtime-core' { @@ -98,5 +100,6 @@ declare module '@vue/runtime-core' { SearchMarker: typeof SearchMarker; SearchLabel: typeof SearchLabel; SearchKeyword: typeof SearchKeyword; + SearchIcon: typeof SearchIcon; } } diff --git a/packages/frontend/src/di.ts b/packages/frontend/src/di.ts index 541cdb76a8..58a2cce207 100644 --- a/packages/frontend/src/di.ts +++ b/packages/frontend/src/di.ts @@ -10,10 +10,12 @@ export const DI = { routerCurrentDepth: Symbol() as InjectionKey, router: Symbol() as InjectionKey, mock: Symbol() as InjectionKey, - pageMetadata: Symbol() as InjectionKey>>, + pageMetadata: Symbol() as InjectionKey | null>>, viewId: Symbol() as InjectionKey, currentStickyTop: Symbol() as InjectionKey>, currentStickyBottom: Symbol() as InjectionKey>, mfmEmojiReactCallback: Symbol() as InjectionKey<(emoji: string) => void>, inModal: Symbol() as InjectionKey, + inAppSearchMarkerId: Symbol() as InjectionKey>, + forceSpacerMin: Symbol() as InjectionKey, }; diff --git a/packages/frontend/src/lib/nirax.ts b/packages/frontend/src/lib/nirax.ts index a97803e879..a166df9eb0 100644 --- a/packages/frontend/src/lib/nirax.ts +++ b/packages/frontend/src/lib/nirax.ts @@ -266,18 +266,20 @@ export class Nirax extends EventEmitter { throw new Error('no route found for: ' + fullPath); } - if ('redirect' in res.route) { - let redirectPath: string; - if (typeof res.route.redirect === 'function') { - redirectPath = res.route.redirect(res.props); - } else { - redirectPath = res.route.redirect + (res._parsedRoute.queryString ? '?' + res._parsedRoute.queryString : '') + (res._parsedRoute.hash ? '#' + res._parsedRoute.hash : ''); + for (let current: PathResolvedResult | undefined = res; current; current = current.child) { + if ('redirect' in current.route) { + let redirectPath: string; + if (typeof current.route.redirect === 'function') { + redirectPath = current.route.redirect(current.props); + } else { + redirectPath = current.route.redirect + (current._parsedRoute.queryString ? '?' + current._parsedRoute.queryString : '') + (current._parsedRoute.hash ? '#' + current._parsedRoute.hash : ''); + } + if (_DEV_) console.log('Redirecting to: ', redirectPath); + if (_redirected && this.redirectCount++ > 10) { + throw new Error('redirect loop detected'); + } + return this.navigate(redirectPath, emitChange, true); } - if (_DEV_) console.log('Redirecting to: ', redirectPath); - if (_redirected && this.redirectCount++ > 10) { - throw new Error('redirect loop detected'); - } - return this.navigate(redirectPath, emitChange, true); } if (res.route.loginRequired && !this.isLoggedIn) { diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 185d9e81b7..c0fe0f2b85 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -114,6 +114,7 @@ export const navbarItemDef = reactive({ title: i18n.ts.chat, icon: 'ti ti-messages', to: '/chat', + show: computed(() => $i != null && $i.policies.chatAvailability !== 'unavailable'), indicated: computed(() => $i != null && $i.hasUnreadChatMessages), }, achievements: { diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 63298d9407..813b49635d 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -547,12 +547,13 @@ export function success(): Promise { }); } -export function waiting(): Promise { +export function waiting(text?: string | null): Promise { return new Promise(resolve => { const showing = ref(true); const { dispose } = popup(MkWaitingDialog, { success: false, showing: showing, + text, }, { done: () => resolve(), closed: () => dispose(), diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index cab00d98f6..9d92ccda60 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -662,7 +662,7 @@ definePage(() => ({ .roleItemSub { padding: 6px 12px; font-size: 85%; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } .roleUnassign { diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue index 0af56e1b15..529d438c4a 100644 --- a/packages/frontend/src/pages/admin/_header_.vue +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -66,7 +66,7 @@ const emit = defineEmits<{ (ev: 'update:tab', key: string); }>(); -const pageMetadata = inject(DI.pageMetadata); +const pageMetadata = inject(DI.pageMetadata, ref(null)); const el = useTemplateRef('el'); const tabHighlightEl = useTemplateRef('tabHighlightEl'); diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 672c976d2c..8d03838a8f 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -341,7 +341,7 @@ defineExpose({ box-sizing: border-box; border-right: solid 0.5px var(--MI_THEME-divider); overflow: auto; - height: 100dvh; + height: 100cqh; } > .main { diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index d1e823215a..930a63f5a9 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -52,6 +52,11 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + + + @@ -160,21 +165,24 @@ SPDX-License-Identifier: AGPL-3.0-only
- - + +
- + - + - - + + + + +
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 631873a076..a978927471 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -184,7 +184,7 @@ definePage(() => ({ .userItemSub { padding: 6px 12px; font-size: 85%; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } .userItemMainBody { diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index df4efd1271..7c950957cf 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -51,12 +51,15 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - - + + + + - + + + + @@ -295,6 +298,7 @@ import MkInput from '@/components/MkInput.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; +import MkSelect from '@/components/MkSelect.vue'; import MkRange from '@/components/MkRange.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index c019bda32c..6362ebd446 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -400,6 +400,6 @@ definePage(() => ({ diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue index 45f0fff107..b53667e98c 100644 --- a/packages/frontend/src/pages/admin/system-webhook.item.vue +++ b/packages/frontend/src/pages/admin/system-webhook.item.vue @@ -75,6 +75,6 @@ function onDeleteClick() { margin-right: 0.75em; flex-shrink: 0; text-align: center; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index 33741b1845..def6ec7d14 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -5,33 +5,28 @@ SPDX-License-Identifier: AGPL-3.0-only