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..bbdc881161 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.ref, 'refs/heads/dependabot/') != true && + startsWith(github.ref, 'refs/heads/renovate/') != true runs-on: ubuntu-latest env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 856dbbf804..ecdaa41224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,16 @@ - 過去自分が送ったメッセージ・自分に送られたメッセージの検索が可能です - 参加中のルームをミュートして通知が来ないように設定可能です - メッセージにはリアクションも可能です +- Feat: アカウントのマイグレーション時に古いアカウントからロールをコピーできるようになりました。 + - 管理者がロールの設定でマイグレーション時にコピーするかを指定できるようになります。 - Enhance: セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。 - Misskeyネイティブでダッシュボードを実装予定です +- Enhance: フロントエンドのエラートラッキングができるように + - `.config/default.yml`中の項目`sentryForFrontend`を適宜設定してください。 + - 外部サービスであるSentryへエラー情報が送信されます。ご利用の地域の法令に従い、適切なプライバシーポリシーを策定の上で運用してください。 - Enhance: ミュートしているユーザーをユーザー検索の結果から除外するように -- Enhance: 連合先のソフトウェア及びバージョン名により配信停止を行えるようになりました +- Enhance: アンテナでセンシティブなチャンネルのノートを除外できるように `#14177` +- Enhance: 連合先のソフトウェア及びバージョン名により配信停止を行えるように `#15727` - Fix: 通知のページネーションで2つ以上読み込めなくなることがある問題を修正 ### Client @@ -36,6 +42,7 @@ - 再度ログインすればサーバーのバックアップから設定データを復元可能です - エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました - 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です + - 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください - Feat: 画面を重ねて表示するオプションを実装(実験的) - 設定 → その他 → 実験的機能 → Enable stacking router view - Enhance: プラグインの管理が強化されました @@ -66,6 +73,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..9a1733128b 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" +hideNotesInSensitiveChannel: "Amaga les 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 " @@ -1339,6 +1340,8 @@ 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)" _chat: noMessagesYet: "Encara no tens missatges " newMessage: "Missatge nou" @@ -1413,6 +1416,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 +1889,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" @@ -2122,7 +2128,6 @@ _theme: 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 +2150,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" 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..1676fb16b8 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" +hideNotesInSensitiveChannel: "Verstecke Notizen von sensitive Kanäle" 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,21 @@ 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" _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" @@ -1384,8 +1394,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 +1405,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 +1443,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 +1455,7 @@ _abuseUserReport: _delivery: status: "Auslieferungsstatus" stop: "Gesperrt" + resume: "Zustellung wieder fortsetzen" _type: none: "Wird veröffentlicht" manuallySuspended: "Manuell gesperrt" @@ -1519,6 +1537,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 +1858,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 +1888,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" @@ -2105,7 +2127,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 +2149,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 +2285,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 +2300,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 +2316,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 +2569,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 +2597,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 +2822,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 +2875,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 +2884,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 +2899,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..63fa07ec68 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" +hideNotesInSensitiveChannel: "Hide notes from sensitive channels" enableServiceworker: "Enable Push-Notifications for your Browser" antennaUsersDescription: "List one username per line" caseSensitive: "Case sensitive" @@ -1339,6 +1340,7 @@ compress: "Compress" right: "Right" bottom: "Bottom" top: "Top" +embed: "Embed" _chat: noMessagesYet: "No messages yet" newMessage: "New message" @@ -1413,6 +1415,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 +1888,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" @@ -2122,7 +2127,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 +2149,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 24a76ace23..62a061e8b3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1718,6 +1718,10 @@ export interface Locale extends ILocale { * ファイルが添付されたノートのみ */ "withFileAntenna": string; + /** + * センシティブなチャンネルのノートを非表示 + */ + "hideNotesInSensitiveChannel": string; /** * ブラウザへのプッシュ通知を有効にする */ @@ -5378,6 +5382,18 @@ export interface Locale extends ILocale { * 上 */ "top": string; + /** + * 埋め込み + */ + "embed": string; + /** + * 設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます) + */ + "settingsMigrating": string; + /** + * 読み取り専用 + */ + "readonly": string; "_chat": { /** * まだメッセージはありません @@ -5492,6 +5508,10 @@ export interface Locale extends ILocale { * このサーバー、またはこのアカウントでチャットは有効化されていません。 */ "chatNotAvailableForThisAccountOrServer": string; + /** + * このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。 + */ + "chatIsReadOnlyForThisAccountOrServer": string; /** * 相手のアカウントでチャット機能が使えない状態になっています。 */ @@ -7369,6 +7389,14 @@ export interface Locale extends ILocale { * 数値が大きいほどUI上で先頭に表示されます。 */ "descriptionOfDisplayOrder": string; + /** + * アサイン状態を移行先アカウントにも引き継ぐ + */ + "preserveAssignmentOnMoveAccount": string; + /** + * オンにすると、このロールが付与されたアカウントが移行された際に、移行先アカウントにもこのロールが引き継がれるようになります。 + */ + "preserveAssignmentOnMoveAccount_description": string; /** * モデレーターのメンバー編集を許可 */ @@ -7527,7 +7555,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..7215070334 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "Escludere i Bot" antennaKeywordsDescription: "Sparando con uno spazio indichi la condizione E (and). Separando con un a capo, indichi la condizione O (or)." notifyAntenna: "Invia notifiche delle nuove note" withFileAntenna: "Solo note con file in allegato" +hideNotesInSensitiveChannel: "Nascondere le Note dai canali espliciti" enableServiceworker: "Abilita ServiceWorker" antennaUsersDescription: "Elenca un nome utente per riga" caseSensitive: "Sensibile alla distinzione tra maiuscole e minuscole" @@ -522,7 +523,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 +606,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 +664,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 +767,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 +1193,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 +1235,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 +1263,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 +1337,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 +1411,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 +1888,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à" @@ -2118,14 +2127,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 +2149,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 +2597,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 +2916,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 f2a038839b..dc798d9b7a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -425,6 +425,7 @@ antennaExcludeBots: "Botアカウントを除外" antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります" notifyAntenna: "新しいノートを通知する" withFileAntenna: "ファイルが添付されたノートのみ" +hideNotesInSensitiveChannel: "センシティブなチャンネルのノートを非表示" enableServiceworker: "ブラウザへのプッシュ通知を有効にする" antennaUsersDescription: "ユーザー名を改行で区切って指定します" caseSensitive: "大文字小文字を区別する" @@ -1340,6 +1341,9 @@ compress: "圧縮" right: "右" bottom: "下" top: "上" +embed: "埋め込み" +settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)" +readonly: "読み取り専用" _chat: noMessagesYet: "まだメッセージはありません" @@ -1370,6 +1374,7 @@ _chat: muteThisRoom: "このルームをミュート" deleteRoom: "ルームを削除" chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。" + chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。" chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。" cannotChatWithTheUser: "このユーザーとのチャットを開始できません" cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。" @@ -1909,6 +1914,8 @@ _role: descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。" displayOrder: "表示順" descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" + preserveAssignmentOnMoveAccount: "アサイン状態を移行先アカウントにも引き継ぐ" + preserveAssignmentOnMoveAccount_description: "オンにすると、このロールが付与されたアカウントが移行された際に、移行先アカウントにもこのロールが引き継がれるようになります。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" priority: "優先度" @@ -1949,7 +1956,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..4204c40309 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -301,6 +301,7 @@ uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 explore: "둘러보기" messageRead: "읽음" noMoreHistory: "이것보다 과거의 기록이 없습니다" +startChat: "채팅을 시작하기" nUsersRead: "{n}명이 읽음" agreeTo: "{0}에 동의" agree: "동의합니다" @@ -423,6 +424,7 @@ antennaExcludeBots: "봇 계정 제외" antennaKeywordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정됩니다" notifyAntenna: "새로운 노트를 알림" withFileAntenna: "파일이 첨부된 노트만" +hideNotesInSensitiveChannel: "민감한 채널의 노트 숨기기" enableServiceworker: "ServiceWorker 사용" antennaUsersDescription: "유저명을 한 줄에 한 명씩 적습니다" caseSensitive: "대소문자를 구분" @@ -1331,12 +1333,59 @@ emojiPalette: "이모지 팔레트" postForm: "글 입력란" textCount: "문자 수" information: "정보" +chat: "채팅" +migrateOldSettings: "기존 설정 정보를 이전" +migrateOldSettings_description: "보통은 자동으로 이루어지지만, 어떤 이유로 인해 성공적으로 이전이 이루어지지 않는 경우 수동으로 이전을 실행할 수 있습니다. 현재 설정 정보는 덮어쓰게 됩니다." +compress: "압축" +right: "오른쪽" +bottom: "아래" +top: "위" +embed: "임베드" _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: "이 서버 또는 이 계정에서 채팅이 활성화되어 있지 않습니다." + chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다." + cannotChatWithTheUser: "이 사용자와 채팅을 시작할 수 없습니다" + cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다." + chatWithThisUser: "채팅하기" + thisUserAllowsChatOnlyFromFollowers: "이 사용자는 팔로워만 채팅을 할 수 있습니다." + thisUserAllowsChatOnlyFromFollowing: "이 사용자는 이 사용자가 팔로우하는 사용자만 채팅을 허용합니다." + thisUserAllowsChatOnlyFromMutualFollowing: "이 사용자는 상호 팔로우하는 사용자만 채팅을 허용합니다." + thisUserNotAllowedChatAnyone: "이 사용자는 다른 사람의 채팅을 받지 않습니다." + chatAllowedUsers: "채팅을 허용한 상대" + chatAllowedUsers_note: "내가 채팅 메시지를 보낸 상대와는 이 설정과 상관없이 채팅이 가능합니다." + _chatAllowedUsers: + everyone: "누구나" + followers: "자신의 팔로워만" + following: "자신이 팔로우한 사용자만" + mutual: "상호 팔로우한 사용자만" + none: "아무도 허락하지 않기" _emojiPalette: palettes: "팔레트" enableSyncBetweenDevicesForPalettes: "팔레트의 디바이스 간 동기화를 활성화" @@ -1362,6 +1411,14 @@ _settings: timelineAndNote: "타임라인과 노트" makeEveryTextElementsSelectable: "모든 텍스트 요소를 선택할 수 있도록 함" makeEveryTextElementsSelectable_description: "활성화 시, 일부 동작에서 사용자의 접근성이 나빠질 수도 있습니다." + useStickyIcons: "아이콘이 스크롤을 따라가도록 하기" + showNavbarSubButtons: "내비게이션 바에 보조 버튼 표시" + ifOn: "켜져 있을 때" + ifOff: "꺼져 있을 때" + enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화" + _chat: + showSenderName: "발신자 이름 표시" + sendOnEnter: "엔터로 보내기" _preferencesProfile: profileName: "프로필 이름" profileNameDescription: "이 디바이스를 식별할 이름을 설정해 주세요." @@ -1831,6 +1888,8 @@ _role: descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다." displayOrder: "표시 순서" descriptionOfDisplayOrder: "값이 클 수록 UI에서 먼저 표시됩니다." + preserveAssignmentOnMoveAccount: "마이그레이션 대상 계정에도 할당 상태 전달" + preserveAssignmentOnMoveAccount_description: "켜면 이 역할이 부여된 계정이 마이그레이션될 때 마이그레이션 대상 계정에도 이 역할이 승계됩니다." canEditMembersByModerator: "모더레이터의 역할 수정 허용" descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다." priority: "우선순위" @@ -1871,6 +1930,7 @@ _role: canImportFollowing: "팔로우 가져오기 허용" canImportMuting: "뮤트 목록 가져오기 허용" canImportUserLists: "리스트 목록 가져오기 허용" + canChat: "채팅을 허락" _condition: roleAssignedTo: "수동 역할에 이미 할당됨" isLocal: "로컬 사용자" @@ -2067,7 +2127,6 @@ _theme: header: "헤더" navBg: "사이드바 배경" navFg: "사이드바 텍스트" - navHoverFg: "사이드바 텍스트 (호버)" navActive: "사이드바 텍스트 (활성)" navIndicator: "사이드바 인디케이터" link: "링크" @@ -2090,17 +2149,15 @@ _theme: buttonHoverBg: "버튼 배경 (호버)" inputBorder: "입력 필드 테두리" driveFolderBg: "드라이브 폴더 배경" - wallpaperOverlay: "배경화면 오버레이" badge: "배지" messageBg: "대화 배경" - accentDarken: "강조 색상 (어두움)" - accentLighten: "강조 색상 (밝음)" fgHighlighted: "강조된 텍스트" _sfx: note: "새 노트" noteMy: "내 노트" notification: "알림" reaction: "리액션 선택" + chatMessage: "채팅 메시지" _soundSettings: driveFile: "드라이브에 있는 오디오를 사용" driveFileWarn: "드라이브에 있는 파일을 선택하세요." @@ -2248,6 +2305,7 @@ _permissions: "read:federation": "연합 정보 불러오기" "write:report-abuse": "위반 내용 신고하기" "write:chat": "대화를 시작하거나 메시지를 보냅니다" + "read:chat": "채팅 열람하기" _auth: shareAccessTitle: "어플리케이션의 접근 허가" shareAccess: "‘{name}’에서 계정에 접근하는 것을 허용하시겠습니까?" @@ -2496,6 +2554,7 @@ _notification: newNote: "새 게시물" unreadAntennaNote: "안테나 {name}" roleAssigned: "역할이 부여 되었습니다." + chatRoomInvitationReceived: "채팅 룸에 초대받았습니다" emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다" achievementEarned: "도전 과제를 달성했습니다" testNotification: "알림 테스트" @@ -2524,6 +2583,7 @@ _notification: receiveFollowRequest: "팔로우 요청을 받았을 때" followRequestAccepted: "팔로우 요청이 승인되었을 때" roleAssigned: "역할이 부여 됨" + chatRoomInvitationReceived: "채팅 룸에 초대받았습니다" achievementEarned: "도전 과제 획득" exportCompleted: "추출을 성공함" login: "로그인" @@ -2537,6 +2597,9 @@ _notification: _deck: alwaysShowMainColumn: "메인 칼럼 항상 표시" columnAlign: "칼럼 정렬" + columnGap: "칼럼 간 여백" + deckMenuPosition: "덱 메뉴 위치" + navbarPosition: "내비게이션 바 위치" addColumn: "칼럼 추가" newNoteNotificationSettings: "새 노트 알림 설정" configureColumn: "칼럼 설정" @@ -2663,6 +2726,7 @@ _moderationLogTypes: deletePage: "페이지를 삭제" deleteFlash: "Play를 삭제" deleteGalleryPost: "갤러리 포스트를 삭제" + deleteChatRoom: "채팅 룸 삭제" updateProxyAccountDescription: "프록시 계정의 설명 업데이트" _fileViewer: title: "파일 상세" 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..57a78471db 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "排除机器人账户" antennaKeywordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" notifyAntenna: "开启通知" withFileAntenna: "仅带有附件的帖子" +hideNotesInSensitiveChannel: "隐藏敏感频道内的帖子" enableServiceworker: "启用 ServiceWorker" antennaUsersDescription: "指定用户名,一行一个" caseSensitive: "区分大小写" @@ -1339,6 +1340,7 @@ compress: "压缩" right: "右" bottom: "下" top: "上" +embed: "嵌入" _chat: noMessagesYet: "还没有消息" newMessage: "新消息" @@ -1885,6 +1887,8 @@ _role: descriptionOfIsExplorable: "打开后将公开角色时间线。如果角色不是公开的,就无法公开时间线。" displayOrder: "显示顺序" descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" + preserveAssignmentOnMoveAccount: "将分配状态继承到目标账户" + preserveAssignmentOnMoveAccount_description: "启用后,当迁移具有该角色的账户时,目标账户也会继承该角色。" canEditMembersByModerator: "允许监察员编辑成员" descriptionOfCanEditMembersByModerator: "如果选中,监察员和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" priority: "优先级" @@ -2122,7 +2126,6 @@ _theme: header: "顶栏" navBg: "侧边栏背景" navFg: "侧栏文本" - navHoverFg: "侧栏文本(悬停)" navActive: "侧栏文本(活动)" navIndicator: "侧栏标记" link: "链接" @@ -2145,11 +2148,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..4a57debf77 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "排除機器人帳戶" antennaKeywordsDescription: "空格代表「以及」(AND),換行代表「或者」(OR)" notifyAntenna: "通知有新貼文" withFileAntenna: "僅帶有附件的貼文" +hideNotesInSensitiveChannel: "隱藏敏感頻道的貼文" enableServiceworker: "啟用瀏覽器的推播通知" antennaUsersDescription: "填寫使用者名稱,以換行分隔" caseSensitive: "區分大小寫" @@ -1339,6 +1340,8 @@ compress: "壓縮" right: "右" bottom: "下" top: "上" +embed: "嵌入" +settingsMigrating: "正在移轉設定。請稍候……(之後也可以到「設定 → 其他 → 舊設定資訊移轉」中手動進行移轉)" _chat: noMessagesYet: "尚無訊息" newMessage: "新訊息" @@ -1413,6 +1416,7 @@ _settings: showNavbarSubButtons: "在導覽列顯示輔助按鈕" ifOn: "開啟時" ifOff: "關閉時" + enableSyncThemesBetweenDevices: "在裝置之間同步已安裝的主題" _chat: showSenderName: "顯示發送者的名稱" sendOnEnter: "按下 Enter 發送訊息" @@ -1885,6 +1889,8 @@ _role: descriptionOfIsExplorable: "若開啟則公開角色時間軸。若角色不是公開的,則無法公開時間軸。" displayOrder: "顯示順序" descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" + preserveAssignmentOnMoveAccount: "將指派狀態承接至轉移後的帳戶" + preserveAssignmentOnMoveAccount_description: "開啟此選項後,當具備此角色的帳戶被移轉時,該角色也會承接至轉移後的帳戶。" canEditMembersByModerator: "允許編輯審查員的成員" descriptionOfCanEditMembersByModerator: "如果開啟,管理員與審查員都可以為使用者指派/解除指派該角色。如果關閉,則只有管理員可以執行。" priority: "優先級" @@ -2122,7 +2128,6 @@ _theme: header: "標題" navBg: "側邊欄的背景 " navFg: "側邊欄的文字" - navHoverFg: "側邊欄文字(懸浮) " navActive: "側邊欄文字(活動)" navIndicator: "側邊欄指示符" link: "連結" @@ -2145,11 +2150,8 @@ _theme: buttonHoverBg: "按鈕背景 (漂浮)" inputBorder: "輸入框邊框" driveFolderBg: "雲端硬碟文件夾背景" - wallpaperOverlay: "壁紙覆蓋層" badge: "徽章" messageBg: "私訊背景" - accentDarken: "強調色(黑暗)" - accentLighten: "強調色(明亮)" fgHighlighted: "突顯文字" _sfx: note: "貼文" diff --git a/package.json b/package.json index 2611ddb695..8675742849 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.4.0-beta.1", + "version": "2025.4.0-rc.3", "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/package.json b/packages/backend/package.json index 4f81addf81..1f688d70d8 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -187,6 +187,7 @@ "devDependencies": { "@jest/globals": "29.7.0", "@nestjs/platform-express": "10.4.15", + "@sentry/vue": "9.8.0", "@simplewebauthn/types": "12.0.0", "@swc/jest": "0.2.37", "@types/accepts": "1.3.7", 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..5ad5bcf72a 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -114,6 +114,8 @@ export class AntennaService implements OnApplicationShutdown { if (note.visibility === 'specified') return false; if (note.visibility === 'followers') return false; + if (antenna.hideNotesInSensitiveChannel && note.channel?.isSensitive) return false; + if (antenna.excludeBots && noteUser.isBot) return false; if (antenna.localOnly && noteUser.host != null) return false; 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/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/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/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index e770028af3..e81c1e8db4 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, + hideNotesInSensitiveChannel: antenna.hideNotesInSensitiveChannel, 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/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..0d92c5cade 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 hideNotesInSensitiveChannel: 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/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts index b5b9a5b42c..2bdaca80d0 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, }, + hideNotesInSensitiveChannel: { + 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/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..9b34b52b16 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' }, + hideNotesInSensitiveChannel: { 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, + hideNotesInSensitiveChannel: ps.hideNotesInSensitiveChannel, }); 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..c5ddefebf7 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' }, + hideNotesInSensitiveChannel: { 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, + hideNotesInSensitiveChannel: ps.hideNotesInSensitiveChannel, 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/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..eb9583ee01 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -146,6 +146,7 @@ describe('アンテナ', () => { caseSensitive: false, createdAt: new Date(response.createdAt).toISOString(), excludeKeywords: [['']], + hideNotesInSensitiveChannel: false, hasUnreadNote: false, isActive: true, keywords: [['keyword']], @@ -217,6 +218,8 @@ describe('アンテナ', () => { { parameters: () => ({ withReplies: true }) }, { parameters: () => ({ withFile: false }) }, { parameters: () => ({ withFile: true }) }, + { parameters: () => ({ hideNotesInSensitiveChannel: false }) }, + { parameters: () => ({ hideNotesInSensitiveChannel: true }) }, ]; test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { const response = await successfulApiCall({ @@ -626,6 +629,42 @@ describe('アンテナ', () => { assert.deepStrictEqual(response, expected); }); + test('が取得できること(センシティブチャンネルのノートを除く)', async () => { + const keyword = 'キーワード'; + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[keyword]], hideNotesInSensitiveChannel: 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/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/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..a1093cacb5 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): string | null { + return extractElementTextChecked(node, node.tag); +} + +function extractElementTextChecked(node: ElementNode, processingNodeName: string): string | null { + const result: string[] = []; + for (const child of node.children) { + const text = extractElementText2Inner(child, processingNodeName); + if (text == null) return null; + result.push(text); + } + return result.join(''); +} + +function extractElementText2Inner(node: TemplateChildNode, processingNodeName: 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 ${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); + } else { + logger.error(`Unexpected ${node.tag} extracting text of ${processingNodeName} ${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} ${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[]): { 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 ${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); + return; + case 'SearchKeyword': + const content = extractElementText(node); + 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 ${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 ${node.loc.start.line}`); + return; } - } + + const iconNode = node.children[0]; + if (iconNode.type !== NodeTypes.ELEMENT) { + logger.error(`SearchIcon must have a child element at ${node.loc.start.line}`); + return; + } + icon = getStringProp(findAttribute(iconNode.props, 'class')); + 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): 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 line ${attr.loc.start.line}`); + return null; + } + return value; + } +} + +function getStringArrayProp(attr: AttributeNode | DirectiveNode | null): string[] | null { + switch (attr?.type) { + case null: + case undefined: + return null; + case NodeTypes.ATTRIBUTE: + logger.error(`Expected directive, got attribute at line ${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 line ${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')) + const icon = getStringProp(findAttribute(node.props, 'icon')) + const label = getStringProp(findAttribute(node.props, 'label')) + const inlining = getStringArrayProp(findAttribute(node.props, 'inlining')) + const keywords = getStringArrayProp(findAttribute(node.props, 'keywords')) + + 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); + 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); + 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(id, 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..156e6abea2 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.8.0", "@syuilo/aiscript": "0.19.0", "@tabler/icons-webfont": "3.31.0", "@twemoji/parser": "15.1.1", @@ -114,6 +114,7 @@ "@typescript-eslint/eslint-plugin": "8.27.0", "@typescript-eslint/parser": "8.27.0", "@vitest/coverage-v8": "3.0.9", + "@vue/compiler-core": "3.5.13", "@vue/runtime-core": "3.5.13", "acorn": "8.14.1", "cross-env": "7.0.3", @@ -124,6 +125,7 @@ "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", 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..e1c8200b73 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.hideNotesInSensitiveChannel }}
@@ -86,6 +87,7 @@ const initialAntenna = deepMerge(props.antenna ?? {}, { caseSensitive: false, localOnly: false, withFile: false, + hideNotesInSensitiveChannel: false, isActive: true, hasUnreadNote: false, notify: false, @@ -108,6 +110,7 @@ const localOnly = ref(initialAntenna.localOnly); const excludeBots = ref(initialAntenna.excludeBots); const withReplies = ref(initialAntenna.withReplies); const withFile = ref(initialAntenna.withFile); +const hideNotesInSensitiveChannel = ref(initialAntenna.hideNotesInSensitiveChannel); const userLists = ref(null); watch(() => src.value, async () => { @@ -124,6 +127,7 @@ async function saveAntenna() { excludeBots: excludeBots.value, withReplies: withReplies.value, withFile: withFile.value, + hideNotesInSensitiveChannel: hideNotesInSensitiveChannel.value, caseSensitive: caseSensitive.value, localOnly: localOnly.value, users: users.value.trim().split('\n').map(x => x.trim()), diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index 46fdf15b5d..bdb2ba6a44 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -140,7 +140,7 @@ watch(v, newValue => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index 50931cc318..80618ebfe4 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -60,7 +60,7 @@ const onInput = () => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 39ca39aad7..1993991106 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -181,11 +181,16 @@ onUnmounted(() => { left: 0; color: var(--MI_THEME-panelHeaderFg); background: var(--MI_THEME-panelHeaderBg); - border-bottom: solid 0.5px var(--MI_THEME-panelHeaderDivider); z-index: 2; line-height: 1.4em; } +@container style(--MI_THEME-panelHeaderBg: var(--MI_THEME-panel)) { + .header { + box-shadow: 0 0.5px 0 0 light-dark(#0002, #fff2); + } +} + .title { margin: 0; padding: 12px 16px; @@ -215,6 +220,14 @@ onUnmounted(() => { .content { --MI-stickyTop: 0px; + /* + 理屈は知らないけど、ここでbackgroundを設定しておかないと + スクロールコンテナーが少なくともChromeにおいて + main thread scrolling になってしまい、パフォーマンスが(多分)落ちる。 + backgroundが透明だと裏側を描画しないといけなくなるとかそういう理由かもしれない + */ + background: var(--MI_THEME-panel); + &.omitted { position: relative; max-height: var(--maxHeight); diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index ec6fcdc311..1cf6f0b744 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -3,16 +3,18 @@ SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> + + + + 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..2dfe242cf4 100644 --- a/packages/frontend/src/di.ts +++ b/packages/frontend/src/di.ts @@ -10,7 +10,7 @@ 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>, 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/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 fd8e76b2bd..f1a6e03c00 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -427,7 +427,7 @@ definePage(() => ({ 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