mirror of
https://github.com/misskey-dev/misskey
synced 2025-08-16 17:12:49 +02:00
Compare commits
43 commits
2025.8.0-a
...
develop
Author | SHA1 | Date | |
---|---|---|---|
|
9ea7340da6 | ||
|
60f7278aff | ||
|
bae92a944d | ||
|
7d30768769 | ||
|
e444942c4e | ||
|
90b9609341 | ||
|
c25a922928 | ||
|
d26169ea32 | ||
|
8839d8d679 | ||
|
ad6af74eef | ||
|
7bb43329bb | ||
|
4c41930554 | ||
|
295f42b986 | ||
|
299f9e3115 | ||
|
1d8e183883 | ||
|
f242892382 | ||
|
ecc033f101 | ||
|
684dbfd626 | ||
|
aa5c42997f | ||
|
e7b666f567 | ||
|
0f7c0ed053 | ||
|
1e92bb4a0a | ||
|
b5b7914073 | ||
|
7595bff43b | ||
|
72864fcbd0 | ||
|
1b0de39f92 | ||
|
d8a137cb6c | ||
|
ddac2fb7a1 | ||
|
b1b335d55a | ||
|
0586dd98cb | ||
|
504f886065 | ||
|
2931eb0aad | ||
|
103d5a4b44 | ||
|
785b85ee46 | ||
|
8bd84a0ec4 | ||
|
9539995458 | ||
|
e67ff36e57 | ||
|
96a165d729 | ||
|
215725a3ac | ||
|
3da04fcae4 | ||
|
85e3e49688 | ||
|
076a83466e | ||
|
aaf3f343ea |
116 changed files with 4581 additions and 3271 deletions
2
.github/workflows/dockle.yml
vendored
2
.github/workflows/dockle.yml
vendored
|
@ -25,7 +25,7 @@ jobs:
|
||||||
cp ./compose_example.yml ./compose.yml
|
cp ./compose_example.yml ./compose.yml
|
||||||
- run: |
|
- run: |
|
||||||
docker compose up -d web
|
docker compose up -d web
|
||||||
docker tag "$(docker compose images web | awk 'OFS=":" {print $4}' | tail -n +2)" misskey-web:latest
|
docker tag "$(docker compose images --format json web | jq -r '.[] | .ID')" misskey-web:latest
|
||||||
- run: |
|
- run: |
|
||||||
cmd="dockle --exit-code 1 misskey-web:latest ${image_name}"
|
cmd="dockle --exit-code 1 misskey-web:latest ${image_name}"
|
||||||
echo "> ${cmd}"
|
echo "> ${cmd}"
|
||||||
|
|
14
CHANGELOG.md
14
CHANGELOG.md
|
@ -13,10 +13,13 @@
|
||||||
- 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。
|
- 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。
|
||||||
- サーバーの初期設定が完了するまでは連合がオンにならないようになりました
|
- サーバーの初期設定が完了するまでは連合がオンにならないようになりました
|
||||||
- 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました
|
- 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました
|
||||||
|
- 実際の動作に即した名称になり、馴染みのない人でも理解しやすくなりました
|
||||||
|
- 他サービスにおける「ダイレクトメッセージ」に相当するMisskeyの機能は「チャット」ですが、「ダイレクト投稿」という名称の機能が存在するとそちらがダイレクトメッセージ機能であるような誤解を生んでいました
|
||||||
- mfm.jsをアップデートしました
|
- mfm.jsをアップデートしました
|
||||||
- Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応
|
- Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応
|
||||||
- Enhance: acctに `.` が入っているユーザーのメンションに対応
|
- Enhance: acctに `.` が入っているユーザーのメンションに対応
|
||||||
- Fix: Unicode絵文字に隣接する異体字セレクタ(`U+FE0F`)が絵文字として認識される問題を修正
|
- Fix: Unicode絵文字に隣接する異体字セレクタ(`U+FE0F`)が絵文字として認識される問題を修正
|
||||||
|
- Enhance: ユーザー検索をロールポリシーで制限できるように
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- Feat: AiScriptが1.0に更新されました
|
- Feat: AiScriptが1.0に更新されました
|
||||||
|
@ -30,17 +33,26 @@
|
||||||
- URLに`?safemode=true`を付ける
|
- URLに`?safemode=true`を付ける
|
||||||
- PWAのショートカットで Safemode を選択して起動する
|
- PWAのショートカットで Safemode を選択して起動する
|
||||||
- Feat: ページのタブバーを下部に表示できるように
|
- Feat: ページのタブバーを下部に表示できるように
|
||||||
|
- Enhance: 「自動でもっと見る」オプションが有効になり、安定性が向上しました
|
||||||
- Enhance: コントロールパネルを検索できるように
|
- Enhance: コントロールパネルを検索できるように
|
||||||
- Enhance: トルコ語 (tr-TR) に対応
|
- Enhance: トルコ語 (tr-TR) に対応
|
||||||
- Enhance: 言語別のスクリプトバンドルを生成するように
|
- Enhance: 不必要な翻訳データを読み込まなくなり、パフォーマンスが向上しました
|
||||||
|
- Enhance: 画像エフェクトのパラメータ名の多言語対応
|
||||||
|
- Enhance: 依存ソフトウェアの更新
|
||||||
- Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正
|
- Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正
|
||||||
- Fix: 一部の設定検索結果が存在しないパスになる問題を修正
|
- Fix: 一部の設定検索結果が存在しないパスになる問題を修正
|
||||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171)
|
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171)
|
||||||
- Fix: テーマエディタが動作しない問題を修正
|
- Fix: テーマエディタが動作しない問題を修正
|
||||||
|
- Fix: チャンネルのハイライトページにノートが表示されない問題を修正
|
||||||
|
- Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正
|
||||||
|
- Fix: 複数のメンションを1行に記述した場合に、サジェストが正しく表示されない問題を修正
|
||||||
|
- Fix: メンションとしての条件を満たしていても、特定の条件(`-`が含まれる場合など)で正しくサジェストされない問題を一部修正
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
- Enhance: ノートの削除処理の効率化
|
- Enhance: ノートの削除処理の効率化
|
||||||
- Enhance: 全体的なパフォーマンスの向上
|
- Enhance: 全体的なパフォーマンスの向上
|
||||||
|
- Enhance: 依存ソフトウェアの更新
|
||||||
|
- Fix: SystemWebhook設定でsecretを空に出来ない問題を修正
|
||||||
|
|
||||||
|
|
||||||
## 2025.7.0
|
## 2025.7.0
|
||||||
|
|
|
@ -1599,3 +1599,9 @@ _watermarkEditor:
|
||||||
type: "نوع"
|
type: "نوع"
|
||||||
image: "صور"
|
image: "صور"
|
||||||
advanced: "متقدم"
|
advanced: "متقدم"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
scale: "الحجم"
|
||||||
|
size: "الحجم"
|
||||||
|
color: "اللون"
|
||||||
|
opacity: "الشفافية"
|
||||||
|
|
|
@ -1357,3 +1357,10 @@ _watermarkEditor:
|
||||||
text: "লেখা"
|
text: "লেখা"
|
||||||
image: "ছবি"
|
image: "ছবি"
|
||||||
advanced: "উন্নত"
|
advanced: "উন্নত"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
scale: "আকার"
|
||||||
|
size: "আকার"
|
||||||
|
color: "রং"
|
||||||
|
opacity: "অস্বচ্ছতা"
|
||||||
|
lightness: "উজ্জ্বল করুন"
|
||||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Fent servir espais crearà expressions AND si l'ex
|
||||||
hiddenTags: "Etiquetes ocultes"
|
hiddenTags: "Etiquetes ocultes"
|
||||||
hiddenTagsDescription: "La visibilitat de totes les notes que continguin qualsevol de les paraules configurades seran, automàticament, afegides a \"Inici\". Pots llistar diferents paraules separant les per línies noves."
|
hiddenTagsDescription: "La visibilitat de totes les notes que continguin qualsevol de les paraules configurades seran, automàticament, afegides a \"Inici\". Pots llistar diferents paraules separant les per línies noves."
|
||||||
notesSearchNotAvailable: "La cerca de notes no es troba disponible."
|
notesSearchNotAvailable: "La cerca de notes no es troba disponible."
|
||||||
|
usersSearchNotAvailable: "La cerca d'usuaris no està disponible."
|
||||||
license: "Llicència"
|
license: "Llicència"
|
||||||
unfavoriteConfirm: "Esborrar dels favorits?"
|
unfavoriteConfirm: "Esborrar dels favorits?"
|
||||||
myClips: "Els meus retalls"
|
myClips: "Els meus retalls"
|
||||||
|
@ -1465,6 +1466,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description2: "Quan s'activa el mode en temps real, el contingut s'actualitza en temps real, independentment d'aquesta configuració."
|
contentsUpdateFrequency_description2: "Quan s'activa el mode en temps real, el contingut s'actualitza en temps real, independentment d'aquesta configuració."
|
||||||
showUrlPreview: "Mostrar vista prèvia d'URL"
|
showUrlPreview: "Mostrar vista prèvia d'URL"
|
||||||
showAvailableReactionsFirstInNote: "Mostra les reacciones que pots fer servir al damunt"
|
showAvailableReactionsFirstInNote: "Mostra les reacciones que pots fer servir al damunt"
|
||||||
|
showPageTabBarBottom: "Mostrar les pestanyes de les línies de temps a la part inferior"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "Mostrar el nom del remitent"
|
showSenderName: "Mostrar el nom del remitent"
|
||||||
sendOnEnter: "Introdueix per enviar"
|
sendOnEnter: "Introdueix per enviar"
|
||||||
|
@ -1998,6 +2000,7 @@ _role:
|
||||||
descriptionOfRateLimitFactor: "Límits baixos són menys restrictius, límits alts són més restrictius."
|
descriptionOfRateLimitFactor: "Límits baixos són menys restrictius, límits alts són més restrictius."
|
||||||
canHideAds: "Pot amagar la publicitat"
|
canHideAds: "Pot amagar la publicitat"
|
||||||
canSearchNotes: "Pot cercar notes"
|
canSearchNotes: "Pot cercar notes"
|
||||||
|
canSearchUsers: "Pot cercar usuaris"
|
||||||
canUseTranslator: "Pot fer servir el traductor"
|
canUseTranslator: "Pot fer servir el traductor"
|
||||||
avatarDecorationLimit: "Nombre màxim de decoracions que es poden aplicar els avatars"
|
avatarDecorationLimit: "Nombre màxim de decoracions que es poden aplicar els avatars"
|
||||||
canImportAntennas: "Autoritza la importació d'antenes "
|
canImportAntennas: "Autoritza la importació d'antenes "
|
||||||
|
@ -3163,10 +3166,10 @@ _watermarkEditor:
|
||||||
type: "Tipus"
|
type: "Tipus"
|
||||||
image: "Imatges"
|
image: "Imatges"
|
||||||
advanced: "Avançat"
|
advanced: "Avançat"
|
||||||
|
angle: "Angle"
|
||||||
stripe: "Bandes"
|
stripe: "Bandes"
|
||||||
stripeWidth: "Amplada de la banda"
|
stripeWidth: "Amplada de la banda"
|
||||||
stripeFrequency: "Freqüència de la banda"
|
stripeFrequency: "Freqüència de la banda"
|
||||||
angle: "Angle"
|
|
||||||
polkadot: "Lunars"
|
polkadot: "Lunars"
|
||||||
checker: "Escacs"
|
checker: "Escacs"
|
||||||
polkadotMainDotOpacity: "Opacitat del lunar principal"
|
polkadotMainDotOpacity: "Opacitat del lunar principal"
|
||||||
|
@ -3178,6 +3181,7 @@ _imageEffector:
|
||||||
title: "Efecte"
|
title: "Efecte"
|
||||||
addEffect: "Afegeix un efecte"
|
addEffect: "Afegeix un efecte"
|
||||||
discardChangesConfirm: "Vols descartar els canvis i sortir?"
|
discardChangesConfirm: "Vols descartar els canvis i sortir?"
|
||||||
|
nothingToConfigure: "No hi ha opcions de configuració disponibles"
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "Aberració cromàtica"
|
chromaticAberration: "Aberració cromàtica"
|
||||||
glitch: "Glitch"
|
glitch: "Glitch"
|
||||||
|
@ -3195,6 +3199,38 @@ _imageEffector:
|
||||||
checker: "Escacs"
|
checker: "Escacs"
|
||||||
blockNoise: "Bloqueig de soroll"
|
blockNoise: "Bloqueig de soroll"
|
||||||
tearing: "Trencament d'imatge "
|
tearing: "Trencament d'imatge "
|
||||||
|
_fxProps:
|
||||||
|
angle: "Angle"
|
||||||
|
scale: "Mida"
|
||||||
|
size: "Mida"
|
||||||
|
color: "Color"
|
||||||
|
opacity: "Opacitat"
|
||||||
|
normalize: "Normalitzar"
|
||||||
|
amount: "Quantitat"
|
||||||
|
lightness: "Brillantor"
|
||||||
|
contrast: "Contrast"
|
||||||
|
hue: "Tonalitat"
|
||||||
|
brightness: "Brillantor"
|
||||||
|
saturation: "Saturació"
|
||||||
|
max: "Màxim"
|
||||||
|
min: "Mínim"
|
||||||
|
direction: "Direcció "
|
||||||
|
phase: "Fase"
|
||||||
|
frequency: "Freqüència "
|
||||||
|
strength: "Intensitat"
|
||||||
|
glitchChannelShift: "Canvi de canal "
|
||||||
|
seed: "Llindar"
|
||||||
|
redComponent: "Component vermell"
|
||||||
|
greenComponent: "Component verd"
|
||||||
|
blueComponent: "Component blau"
|
||||||
|
threshold: "Llindar"
|
||||||
|
centerX: "Centre de X"
|
||||||
|
centerY: "Centre de Y"
|
||||||
|
zoomLinesSmoothing: "Suavitzat"
|
||||||
|
zoomLinesSmoothingDescription: "Els paràmetres de suavitzat i amplada de línia en augmentar no es poden fer servir junts."
|
||||||
|
zoomLinesThreshold: "Amplada de línia a l'augmentar "
|
||||||
|
zoomLinesMaskSize: "Diàmetre del centre"
|
||||||
|
zoomLinesBlack: "Obscurir"
|
||||||
drafts: "Esborrany "
|
drafts: "Esborrany "
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "Seleccionar esborrany"
|
select: "Seleccionar esborrany"
|
||||||
|
|
|
@ -2053,3 +2053,10 @@ _watermarkEditor:
|
||||||
type: "Typ"
|
type: "Typ"
|
||||||
image: "Obrázky"
|
image: "Obrázky"
|
||||||
advanced: "Pokročilé"
|
advanced: "Pokročilé"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
scale: "Velikost"
|
||||||
|
size: "Velikost"
|
||||||
|
color: "Barva"
|
||||||
|
opacity: "Průhlednost"
|
||||||
|
lightness: "Zesvětlit"
|
||||||
|
|
|
@ -3147,10 +3147,10 @@ _watermarkEditor:
|
||||||
type: "Art"
|
type: "Art"
|
||||||
image: "Bilder"
|
image: "Bilder"
|
||||||
advanced: "Fortgeschritten"
|
advanced: "Fortgeschritten"
|
||||||
|
angle: "Winkel"
|
||||||
stripe: "Streifen"
|
stripe: "Streifen"
|
||||||
stripeWidth: "Linienbreite"
|
stripeWidth: "Linienbreite"
|
||||||
stripeFrequency: "Linienanzahl"
|
stripeFrequency: "Linienanzahl"
|
||||||
angle: "Winkel"
|
|
||||||
polkadot: "Punktmuster"
|
polkadot: "Punktmuster"
|
||||||
polkadotMainDotOpacity: "Deckkraft des Hauptpunktes"
|
polkadotMainDotOpacity: "Deckkraft des Hauptpunktes"
|
||||||
polkadotMainDotRadius: "Größe des Hauptpunktes"
|
polkadotMainDotRadius: "Größe des Hauptpunktes"
|
||||||
|
@ -3173,6 +3173,13 @@ _imageEffector:
|
||||||
distort: "Verzerrung"
|
distort: "Verzerrung"
|
||||||
stripe: "Streifen"
|
stripe: "Streifen"
|
||||||
polkadot: "Punktmuster"
|
polkadot: "Punktmuster"
|
||||||
|
_fxProps:
|
||||||
|
angle: "Winkel"
|
||||||
|
scale: "Größe"
|
||||||
|
size: "Größe"
|
||||||
|
color: "Farbe"
|
||||||
|
opacity: "Transparenz"
|
||||||
|
lightness: "Erhellen"
|
||||||
drafts: "Entwurf"
|
drafts: "Entwurf"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "Entwurf auswählen"
|
select: "Entwurf auswählen"
|
||||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Using spaces will create AND expressions and surro
|
||||||
hiddenTags: "Hidden hashtags"
|
hiddenTags: "Hidden hashtags"
|
||||||
hiddenTagsDescription: "Select tags which will not shown on trend list.\nMultiple tags could be registered by lines."
|
hiddenTagsDescription: "Select tags which will not shown on trend list.\nMultiple tags could be registered by lines."
|
||||||
notesSearchNotAvailable: "Note search is unavailable."
|
notesSearchNotAvailable: "Note search is unavailable."
|
||||||
|
usersSearchNotAvailable: "User search is not available."
|
||||||
license: "License"
|
license: "License"
|
||||||
unfavoriteConfirm: "Really remove from favorites?"
|
unfavoriteConfirm: "Really remove from favorites?"
|
||||||
myClips: "My clips"
|
myClips: "My clips"
|
||||||
|
@ -1465,6 +1466,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description2: "When real-time mode is on, content is updated in real time regardless of this setting."
|
contentsUpdateFrequency_description2: "When real-time mode is on, content is updated in real time regardless of this setting."
|
||||||
showUrlPreview: "Show URL preview"
|
showUrlPreview: "Show URL preview"
|
||||||
showAvailableReactionsFirstInNote: "Show available reactions at the top."
|
showAvailableReactionsFirstInNote: "Show available reactions at the top."
|
||||||
|
showPageTabBarBottom: "Show page tab bar at the bottom"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "Show sender's name"
|
showSenderName: "Show sender's name"
|
||||||
sendOnEnter: "Press Enter to send"
|
sendOnEnter: "Press Enter to send"
|
||||||
|
@ -1998,19 +2000,20 @@ _role:
|
||||||
descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. "
|
descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. "
|
||||||
canHideAds: "Can hide ads"
|
canHideAds: "Can hide ads"
|
||||||
canSearchNotes: "Usage of note search"
|
canSearchNotes: "Usage of note search"
|
||||||
|
canSearchUsers: "User search"
|
||||||
canUseTranslator: "Translator usage"
|
canUseTranslator: "Translator usage"
|
||||||
avatarDecorationLimit: "Maximum number of avatar decorations that can be applied"
|
avatarDecorationLimit: "Maximum number of avatar decorations"
|
||||||
canImportAntennas: "Allow importing antennas"
|
canImportAntennas: "Can import antennas"
|
||||||
canImportBlocking: "Allow importing blocking"
|
canImportBlocking: "Can import blocking"
|
||||||
canImportFollowing: "Allow importing following"
|
canImportFollowing: "Can import following"
|
||||||
canImportMuting: "Allow importing muting"
|
canImportMuting: "Can import muting"
|
||||||
canImportUserLists: "Allow importing lists"
|
canImportUserLists: "Can import lists"
|
||||||
chatAvailability: "Allow Chat"
|
chatAvailability: "Chat"
|
||||||
uploadableFileTypes: "Uploadable file types"
|
uploadableFileTypes: "Uploadable file types"
|
||||||
uploadableFileTypes_caption: "Specifies the allowed MIME/file types. Multiple MIME types can be specified by separating them with a new line, and wildcards can be specified with an asterisk (*). (e.g., image/*)"
|
uploadableFileTypes_caption: "Specifies the allowed MIME/file types. Multiple MIME types can be specified by separating them with a new line, and wildcards can be specified with an asterisk (*). (e.g., image/*)"
|
||||||
uploadableFileTypes_caption2: "Some files types might fail to be detected. To allow such files, add {x} to the specification."
|
uploadableFileTypes_caption2: "Some files types might fail to be detected. To allow such files, add {x} to the specification."
|
||||||
noteDraftLimit: "Number of possible drafts of server notes"
|
noteDraftLimit: "Number of possible drafts of server notes"
|
||||||
watermarkAvailable: "Availability of watermark function"
|
watermarkAvailable: "Watermark function"
|
||||||
_condition:
|
_condition:
|
||||||
roleAssignedTo: "Assigned to manual roles"
|
roleAssignedTo: "Assigned to manual roles"
|
||||||
isLocal: "Local user"
|
isLocal: "Local user"
|
||||||
|
@ -3163,10 +3166,10 @@ _watermarkEditor:
|
||||||
type: "Type"
|
type: "Type"
|
||||||
image: "Images"
|
image: "Images"
|
||||||
advanced: "Advanced"
|
advanced: "Advanced"
|
||||||
|
angle: "Angle"
|
||||||
stripe: "Stripes"
|
stripe: "Stripes"
|
||||||
stripeWidth: "Line width"
|
stripeWidth: "Line width"
|
||||||
stripeFrequency: "Lines count"
|
stripeFrequency: "Lines count"
|
||||||
angle: "Angle"
|
|
||||||
polkadot: "Polkadot"
|
polkadot: "Polkadot"
|
||||||
checker: "Checker"
|
checker: "Checker"
|
||||||
polkadotMainDotOpacity: "Opacity of the main dot"
|
polkadotMainDotOpacity: "Opacity of the main dot"
|
||||||
|
@ -3178,6 +3181,7 @@ _imageEffector:
|
||||||
title: "Effects"
|
title: "Effects"
|
||||||
addEffect: "Add Effects"
|
addEffect: "Add Effects"
|
||||||
discardChangesConfirm: "Are you sure you want to leave? You have unsaved changes."
|
discardChangesConfirm: "Are you sure you want to leave? You have unsaved changes."
|
||||||
|
nothingToConfigure: "No configurable options available"
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "Chromatic Aberration"
|
chromaticAberration: "Chromatic Aberration"
|
||||||
glitch: "Glitch"
|
glitch: "Glitch"
|
||||||
|
@ -3195,6 +3199,38 @@ _imageEffector:
|
||||||
checker: "Checker"
|
checker: "Checker"
|
||||||
blockNoise: "Block Noise"
|
blockNoise: "Block Noise"
|
||||||
tearing: "Tearing"
|
tearing: "Tearing"
|
||||||
|
_fxProps:
|
||||||
|
angle: "Angle"
|
||||||
|
scale: "Size"
|
||||||
|
size: "Size"
|
||||||
|
color: "Color"
|
||||||
|
opacity: "Opacity"
|
||||||
|
normalize: "Normalize"
|
||||||
|
amount: "Amount"
|
||||||
|
lightness: "Lighten"
|
||||||
|
contrast: "Contrast"
|
||||||
|
hue: "Hue"
|
||||||
|
brightness: "Brightness"
|
||||||
|
saturation: "Saturation"
|
||||||
|
max: "Maximum"
|
||||||
|
min: "Minimum"
|
||||||
|
direction: "Direction"
|
||||||
|
phase: "Phase"
|
||||||
|
frequency: "Frequency"
|
||||||
|
strength: "Strength"
|
||||||
|
glitchChannelShift: "Channel shift"
|
||||||
|
seed: "Seed value"
|
||||||
|
redComponent: "Red component"
|
||||||
|
greenComponent: "Green component"
|
||||||
|
blueComponent: "Blue component"
|
||||||
|
threshold: "Threshold"
|
||||||
|
centerX: "Center X"
|
||||||
|
centerY: "Center Y"
|
||||||
|
zoomLinesSmoothing: "Smoothing"
|
||||||
|
zoomLinesSmoothingDescription: "Smoothing and zoom line width cannot be used together."
|
||||||
|
zoomLinesThreshold: "Zoom line width"
|
||||||
|
zoomLinesMaskSize: "Center diameter"
|
||||||
|
zoomLinesBlack: "Make black"
|
||||||
drafts: "Drafts"
|
drafts: "Drafts"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "Select Draft"
|
select: "Select Draft"
|
||||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Si se usan espacios se crearán expresiones AND y
|
||||||
hiddenTags: "Hashtags ocultos"
|
hiddenTags: "Hashtags ocultos"
|
||||||
hiddenTagsDescription: "Selecciona las etiquetas que no se mostrarán en tendencias. Una etiqueta por línea."
|
hiddenTagsDescription: "Selecciona las etiquetas que no se mostrarán en tendencias. Una etiqueta por línea."
|
||||||
notesSearchNotAvailable: "No se puede buscar una nota"
|
notesSearchNotAvailable: "No se puede buscar una nota"
|
||||||
|
usersSearchNotAvailable: "La búsqueda de usuarios no está disponible."
|
||||||
license: "Licencia"
|
license: "Licencia"
|
||||||
unfavoriteConfirm: "¿Desea quitar de favoritos?"
|
unfavoriteConfirm: "¿Desea quitar de favoritos?"
|
||||||
myClips: "Mis clips"
|
myClips: "Mis clips"
|
||||||
|
@ -1465,6 +1466,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description2: "Cuando el modo en tiempo real está activado, el contenido se actualiza en tiempo real independientemente de esta configuración."
|
contentsUpdateFrequency_description2: "Cuando el modo en tiempo real está activado, el contenido se actualiza en tiempo real independientemente de esta configuración."
|
||||||
showUrlPreview: "Mostrar la vista previa de la URL"
|
showUrlPreview: "Mostrar la vista previa de la URL"
|
||||||
showAvailableReactionsFirstInNote: "Mostrar las reacciones disponibles en la parte superior."
|
showAvailableReactionsFirstInNote: "Mostrar las reacciones disponibles en la parte superior."
|
||||||
|
showPageTabBarBottom: "Mostrar la barra de pestañas de la página en la parte inferior."
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "Mostrar el nombre del remitente"
|
showSenderName: "Mostrar el nombre del remitente"
|
||||||
sendOnEnter: "Intro para enviar"
|
sendOnEnter: "Intro para enviar"
|
||||||
|
@ -1998,6 +2000,7 @@ _role:
|
||||||
descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos"
|
descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos"
|
||||||
canHideAds: "Puede ocultar anuncios"
|
canHideAds: "Puede ocultar anuncios"
|
||||||
canSearchNotes: "Uso de la búsqueda de notas"
|
canSearchNotes: "Uso de la búsqueda de notas"
|
||||||
|
canSearchUsers: "Uso de la búsqueda de usuarios"
|
||||||
canUseTranslator: "Uso de traductor"
|
canUseTranslator: "Uso de traductor"
|
||||||
avatarDecorationLimit: "Número máximo de decoraciones de avatar"
|
avatarDecorationLimit: "Número máximo de decoraciones de avatar"
|
||||||
canImportAntennas: "Permitir la importación de antenas"
|
canImportAntennas: "Permitir la importación de antenas"
|
||||||
|
@ -3163,10 +3166,10 @@ _watermarkEditor:
|
||||||
type: "Tipo"
|
type: "Tipo"
|
||||||
image: "Imágenes"
|
image: "Imágenes"
|
||||||
advanced: "Avanzado"
|
advanced: "Avanzado"
|
||||||
|
angle: "Ángulo"
|
||||||
stripe: "Rayas"
|
stripe: "Rayas"
|
||||||
stripeWidth: "Anchura de línea"
|
stripeWidth: "Anchura de línea"
|
||||||
stripeFrequency: "Número de líneas."
|
stripeFrequency: "Número de líneas."
|
||||||
angle: "Ángulo"
|
|
||||||
polkadot: "Lunares"
|
polkadot: "Lunares"
|
||||||
checker: "verificador"
|
checker: "verificador"
|
||||||
polkadotMainDotOpacity: "Opacidad del círculo principal"
|
polkadotMainDotOpacity: "Opacidad del círculo principal"
|
||||||
|
@ -3178,6 +3181,7 @@ _imageEffector:
|
||||||
title: "Efecto"
|
title: "Efecto"
|
||||||
addEffect: "Añadir Efecto"
|
addEffect: "Añadir Efecto"
|
||||||
discardChangesConfirm: "¿Ignorar cambios y salir?"
|
discardChangesConfirm: "¿Ignorar cambios y salir?"
|
||||||
|
nothingToConfigure: "No hay opciones configurables disponibles."
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "Aberración Cromática"
|
chromaticAberration: "Aberración Cromática"
|
||||||
glitch: "Glitch"
|
glitch: "Glitch"
|
||||||
|
@ -3195,6 +3199,38 @@ _imageEffector:
|
||||||
checker: "Corrector"
|
checker: "Corrector"
|
||||||
blockNoise: "Bloquear Ruido"
|
blockNoise: "Bloquear Ruido"
|
||||||
tearing: "Rasgado de Imagen (Tearing)"
|
tearing: "Rasgado de Imagen (Tearing)"
|
||||||
|
_fxProps:
|
||||||
|
angle: "Ángulo"
|
||||||
|
scale: "Tamaño"
|
||||||
|
size: "Tamaño"
|
||||||
|
color: "Color"
|
||||||
|
opacity: "Opacidad"
|
||||||
|
normalize: "Normalización"
|
||||||
|
amount: "Cantidad"
|
||||||
|
lightness: "Brillo"
|
||||||
|
contrast: "Contraste"
|
||||||
|
hue: "Tonalidad"
|
||||||
|
brightness: "Brillo"
|
||||||
|
saturation: "Saturación"
|
||||||
|
max: "Valor máximo"
|
||||||
|
min: "Valor mínimo"
|
||||||
|
direction: "Dirección"
|
||||||
|
phase: "Fase"
|
||||||
|
frequency: "Frecuencia"
|
||||||
|
strength: "Intensidad"
|
||||||
|
glitchChannelShift: "cambio de canal de imagen"
|
||||||
|
seed: "Valor de la semilla"
|
||||||
|
redComponent: "Componente rojo"
|
||||||
|
greenComponent: "Componente Verde"
|
||||||
|
blueComponent: "Componente Azul"
|
||||||
|
threshold: "Umbral"
|
||||||
|
centerX: "Centrar X"
|
||||||
|
centerY: "Centrar Y"
|
||||||
|
zoomLinesSmoothing: "Suavizado"
|
||||||
|
zoomLinesSmoothingDescription: "El suavizado y el ancho de línea de zoom no se pueden utilizar juntos."
|
||||||
|
zoomLinesThreshold: "Ancho de línea del zoom"
|
||||||
|
zoomLinesMaskSize: "Diámetro del centro"
|
||||||
|
zoomLinesBlack: "Hacer oscuro"
|
||||||
drafts: "Borrador"
|
drafts: "Borrador"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "Seleccionar borradores"
|
select: "Seleccionar borradores"
|
||||||
|
|
|
@ -2372,3 +2372,11 @@ _watermarkEditor:
|
||||||
image: "Images"
|
image: "Images"
|
||||||
advanced: "Avancé"
|
advanced: "Avancé"
|
||||||
angle: "Angle"
|
angle: "Angle"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
angle: "Angle"
|
||||||
|
scale: "Taille"
|
||||||
|
size: "Taille"
|
||||||
|
color: "Couleur"
|
||||||
|
opacity: "Transparence"
|
||||||
|
lightness: "Clair"
|
||||||
|
|
|
@ -2627,3 +2627,11 @@ _watermarkEditor:
|
||||||
image: "Gambar"
|
image: "Gambar"
|
||||||
advanced: "Tingkat lanjut"
|
advanced: "Tingkat lanjut"
|
||||||
angle: "Sudut"
|
angle: "Sudut"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
angle: "Sudut"
|
||||||
|
scale: "Ukuran"
|
||||||
|
size: "Ukuran"
|
||||||
|
color: "Warna"
|
||||||
|
opacity: "Opasitas"
|
||||||
|
lightness: "Menerangkan"
|
||||||
|
|
146
locales/index.d.ts
vendored
146
locales/index.d.ts
vendored
|
@ -4386,6 +4386,10 @@ export interface Locale extends ILocale {
|
||||||
* ノート検索は利用できません。
|
* ノート検索は利用できません。
|
||||||
*/
|
*/
|
||||||
"notesSearchNotAvailable": string;
|
"notesSearchNotAvailable": string;
|
||||||
|
/**
|
||||||
|
* ユーザー検索は利用できません。
|
||||||
|
*/
|
||||||
|
"usersSearchNotAvailable": string;
|
||||||
/**
|
/**
|
||||||
* ライセンス
|
* ライセンス
|
||||||
*/
|
*/
|
||||||
|
@ -7799,6 +7803,10 @@ export interface Locale extends ILocale {
|
||||||
* ノート検索の利用
|
* ノート検索の利用
|
||||||
*/
|
*/
|
||||||
"canSearchNotes": string;
|
"canSearchNotes": string;
|
||||||
|
/**
|
||||||
|
* ユーザー検索の利用
|
||||||
|
*/
|
||||||
|
"canSearchUsers": string;
|
||||||
/**
|
/**
|
||||||
* 翻訳機能の利用
|
* 翻訳機能の利用
|
||||||
*/
|
*/
|
||||||
|
@ -12203,6 +12211,10 @@ export interface Locale extends ILocale {
|
||||||
* 高度
|
* 高度
|
||||||
*/
|
*/
|
||||||
"advanced": string;
|
"advanced": string;
|
||||||
|
/**
|
||||||
|
* 角度
|
||||||
|
*/
|
||||||
|
"angle": string;
|
||||||
/**
|
/**
|
||||||
* ストライプ
|
* ストライプ
|
||||||
*/
|
*/
|
||||||
|
@ -12215,10 +12227,6 @@ export interface Locale extends ILocale {
|
||||||
* ラインの数
|
* ラインの数
|
||||||
*/
|
*/
|
||||||
"stripeFrequency": string;
|
"stripeFrequency": string;
|
||||||
/**
|
|
||||||
* 角度
|
|
||||||
*/
|
|
||||||
"angle": string;
|
|
||||||
/**
|
/**
|
||||||
* ポルカドット
|
* ポルカドット
|
||||||
*/
|
*/
|
||||||
|
@ -12261,6 +12269,10 @@ export interface Locale extends ILocale {
|
||||||
* 変更を破棄して終了しますか?
|
* 変更を破棄して終了しますか?
|
||||||
*/
|
*/
|
||||||
"discardChangesConfirm": string;
|
"discardChangesConfirm": string;
|
||||||
|
/**
|
||||||
|
* 設定項目はありません
|
||||||
|
*/
|
||||||
|
"nothingToConfigure": string;
|
||||||
"_fxs": {
|
"_fxs": {
|
||||||
/**
|
/**
|
||||||
* 色収差
|
* 色収差
|
||||||
|
@ -12327,6 +12339,132 @@ export interface Locale extends ILocale {
|
||||||
*/
|
*/
|
||||||
"tearing": string;
|
"tearing": string;
|
||||||
};
|
};
|
||||||
|
"_fxProps": {
|
||||||
|
/**
|
||||||
|
* 角度
|
||||||
|
*/
|
||||||
|
"angle": string;
|
||||||
|
/**
|
||||||
|
* サイズ
|
||||||
|
*/
|
||||||
|
"scale": string;
|
||||||
|
/**
|
||||||
|
* サイズ
|
||||||
|
*/
|
||||||
|
"size": string;
|
||||||
|
/**
|
||||||
|
* 色
|
||||||
|
*/
|
||||||
|
"color": string;
|
||||||
|
/**
|
||||||
|
* 不透明度
|
||||||
|
*/
|
||||||
|
"opacity": string;
|
||||||
|
/**
|
||||||
|
* 正規化
|
||||||
|
*/
|
||||||
|
"normalize": string;
|
||||||
|
/**
|
||||||
|
* 量
|
||||||
|
*/
|
||||||
|
"amount": string;
|
||||||
|
/**
|
||||||
|
* 明るさ
|
||||||
|
*/
|
||||||
|
"lightness": string;
|
||||||
|
/**
|
||||||
|
* コントラスト
|
||||||
|
*/
|
||||||
|
"contrast": string;
|
||||||
|
/**
|
||||||
|
* 色相
|
||||||
|
*/
|
||||||
|
"hue": string;
|
||||||
|
/**
|
||||||
|
* 輝度
|
||||||
|
*/
|
||||||
|
"brightness": string;
|
||||||
|
/**
|
||||||
|
* 彩度
|
||||||
|
*/
|
||||||
|
"saturation": string;
|
||||||
|
/**
|
||||||
|
* 最大値
|
||||||
|
*/
|
||||||
|
"max": string;
|
||||||
|
/**
|
||||||
|
* 最小値
|
||||||
|
*/
|
||||||
|
"min": string;
|
||||||
|
/**
|
||||||
|
* 方向
|
||||||
|
*/
|
||||||
|
"direction": string;
|
||||||
|
/**
|
||||||
|
* 位相
|
||||||
|
*/
|
||||||
|
"phase": string;
|
||||||
|
/**
|
||||||
|
* 頻度
|
||||||
|
*/
|
||||||
|
"frequency": string;
|
||||||
|
/**
|
||||||
|
* 強さ
|
||||||
|
*/
|
||||||
|
"strength": string;
|
||||||
|
/**
|
||||||
|
* ズレ
|
||||||
|
*/
|
||||||
|
"glitchChannelShift": string;
|
||||||
|
/**
|
||||||
|
* シード値
|
||||||
|
*/
|
||||||
|
"seed": string;
|
||||||
|
/**
|
||||||
|
* 赤色成分
|
||||||
|
*/
|
||||||
|
"redComponent": string;
|
||||||
|
/**
|
||||||
|
* 緑色成分
|
||||||
|
*/
|
||||||
|
"greenComponent": string;
|
||||||
|
/**
|
||||||
|
* 青色成分
|
||||||
|
*/
|
||||||
|
"blueComponent": string;
|
||||||
|
/**
|
||||||
|
* しきい値
|
||||||
|
*/
|
||||||
|
"threshold": string;
|
||||||
|
/**
|
||||||
|
* 中心X
|
||||||
|
*/
|
||||||
|
"centerX": string;
|
||||||
|
/**
|
||||||
|
* 中心Y
|
||||||
|
*/
|
||||||
|
"centerY": string;
|
||||||
|
/**
|
||||||
|
* スムージング
|
||||||
|
*/
|
||||||
|
"zoomLinesSmoothing": string;
|
||||||
|
/**
|
||||||
|
* スムージングと集中線の幅の設定は併用できません。
|
||||||
|
*/
|
||||||
|
"zoomLinesSmoothingDescription": string;
|
||||||
|
/**
|
||||||
|
* 集中線の幅
|
||||||
|
*/
|
||||||
|
"zoomLinesThreshold": string;
|
||||||
|
/**
|
||||||
|
* 中心径
|
||||||
|
*/
|
||||||
|
"zoomLinesMaskSize": string;
|
||||||
|
/**
|
||||||
|
* 黒色にする
|
||||||
|
*/
|
||||||
|
"zoomLinesBlack": string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 下書き
|
* 下書き
|
||||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Gli spazi creano la relazione \"E\" tra parole (qu
|
||||||
hiddenTags: "Hashtag nascosti"
|
hiddenTags: "Hashtag nascosti"
|
||||||
hiddenTagsDescription: "Impedire la visualizzazione del tag impostato nei trend. Puoi impostare più valori, uno per riga."
|
hiddenTagsDescription: "Impedire la visualizzazione del tag impostato nei trend. Puoi impostare più valori, uno per riga."
|
||||||
notesSearchNotAvailable: "Non è possibile cercare tra le Note."
|
notesSearchNotAvailable: "Non è possibile cercare tra le Note."
|
||||||
|
usersSearchNotAvailable: "La ricerca profili non è disponibile."
|
||||||
license: "Licenza"
|
license: "Licenza"
|
||||||
unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?"
|
unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?"
|
||||||
myClips: "Le mie Clip"
|
myClips: "Le mie Clip"
|
||||||
|
@ -1198,7 +1199,7 @@ replies: "Risposte"
|
||||||
renotes: "Rinota"
|
renotes: "Rinota"
|
||||||
loadReplies: "Leggi le risposte"
|
loadReplies: "Leggi le risposte"
|
||||||
loadConversation: "Leggi la conversazione"
|
loadConversation: "Leggi la conversazione"
|
||||||
pinnedList: "Elenco in primo piano"
|
pinnedList: "Lista in primo piano"
|
||||||
keepScreenOn: "Mantenere lo schermo acceso"
|
keepScreenOn: "Mantenere lo schermo acceso"
|
||||||
verifiedLink: "Abbiamo confermato la validità di questo collegamento"
|
verifiedLink: "Abbiamo confermato la validità di questo collegamento"
|
||||||
notifyNotes: "Notifica nuove Note"
|
notifyNotes: "Notifica nuove Note"
|
||||||
|
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "Livello predefinito di compressione immagini"
|
||||||
defaultImageCompressionLevel_description: "La compressione diminuisce la qualità dell'immagine, poca compressione mantiene alta qualità delle immagini. Aumentandola, si riducono le dimensioni del file, a discapito della qualità dell'immagine."
|
defaultImageCompressionLevel_description: "La compressione diminuisce la qualità dell'immagine, poca compressione mantiene alta qualità delle immagini. Aumentandola, si riducono le dimensioni del file, a discapito della qualità dell'immagine."
|
||||||
inMinutes: "min"
|
inMinutes: "min"
|
||||||
inDays: "giorni"
|
inDays: "giorni"
|
||||||
|
safeModeEnabled: "La modalità sicura è attiva"
|
||||||
|
pluginsAreDisabledBecauseSafeMode: "Tutti i plugin sono disattivati, poiché la modalità sicura è attiva."
|
||||||
|
customCssIsDisabledBecauseSafeMode: "Il CSS personalizzato non è stato applicato, poiché la modalità sicura è attiva."
|
||||||
|
themeIsDefaultBecauseSafeMode: "Quando la modalità sicura è attiva, viene utilizzato il tema predefinito. Quando la modalità sicura viene disattivata, il tema torna a essere quello precedente."
|
||||||
_order:
|
_order:
|
||||||
newest: "Prima i più recenti"
|
newest: "Prima i più recenti"
|
||||||
oldest: "Meno recenti prima"
|
oldest: "Meno recenti prima"
|
||||||
|
@ -1461,6 +1466,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description2: "Quando la modalità è in tempo reale, arriveranno a prescindere."
|
contentsUpdateFrequency_description2: "Quando la modalità è in tempo reale, arriveranno a prescindere."
|
||||||
showUrlPreview: "Mostra anteprima dell'URL"
|
showUrlPreview: "Mostra anteprima dell'URL"
|
||||||
showAvailableReactionsFirstInNote: "Mostra le reazioni disponibili in alto"
|
showAvailableReactionsFirstInNote: "Mostra le reazioni disponibili in alto"
|
||||||
|
showPageTabBarBottom: "Visualizza le schede della pagina nella parte inferiore"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "Mostra il nome del mittente"
|
showSenderName: "Mostra il nome del mittente"
|
||||||
sendOnEnter: "Invio spedisce"
|
sendOnEnter: "Invio spedisce"
|
||||||
|
@ -1634,6 +1640,10 @@ _serverSettings:
|
||||||
fanoutTimelineDbFallback: "Elaborazione dati alternativa"
|
fanoutTimelineDbFallback: "Elaborazione dati alternativa"
|
||||||
fanoutTimelineDbFallbackDescription: "Attivando l'elaborazione alternativa, verrà interrogato ulteriormente il database se la timeline non è nella cache. \nDisattivando, si può ridurre ulteriormente il carico del server, evitando l'elaborazione alternativa, ma limitando l'intervallo recuperabile delle timeline."
|
fanoutTimelineDbFallbackDescription: "Attivando l'elaborazione alternativa, verrà interrogato ulteriormente il database se la timeline non è nella cache. \nDisattivando, si può ridurre ulteriormente il carico del server, evitando l'elaborazione alternativa, ma limitando l'intervallo recuperabile delle timeline."
|
||||||
reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
|
reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
|
||||||
|
remoteNotesCleaning: "Pulizia automatica dei contenuti remoti"
|
||||||
|
remoteNotesCleaning_description: "Se abilitata, verranno periodicamente rimosse le vecchie Note remote senza relazioni, per ridurre il sovraccarico del sistema."
|
||||||
|
remoteNotesCleaningMaxProcessingDuration: "Durata massima del processo di pulizia"
|
||||||
|
remoteNotesCleaningExpiryDaysForEachNotes: "Periodo minimo di conservazione delle note"
|
||||||
inquiryUrl: "URL di contatto"
|
inquiryUrl: "URL di contatto"
|
||||||
inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
|
inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
|
||||||
openRegistration: "Registrazioni aperte"
|
openRegistration: "Registrazioni aperte"
|
||||||
|
@ -1652,6 +1662,8 @@ _serverSettings:
|
||||||
userGeneratedContentsVisibilityForVisitor: "Visibilità dei contenuti generati dagli utenti ai non utenti"
|
userGeneratedContentsVisibilityForVisitor: "Visibilità dei contenuti generati dagli utenti ai non utenti"
|
||||||
userGeneratedContentsVisibilityForVisitor_description: "Questa funzionalità è utile per impedire che contenuti remoti inappropriati e difficili da moderare vengano inavvertitamente resi pubblici su Internet tramite il proprio server."
|
userGeneratedContentsVisibilityForVisitor_description: "Questa funzionalità è utile per impedire che contenuti remoti inappropriati e difficili da moderare vengano inavvertitamente resi pubblici su Internet tramite il proprio server."
|
||||||
userGeneratedContentsVisibilityForVisitor_description2: "Esistono dei rischi nell'esporre incondizionatamente su internet tutto il contenuto del tuo server, incluso il contenuto remoto ricevuto da altri server. In particolare, occorre prestare attenzione, perché le persone non consapevoli della federazione potrebbero erroneamente credere che il contenuto remoto sia stato invece creato all'interno del proprio server."
|
userGeneratedContentsVisibilityForVisitor_description2: "Esistono dei rischi nell'esporre incondizionatamente su internet tutto il contenuto del tuo server, incluso il contenuto remoto ricevuto da altri server. In particolare, occorre prestare attenzione, perché le persone non consapevoli della federazione potrebbero erroneamente credere che il contenuto remoto sia stato invece creato all'interno del proprio server."
|
||||||
|
restartServerSetupWizardConfirm_title: "Vuoi ripetere la procedura guidata di configurazione iniziale del server?"
|
||||||
|
restartServerSetupWizardConfirm_text: "Verranno ripristinate alcune tue impostazioni personalizzate."
|
||||||
_userGeneratedContentsVisibilityForVisitor:
|
_userGeneratedContentsVisibilityForVisitor:
|
||||||
all: "Tutto pubblico"
|
all: "Tutto pubblico"
|
||||||
localOnly: "Pubblica solo contenuti locali, mantieni privati i contenuti remoti"
|
localOnly: "Pubblica solo contenuti locali, mantieni privati i contenuti remoti"
|
||||||
|
@ -1988,6 +2000,7 @@ _role:
|
||||||
descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più."
|
descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più."
|
||||||
canHideAds: "Nascondere i banner"
|
canHideAds: "Nascondere i banner"
|
||||||
canSearchNotes: "Ricercare nelle Note"
|
canSearchNotes: "Ricercare nelle Note"
|
||||||
|
canSearchUsers: "Può cercare profili"
|
||||||
canUseTranslator: "Tradurre le Note"
|
canUseTranslator: "Tradurre le Note"
|
||||||
avatarDecorationLimit: "Numero massimo di decorazioni foto profilo installabili"
|
avatarDecorationLimit: "Numero massimo di decorazioni foto profilo installabili"
|
||||||
canImportAntennas: "Può importare Antenne"
|
canImportAntennas: "Può importare Antenne"
|
||||||
|
@ -3062,6 +3075,7 @@ _bootErrors:
|
||||||
otherOption1: "Nelle impostazioni, cancellare le impostazioni del client e svuotare la cache"
|
otherOption1: "Nelle impostazioni, cancellare le impostazioni del client e svuotare la cache"
|
||||||
otherOption2: "Avviare il client predefinito"
|
otherOption2: "Avviare il client predefinito"
|
||||||
otherOption3: "Avviare lo strumento di riparazione"
|
otherOption3: "Avviare lo strumento di riparazione"
|
||||||
|
otherOption4: "Avvia Misskey in modalità sicura"
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "Tutte"
|
searchScopeAll: "Tutte"
|
||||||
searchScopeLocal: "Locale"
|
searchScopeLocal: "Locale"
|
||||||
|
@ -3098,6 +3112,8 @@ _serverSetupWizard:
|
||||||
doYouConnectToFediverse_description1: "Collegandosi a una rete di server distribuiti, denominata Fediverso, potrai scambiare contenuti con altri server, tramite il protocollo di comunicazione ActivityPub."
|
doYouConnectToFediverse_description1: "Collegandosi a una rete di server distribuiti, denominata Fediverso, potrai scambiare contenuti con altri server, tramite il protocollo di comunicazione ActivityPub."
|
||||||
doYouConnectToFediverse_description2: "Connettersi al Fediverso è anche detto \"federazione\"."
|
doYouConnectToFediverse_description2: "Connettersi al Fediverso è anche detto \"federazione\"."
|
||||||
youCanConfigureMoreFederationSettingsLater: "Puoi svolgere la configurazione avanzata anche dopo. Ad esempio specificando quali server possono federarsi."
|
youCanConfigureMoreFederationSettingsLater: "Puoi svolgere la configurazione avanzata anche dopo. Ad esempio specificando quali server possono federarsi."
|
||||||
|
remoteContentsCleaning: "Pulizia automatica dei contenuti in arrivo"
|
||||||
|
remoteContentsCleaning_description: "Con la federazione funzionante, riceverai sempre più contenuti. Abilitando la pulizia automatica, i contenuti non referenziati e obsoleti verranno rimossi automaticamente dai tuoi server, risparmiando spazio di archiviazione."
|
||||||
adminInfo: "Informazioni sull'amministratore"
|
adminInfo: "Informazioni sull'amministratore"
|
||||||
adminInfo_description: "Imposta le informazioni dell'amministratore utilizzate per accettare le richieste."
|
adminInfo_description: "Imposta le informazioni dell'amministratore utilizzate per accettare le richieste."
|
||||||
adminInfo_mustBeFilled: "Questa operazione è necessaria su un server aperto o se è attiva la federazione."
|
adminInfo_mustBeFilled: "Questa operazione è necessaria su un server aperto o se è attiva la federazione."
|
||||||
|
@ -3150,10 +3166,10 @@ _watermarkEditor:
|
||||||
type: "Tipo"
|
type: "Tipo"
|
||||||
image: "Immagini"
|
image: "Immagini"
|
||||||
advanced: "Avanzato"
|
advanced: "Avanzato"
|
||||||
|
angle: "Angolo"
|
||||||
stripe: "Strisce"
|
stripe: "Strisce"
|
||||||
stripeWidth: "Larghezza della linea"
|
stripeWidth: "Larghezza della linea"
|
||||||
stripeFrequency: "Il numero di linee"
|
stripeFrequency: "Il numero di linee"
|
||||||
angle: "Angolo"
|
|
||||||
polkadot: "A pallini"
|
polkadot: "A pallini"
|
||||||
checker: "revisore"
|
checker: "revisore"
|
||||||
polkadotMainDotOpacity: "Opacità del punto principale"
|
polkadotMainDotOpacity: "Opacità del punto principale"
|
||||||
|
@ -3165,6 +3181,7 @@ _imageEffector:
|
||||||
title: "Effetto"
|
title: "Effetto"
|
||||||
addEffect: "Aggiungi effetto"
|
addEffect: "Aggiungi effetto"
|
||||||
discardChangesConfirm: "Scarta le modifiche ed esci?"
|
discardChangesConfirm: "Scarta le modifiche ed esci?"
|
||||||
|
nothingToConfigure: "Nessuna impostazione configurabile."
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "Aberrazione cromatica"
|
chromaticAberration: "Aberrazione cromatica"
|
||||||
glitch: "Glitch"
|
glitch: "Glitch"
|
||||||
|
@ -3182,6 +3199,38 @@ _imageEffector:
|
||||||
checker: "revisore"
|
checker: "revisore"
|
||||||
blockNoise: "Attenua rumore"
|
blockNoise: "Attenua rumore"
|
||||||
tearing: "Strappa immagine"
|
tearing: "Strappa immagine"
|
||||||
|
_fxProps:
|
||||||
|
angle: "Angolo"
|
||||||
|
scale: "Dimensioni"
|
||||||
|
size: "Dimensioni"
|
||||||
|
color: "Colore"
|
||||||
|
opacity: "Opacità"
|
||||||
|
normalize: "Normalizza"
|
||||||
|
amount: "Quantità"
|
||||||
|
lightness: "Chiaro"
|
||||||
|
contrast: "Contrasto"
|
||||||
|
hue: "Tinta"
|
||||||
|
brightness: "Luminosità"
|
||||||
|
saturation: "Saturazione"
|
||||||
|
max: "Valore massimo"
|
||||||
|
min: "Valore minimo"
|
||||||
|
direction: "Orientamento"
|
||||||
|
phase: "Fasare"
|
||||||
|
frequency: "Frequenza"
|
||||||
|
strength: "Forza"
|
||||||
|
glitchChannelShift: "Glitch cambio canale"
|
||||||
|
seed: "Seme"
|
||||||
|
redComponent: "Rosso composito"
|
||||||
|
greenComponent: "Verde composito"
|
||||||
|
blueComponent: "Blu composito"
|
||||||
|
threshold: "Soglia"
|
||||||
|
centerX: "Centro orizzontale"
|
||||||
|
centerY: "Centro verticale"
|
||||||
|
zoomLinesSmoothing: "Levigatura"
|
||||||
|
zoomLinesSmoothingDescription: "Non si possono usare insieme la levigatura e la larghezza della linea centrale."
|
||||||
|
zoomLinesThreshold: "Limite delle linee zoom"
|
||||||
|
zoomLinesMaskSize: "Ampiezza del diametro"
|
||||||
|
zoomLinesBlack: "Bande nere"
|
||||||
drafts: "Bozza"
|
drafts: "Bozza"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "Selezionare bozza"
|
select: "Selezionare bozza"
|
||||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "スペースで区切るとAND指定になり、
|
||||||
hiddenTags: "非表示ハッシュタグ"
|
hiddenTags: "非表示ハッシュタグ"
|
||||||
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
|
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
|
||||||
notesSearchNotAvailable: "ノート検索は利用できません。"
|
notesSearchNotAvailable: "ノート検索は利用できません。"
|
||||||
|
usersSearchNotAvailable: "ユーザー検索は利用できません。"
|
||||||
license: "ライセンス"
|
license: "ライセンス"
|
||||||
unfavoriteConfirm: "お気に入り解除しますか?"
|
unfavoriteConfirm: "お気に入り解除しますか?"
|
||||||
myClips: "自分のクリップ"
|
myClips: "自分のクリップ"
|
||||||
|
@ -2020,6 +2021,7 @@ _role:
|
||||||
descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。"
|
descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。"
|
||||||
canHideAds: "広告の非表示"
|
canHideAds: "広告の非表示"
|
||||||
canSearchNotes: "ノート検索の利用"
|
canSearchNotes: "ノート検索の利用"
|
||||||
|
canSearchUsers: "ユーザー検索の利用"
|
||||||
canUseTranslator: "翻訳機能の利用"
|
canUseTranslator: "翻訳機能の利用"
|
||||||
avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
|
avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
|
||||||
canImportAntennas: "アンテナのインポートを許可"
|
canImportAntennas: "アンテナのインポートを許可"
|
||||||
|
@ -3267,10 +3269,10 @@ _watermarkEditor:
|
||||||
type: "タイプ"
|
type: "タイプ"
|
||||||
image: "画像"
|
image: "画像"
|
||||||
advanced: "高度"
|
advanced: "高度"
|
||||||
|
angle: "角度"
|
||||||
stripe: "ストライプ"
|
stripe: "ストライプ"
|
||||||
stripeWidth: "ラインの幅"
|
stripeWidth: "ラインの幅"
|
||||||
stripeFrequency: "ラインの数"
|
stripeFrequency: "ラインの数"
|
||||||
angle: "角度"
|
|
||||||
polkadot: "ポルカドット"
|
polkadot: "ポルカドット"
|
||||||
checker: "チェッカー"
|
checker: "チェッカー"
|
||||||
polkadotMainDotOpacity: "メインドットの不透明度"
|
polkadotMainDotOpacity: "メインドットの不透明度"
|
||||||
|
@ -3283,6 +3285,7 @@ _imageEffector:
|
||||||
title: "エフェクト"
|
title: "エフェクト"
|
||||||
addEffect: "エフェクトを追加"
|
addEffect: "エフェクトを追加"
|
||||||
discardChangesConfirm: "変更を破棄して終了しますか?"
|
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||||
|
nothingToConfigure: "設定項目はありません"
|
||||||
|
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "色収差"
|
chromaticAberration: "色収差"
|
||||||
|
@ -3302,6 +3305,39 @@ _imageEffector:
|
||||||
blockNoise: "ブロックノイズ"
|
blockNoise: "ブロックノイズ"
|
||||||
tearing: "ティアリング"
|
tearing: "ティアリング"
|
||||||
|
|
||||||
|
_fxProps:
|
||||||
|
angle: "角度"
|
||||||
|
scale: "サイズ"
|
||||||
|
size: "サイズ"
|
||||||
|
color: "色"
|
||||||
|
opacity: "不透明度"
|
||||||
|
normalize: "正規化"
|
||||||
|
amount: "量"
|
||||||
|
lightness: "明るさ"
|
||||||
|
contrast: "コントラスト"
|
||||||
|
hue: "色相"
|
||||||
|
brightness: "輝度"
|
||||||
|
saturation: "彩度"
|
||||||
|
max: "最大値"
|
||||||
|
min: "最小値"
|
||||||
|
direction: "方向"
|
||||||
|
phase: "位相"
|
||||||
|
frequency: "頻度"
|
||||||
|
strength: "強さ"
|
||||||
|
glitchChannelShift: "ズレ"
|
||||||
|
seed: "シード値"
|
||||||
|
redComponent: "赤色成分"
|
||||||
|
greenComponent: "緑色成分"
|
||||||
|
blueComponent: "青色成分"
|
||||||
|
threshold: "しきい値"
|
||||||
|
centerX: "中心X"
|
||||||
|
centerY: "中心Y"
|
||||||
|
zoomLinesSmoothing: "スムージング"
|
||||||
|
zoomLinesSmoothingDescription: "スムージングと集中線の幅の設定は併用できません。"
|
||||||
|
zoomLinesThreshold: "集中線の幅"
|
||||||
|
zoomLinesMaskSize: "中心径"
|
||||||
|
zoomLinesBlack: "黒色にする"
|
||||||
|
|
||||||
drafts: "下書き"
|
drafts: "下書き"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "下書きを選択"
|
select: "下書きを選択"
|
||||||
|
|
|
@ -3020,6 +3020,13 @@ _watermarkEditor:
|
||||||
angle: "角度"
|
angle: "角度"
|
||||||
_imageEffector:
|
_imageEffector:
|
||||||
discardChangesConfirm: "変更をせんで終わるか?"
|
discardChangesConfirm: "変更をせんで終わるか?"
|
||||||
|
_fxProps:
|
||||||
|
angle: "角度"
|
||||||
|
scale: "大きさ"
|
||||||
|
size: "大きさ"
|
||||||
|
color: "色"
|
||||||
|
opacity: "不透明度"
|
||||||
|
lightness: "明るさ"
|
||||||
_drafts:
|
_drafts:
|
||||||
cannotCreateDraftAnymore: "下書きはこれ以上は作れへんな。"
|
cannotCreateDraftAnymore: "下書きはこれ以上は作れへんな。"
|
||||||
cannotCreateDraft: "この内容で下書きは作れへんな。"
|
cannotCreateDraft: "この内容で下書きは作れへんな。"
|
||||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며,
|
||||||
hiddenTags: "숨긴 해시태그"
|
hiddenTags: "숨긴 해시태그"
|
||||||
hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다."
|
hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다."
|
||||||
notesSearchNotAvailable: "노트 검색을 이용하실 수 없습니다."
|
notesSearchNotAvailable: "노트 검색을 이용하실 수 없습니다."
|
||||||
|
usersSearchNotAvailable: "유저 검색을 이용하실 수 없습니다."
|
||||||
license: "라이선스"
|
license: "라이선스"
|
||||||
unfavoriteConfirm: "즐겨찾기를 해제하시겠습니까?"
|
unfavoriteConfirm: "즐겨찾기를 해제하시겠습니까?"
|
||||||
myClips: "내 클립"
|
myClips: "내 클립"
|
||||||
|
@ -1465,6 +1466,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description2: "실시간 모드가 켜져 있을 때는 이 설정과 상관없이 실시간으로 콘텐츠가 업데이트됩니다."
|
contentsUpdateFrequency_description2: "실시간 모드가 켜져 있을 때는 이 설정과 상관없이 실시간으로 콘텐츠가 업데이트됩니다."
|
||||||
showUrlPreview: "URL 미리보기 표시"
|
showUrlPreview: "URL 미리보기 표시"
|
||||||
showAvailableReactionsFirstInNote: "이용 가능한 리액션을 선두로 표시"
|
showAvailableReactionsFirstInNote: "이용 가능한 리액션을 선두로 표시"
|
||||||
|
showPageTabBarBottom: "페이지의 탭 바를 아래쪽에 표시"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "발신자 이름 표시"
|
showSenderName: "발신자 이름 표시"
|
||||||
sendOnEnter: "엔터로 보내기"
|
sendOnEnter: "엔터로 보내기"
|
||||||
|
@ -1998,6 +2000,7 @@ _role:
|
||||||
descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다."
|
descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다."
|
||||||
canHideAds: "광고 숨기기"
|
canHideAds: "광고 숨기기"
|
||||||
canSearchNotes: "노트 검색 이용 가능 여부"
|
canSearchNotes: "노트 검색 이용 가능 여부"
|
||||||
|
canSearchUsers: "유저 검색 이용"
|
||||||
canUseTranslator: "번역 기능의 사용"
|
canUseTranslator: "번역 기능의 사용"
|
||||||
avatarDecorationLimit: "아바타 장식의 최대 붙임 개수"
|
avatarDecorationLimit: "아바타 장식의 최대 붙임 개수"
|
||||||
canImportAntennas: "안테나 가져오기 허용"
|
canImportAntennas: "안테나 가져오기 허용"
|
||||||
|
@ -3163,10 +3166,10 @@ _watermarkEditor:
|
||||||
type: "종류"
|
type: "종류"
|
||||||
image: "이미지"
|
image: "이미지"
|
||||||
advanced: "고급"
|
advanced: "고급"
|
||||||
|
angle: "각도"
|
||||||
stripe: "줄무늬"
|
stripe: "줄무늬"
|
||||||
stripeWidth: "라인의 폭"
|
stripeWidth: "라인의 폭"
|
||||||
stripeFrequency: "라인의 수"
|
stripeFrequency: "라인의 수"
|
||||||
angle: "각도"
|
|
||||||
polkadot: "물방울 무늬"
|
polkadot: "물방울 무늬"
|
||||||
checker: "체크 무늬"
|
checker: "체크 무늬"
|
||||||
polkadotMainDotOpacity: "주요 물방울의 불투명도"
|
polkadotMainDotOpacity: "주요 물방울의 불투명도"
|
||||||
|
@ -3178,6 +3181,7 @@ _imageEffector:
|
||||||
title: "이펙트"
|
title: "이펙트"
|
||||||
addEffect: "이펙트를 추가"
|
addEffect: "이펙트를 추가"
|
||||||
discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?"
|
discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?"
|
||||||
|
nothingToConfigure: "설정 항목이 없습니다."
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "색수차"
|
chromaticAberration: "색수차"
|
||||||
glitch: "글리치"
|
glitch: "글리치"
|
||||||
|
@ -3195,6 +3199,38 @@ _imageEffector:
|
||||||
checker: "체크 무늬"
|
checker: "체크 무늬"
|
||||||
blockNoise: "노이즈 방지"
|
blockNoise: "노이즈 방지"
|
||||||
tearing: "티어링"
|
tearing: "티어링"
|
||||||
|
_fxProps:
|
||||||
|
angle: "각도"
|
||||||
|
scale: "크기"
|
||||||
|
size: "크기"
|
||||||
|
color: "색"
|
||||||
|
opacity: "불투명도"
|
||||||
|
normalize: "노멀라이즈"
|
||||||
|
amount: "양"
|
||||||
|
lightness: "밝음"
|
||||||
|
contrast: "대비"
|
||||||
|
hue: "색조"
|
||||||
|
brightness: "밝기"
|
||||||
|
saturation: "채도"
|
||||||
|
max: "최대 값"
|
||||||
|
min: "최소 값"
|
||||||
|
direction: "방향"
|
||||||
|
phase: "위상"
|
||||||
|
frequency: "빈도"
|
||||||
|
strength: "강도"
|
||||||
|
glitchChannelShift: "글리치"
|
||||||
|
seed: "시드 값"
|
||||||
|
redComponent: "빨간색 요소"
|
||||||
|
greenComponent: "녹색 요소"
|
||||||
|
blueComponent: "파란색 요소"
|
||||||
|
threshold: "한계 값"
|
||||||
|
centerX: "X축 중심"
|
||||||
|
centerY: "Y축 중심"
|
||||||
|
zoomLinesSmoothing: "다듬기"
|
||||||
|
zoomLinesSmoothingDescription: "다듬기와 집중선 폭 설정은 같이 쓸 수 없습니다."
|
||||||
|
zoomLinesThreshold: "집중선 폭"
|
||||||
|
zoomLinesMaskSize: "중앙 값"
|
||||||
|
zoomLinesBlack: "검은색으로 하기"
|
||||||
drafts: "초안"
|
drafts: "초안"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "초안 선택"
|
select: "초안 선택"
|
||||||
|
|
|
@ -742,3 +742,8 @@ _watermarkEditor:
|
||||||
text: "Tekst"
|
text: "Tekst"
|
||||||
type: "Type"
|
type: "Type"
|
||||||
image: "Bilder"
|
image: "Bilder"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
scale: "Størrelse"
|
||||||
|
size: "Størrelse"
|
||||||
|
color: "Farge"
|
||||||
|
|
|
@ -1593,3 +1593,10 @@ _watermarkEditor:
|
||||||
type: "Typ"
|
type: "Typ"
|
||||||
image: "Zdjęcia"
|
image: "Zdjęcia"
|
||||||
advanced: "Zaawansowane"
|
advanced: "Zaawansowane"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
scale: "Rozmiar"
|
||||||
|
size: "Rozmiar"
|
||||||
|
color: "Kolor"
|
||||||
|
opacity: "Przezroczystość"
|
||||||
|
lightness: "Rozjaśnij"
|
||||||
|
|
|
@ -3150,10 +3150,10 @@ _watermarkEditor:
|
||||||
type: "Tipo"
|
type: "Tipo"
|
||||||
image: "imagem"
|
image: "imagem"
|
||||||
advanced: "Avançado"
|
advanced: "Avançado"
|
||||||
|
angle: "Ângulo"
|
||||||
stripe: "Listras"
|
stripe: "Listras"
|
||||||
stripeWidth: "Largura da linha"
|
stripeWidth: "Largura da linha"
|
||||||
stripeFrequency: "Número de linhas"
|
stripeFrequency: "Número de linhas"
|
||||||
angle: "Ângulo"
|
|
||||||
polkadot: "Bolinhas"
|
polkadot: "Bolinhas"
|
||||||
checker: "Xadrez"
|
checker: "Xadrez"
|
||||||
polkadotMainDotOpacity: "Opacidade da bolinha principal"
|
polkadotMainDotOpacity: "Opacidade da bolinha principal"
|
||||||
|
@ -3182,6 +3182,13 @@ _imageEffector:
|
||||||
checker: "Xadrez"
|
checker: "Xadrez"
|
||||||
blockNoise: "Bloquear Ruído"
|
blockNoise: "Bloquear Ruído"
|
||||||
tearing: "Descontinuidade"
|
tearing: "Descontinuidade"
|
||||||
|
_fxProps:
|
||||||
|
angle: "Ângulo"
|
||||||
|
scale: "Tamanho"
|
||||||
|
size: "Tamanho"
|
||||||
|
color: "Cor"
|
||||||
|
opacity: "Opacidade"
|
||||||
|
lightness: "Esclarecer"
|
||||||
drafts: "Rascunhos"
|
drafts: "Rascunhos"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "Selecionar Rascunho"
|
select: "Selecionar Rascunho"
|
||||||
|
|
|
@ -1400,3 +1400,7 @@ _watermarkEditor:
|
||||||
type: "Tip"
|
type: "Tip"
|
||||||
image: "Imagini"
|
image: "Imagini"
|
||||||
advanced: "Avansat"
|
advanced: "Avansat"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
scale: "Dimensiune"
|
||||||
|
size: "Dimensiune"
|
||||||
|
|
|
@ -2257,4 +2257,12 @@ _watermarkEditor:
|
||||||
image: "Изображения"
|
image: "Изображения"
|
||||||
advanced: "Для продвинутых"
|
advanced: "Для продвинутых"
|
||||||
angle: "Угол"
|
angle: "Угол"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
angle: "Угол"
|
||||||
|
scale: "Размер"
|
||||||
|
size: "Размер"
|
||||||
|
color: "Цвет"
|
||||||
|
opacity: "Непрозрачность"
|
||||||
|
lightness: "Осветление"
|
||||||
drafts: "Черновик"
|
drafts: "Черновик"
|
||||||
|
|
|
@ -1459,3 +1459,10 @@ _watermarkEditor:
|
||||||
type: "Typ"
|
type: "Typ"
|
||||||
image: "Obrázky"
|
image: "Obrázky"
|
||||||
advanced: "Rozšírené"
|
advanced: "Rozšírené"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
scale: "Veľkosť"
|
||||||
|
size: "Veľkosť"
|
||||||
|
color: "Farba"
|
||||||
|
opacity: "Priehľadnosť"
|
||||||
|
lightness: "Zosvetliť"
|
||||||
|
|
|
@ -716,3 +716,8 @@ _search:
|
||||||
_watermarkEditor:
|
_watermarkEditor:
|
||||||
scale: "Storlek"
|
scale: "Storlek"
|
||||||
image: "Bilder"
|
image: "Bilder"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
scale: "Storlek"
|
||||||
|
size: "Storlek"
|
||||||
|
color: "Färg"
|
||||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "ถ้าแยกด้วยเว้นวร
|
||||||
hiddenTags: "แฮชแท็กที่ซ่อนอยู่"
|
hiddenTags: "แฮชแท็กที่ซ่อนอยู่"
|
||||||
hiddenTagsDescription: "เลือกแท็กที่จะไม่แสดงในรายการเทรนด์ สามารถลงทะเบียนหลายแท็กได้โดยขึ้นบรรทัดใหม่"
|
hiddenTagsDescription: "เลือกแท็กที่จะไม่แสดงในรายการเทรนด์ สามารถลงทะเบียนหลายแท็กได้โดยขึ้นบรรทัดใหม่"
|
||||||
notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งาน"
|
notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งาน"
|
||||||
|
usersSearchNotAvailable: "การค้นหาผู้ใช้ไม่พร้อมใช้งาน"
|
||||||
license: "ใบอนุญาต"
|
license: "ใบอนุญาต"
|
||||||
unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?"
|
unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?"
|
||||||
myClips: "คลิปของฉัน"
|
myClips: "คลิปของฉัน"
|
||||||
|
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "ความละเอียดเริ่ม
|
||||||
defaultImageCompressionLevel_description: "หากตั้งค่าต่ำ จะรักษาคุณภาพภาพได้ดีขึ้นแต่ขนาดไฟล์จะเพิ่มขึ้น<br>หากตั้งค่าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพภาพจะลดลง"
|
defaultImageCompressionLevel_description: "หากตั้งค่าต่ำ จะรักษาคุณภาพภาพได้ดีขึ้นแต่ขนาดไฟล์จะเพิ่มขึ้น<br>หากตั้งค่าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพภาพจะลดลง"
|
||||||
inMinutes: "นาที"
|
inMinutes: "นาที"
|
||||||
inDays: "วัน"
|
inDays: "วัน"
|
||||||
|
safeModeEnabled: "โหมดปลอดภัยถูกเปิดใช้งาน"
|
||||||
|
pluginsAreDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน ปลั๊กอินทั้งหมดจึงถูกปิดใช้งาน"
|
||||||
|
customCssIsDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน CSS แบบกำหนดเองจึงไม่ได้ถูกนำมาใช้"
|
||||||
|
themeIsDefaultBecauseSafeMode: "ในระหว่างที่โหมดปลอดภัยถูกเปิดใช้งาน จะใช้ธีมเริ่มต้น เมื่อปิดโหมดปลอดภัยจะกลับคืนดังเดิม"
|
||||||
_order:
|
_order:
|
||||||
newest: "เรียงจากใหม่ไปเก่า"
|
newest: "เรียงจากใหม่ไปเก่า"
|
||||||
oldest: "เรียงจากเก่าไปใหม่"
|
oldest: "เรียงจากเก่าไปใหม่"
|
||||||
|
@ -1461,6 +1466,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description2: "เมื่อโหมดเรียลไทม์เปิดอยู่ เนื้อหาจะอัปเดตแบบเรียลไทม์โดยไม่ขึ้นกับการตั้งค่านี้"
|
contentsUpdateFrequency_description2: "เมื่อโหมดเรียลไทม์เปิดอยู่ เนื้อหาจะอัปเดตแบบเรียลไทม์โดยไม่ขึ้นกับการตั้งค่านี้"
|
||||||
showUrlPreview: "แสดงตัวอย่าง URL"
|
showUrlPreview: "แสดงตัวอย่าง URL"
|
||||||
showAvailableReactionsFirstInNote: "แสดงรีแอคชั่นที่ใช้ได้ไว้หน้าสุด"
|
showAvailableReactionsFirstInNote: "แสดงรีแอคชั่นที่ใช้ได้ไว้หน้าสุด"
|
||||||
|
showPageTabBarBottom: "แสดงแท็บบาร์ของเพจที่ด้านล่าง"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "แสดงชื่อผู้ส่ง"
|
showSenderName: "แสดงชื่อผู้ส่ง"
|
||||||
sendOnEnter: "กด Enter เพื่อส่ง"
|
sendOnEnter: "กด Enter เพื่อส่ง"
|
||||||
|
@ -1994,6 +2000,7 @@ _role:
|
||||||
descriptionOfRateLimitFactor: "ยิ่งตัวเลขน้อยก็ยิ่งจำกัดน้อย ยิ่งมากก็ยิ่งเข้มงวดมากขึ้น"
|
descriptionOfRateLimitFactor: "ยิ่งตัวเลขน้อยก็ยิ่งจำกัดน้อย ยิ่งมากก็ยิ่งเข้มงวดมากขึ้น"
|
||||||
canHideAds: "ซ่อนโฆษณา"
|
canHideAds: "ซ่อนโฆษณา"
|
||||||
canSearchNotes: "การใช้การค้นหาโน้ต"
|
canSearchNotes: "การใช้การค้นหาโน้ต"
|
||||||
|
canSearchUsers: "ค้นหาผู้ใช้"
|
||||||
canUseTranslator: "การใช้งานแปล"
|
canUseTranslator: "การใช้งานแปล"
|
||||||
avatarDecorationLimit: "จำนวนของตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
|
avatarDecorationLimit: "จำนวนของตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
|
||||||
canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ"
|
canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ"
|
||||||
|
@ -3068,6 +3075,7 @@ _bootErrors:
|
||||||
otherOption1: "ลบการตั้งค่าและแคชของไคลเอนต์"
|
otherOption1: "ลบการตั้งค่าและแคชของไคลเอนต์"
|
||||||
otherOption2: "เริ่มใช้งานไคลเอนต์แบบง่าย"
|
otherOption2: "เริ่มใช้งานไคลเอนต์แบบง่าย"
|
||||||
otherOption3: "เปิดเครื่องมือซ่อมแซม"
|
otherOption3: "เปิดเครื่องมือซ่อมแซม"
|
||||||
|
otherOption4: "เริ่มทำงาน Misskey ในโหมดปลอดภัย"
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "ทั้งหมด"
|
searchScopeAll: "ทั้งหมด"
|
||||||
searchScopeLocal: "ท้องถิ่น"
|
searchScopeLocal: "ท้องถิ่น"
|
||||||
|
@ -3158,10 +3166,10 @@ _watermarkEditor:
|
||||||
type: "รูปแบบ"
|
type: "รูปแบบ"
|
||||||
image: "รูปภาพ"
|
image: "รูปภาพ"
|
||||||
advanced: "ขั้นสูง"
|
advanced: "ขั้นสูง"
|
||||||
|
angle: "แองเกิล"
|
||||||
stripe: "ริ้ว"
|
stripe: "ริ้ว"
|
||||||
stripeWidth: "ความกว้างเส้น"
|
stripeWidth: "ความกว้างเส้น"
|
||||||
stripeFrequency: "จำนวนเส้น"
|
stripeFrequency: "จำนวนเส้น"
|
||||||
angle: "แองเกิล"
|
|
||||||
polkadot: "ลายจุด"
|
polkadot: "ลายจุด"
|
||||||
checker: "ช่องตาราง"
|
checker: "ช่องตาราง"
|
||||||
polkadotMainDotOpacity: "ความทึบของจุดหลัก"
|
polkadotMainDotOpacity: "ความทึบของจุดหลัก"
|
||||||
|
@ -3173,6 +3181,7 @@ _imageEffector:
|
||||||
title: "เอฟเฟกต์"
|
title: "เอฟเฟกต์"
|
||||||
addEffect: "เพิ่มเอฟเฟกต์"
|
addEffect: "เพิ่มเอฟเฟกต์"
|
||||||
discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?"
|
discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?"
|
||||||
|
nothingToConfigure: "ไม่มีอะไรให้ตั้งค่า"
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "ความคลาดสี"
|
chromaticAberration: "ความคลาดสี"
|
||||||
glitch: "กลิตช์"
|
glitch: "กลิตช์"
|
||||||
|
@ -3190,6 +3199,38 @@ _imageEffector:
|
||||||
checker: "ช่องตาราง"
|
checker: "ช่องตาราง"
|
||||||
blockNoise: "บล็อกที่มีการรบกวน"
|
blockNoise: "บล็อกที่มีการรบกวน"
|
||||||
tearing: "ฉีกขาด"
|
tearing: "ฉีกขาด"
|
||||||
|
_fxProps:
|
||||||
|
angle: "แองเกิล"
|
||||||
|
scale: "ขนาด"
|
||||||
|
size: "ขนาด"
|
||||||
|
color: "สี"
|
||||||
|
opacity: "ความทึบแสง"
|
||||||
|
normalize: "นอร์มัลไลซ์"
|
||||||
|
amount: "จำนวน"
|
||||||
|
lightness: "สว่าง"
|
||||||
|
contrast: "คอนทราสต์"
|
||||||
|
hue: "HUE"
|
||||||
|
brightness: "ความสว่าง"
|
||||||
|
saturation: "ความอิ่มตัว"
|
||||||
|
max: "สูงสุด"
|
||||||
|
min: "ต่ำสุด"
|
||||||
|
direction: "ทิศทาง"
|
||||||
|
phase: "ระยะ"
|
||||||
|
frequency: "ความถี่"
|
||||||
|
strength: "ความแรง"
|
||||||
|
glitchChannelShift: "ความเคลื่อน"
|
||||||
|
seed: "ซีด"
|
||||||
|
redComponent: "ส่วนสีแดง"
|
||||||
|
greenComponent: "ส่วนสีเขียว"
|
||||||
|
blueComponent: "ส่วนสีน้ำเงิน"
|
||||||
|
threshold: "เทรชโฮลด์"
|
||||||
|
centerX: "กลาง X"
|
||||||
|
centerY: "กลาง Y"
|
||||||
|
zoomLinesSmoothing: "ทำให้สมูธ"
|
||||||
|
zoomLinesSmoothingDescription: "ตั้งให้สมูธไม่สามารถใช้ร่วมกับตั้งความกว้างเส้นรวมศูนย์ได้"
|
||||||
|
zoomLinesThreshold: "ความกว้างเส้นรวมศูนย์"
|
||||||
|
zoomLinesMaskSize: "ขนาดพื้นที่ตรงกลาง"
|
||||||
|
zoomLinesBlack: "ทำให้ดำ"
|
||||||
drafts: "ร่าง"
|
drafts: "ร่าง"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "เลือกฉบับร่าง"
|
select: "เลือกฉบับร่าง"
|
||||||
|
|
1056
locales/tr-TR.yml
1056
locales/tr-TR.yml
File diff suppressed because it is too large
Load diff
|
@ -1648,3 +1648,10 @@ _watermarkEditor:
|
||||||
type: "Тип"
|
type: "Тип"
|
||||||
image: "Зображення"
|
image: "Зображення"
|
||||||
advanced: "Розширені"
|
advanced: "Розширені"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
scale: "Розмір"
|
||||||
|
size: "Розмір"
|
||||||
|
color: "Колір"
|
||||||
|
opacity: "Непрозорість"
|
||||||
|
lightness: "Яскравість"
|
||||||
|
|
|
@ -1102,3 +1102,7 @@ _watermarkEditor:
|
||||||
type: "turi"
|
type: "turi"
|
||||||
image: "Rasmlar"
|
image: "Rasmlar"
|
||||||
advanced: "Murakkab"
|
advanced: "Murakkab"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
color: "Rang"
|
||||||
|
lightness: "Yoritish"
|
||||||
|
|
|
@ -2091,3 +2091,11 @@ _watermarkEditor:
|
||||||
image: "Hình ảnh"
|
image: "Hình ảnh"
|
||||||
advanced: "Nâng cao"
|
advanced: "Nâng cao"
|
||||||
angle: "Góc"
|
angle: "Góc"
|
||||||
|
_imageEffector:
|
||||||
|
_fxProps:
|
||||||
|
angle: "Góc"
|
||||||
|
scale: "Kích thước"
|
||||||
|
size: "Kích thước"
|
||||||
|
color: "Màu sắc"
|
||||||
|
opacity: "Độ trong suốt"
|
||||||
|
lightness: "Độ sáng"
|
||||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "AND 条件用空格分隔,正则表达式用斜
|
||||||
hiddenTags: "隐藏标签"
|
hiddenTags: "隐藏标签"
|
||||||
hiddenTagsDescription: "设定的标签将不会在时间线上显示。可使用换行来设置多个标签。"
|
hiddenTagsDescription: "设定的标签将不会在时间线上显示。可使用换行来设置多个标签。"
|
||||||
notesSearchNotAvailable: "帖子检索不可用"
|
notesSearchNotAvailable: "帖子检索不可用"
|
||||||
|
usersSearchNotAvailable: "用户检索不可用"
|
||||||
license: "许可信息"
|
license: "许可信息"
|
||||||
unfavoriteConfirm: "确定要取消收藏吗?"
|
unfavoriteConfirm: "确定要取消收藏吗?"
|
||||||
myClips: "我的便签"
|
myClips: "我的便签"
|
||||||
|
@ -1465,6 +1466,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description2: "当实时模式开启时,无论此设置如何,内容都会实时更新。"
|
contentsUpdateFrequency_description2: "当实时模式开启时,无论此设置如何,内容都会实时更新。"
|
||||||
showUrlPreview: "显示 URL 预览"
|
showUrlPreview: "显示 URL 预览"
|
||||||
showAvailableReactionsFirstInNote: "在顶部显示可用的回应"
|
showAvailableReactionsFirstInNote: "在顶部显示可用的回应"
|
||||||
|
showPageTabBarBottom: "在下方显示页面标签栏"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "显示发送者的名字"
|
showSenderName: "显示发送者的名字"
|
||||||
sendOnEnter: "回车键发送"
|
sendOnEnter: "回车键发送"
|
||||||
|
@ -1998,6 +2000,7 @@ _role:
|
||||||
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
|
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
|
||||||
canHideAds: "可以隐藏广告"
|
canHideAds: "可以隐藏广告"
|
||||||
canSearchNotes: "是否可以搜索帖子"
|
canSearchNotes: "是否可以搜索帖子"
|
||||||
|
canSearchUsers: "使用用户检索"
|
||||||
canUseTranslator: "使用翻译功能"
|
canUseTranslator: "使用翻译功能"
|
||||||
avatarDecorationLimit: "可添加头像挂件的最大个数"
|
avatarDecorationLimit: "可添加头像挂件的最大个数"
|
||||||
canImportAntennas: "允许导入天线"
|
canImportAntennas: "允许导入天线"
|
||||||
|
@ -3163,10 +3166,10 @@ _watermarkEditor:
|
||||||
type: "类型"
|
type: "类型"
|
||||||
image: "图片"
|
image: "图片"
|
||||||
advanced: "高级"
|
advanced: "高级"
|
||||||
|
angle: "角度"
|
||||||
stripe: "条纹"
|
stripe: "条纹"
|
||||||
stripeWidth: "线条宽度"
|
stripeWidth: "线条宽度"
|
||||||
stripeFrequency: "线条数量"
|
stripeFrequency: "线条数量"
|
||||||
angle: "角度"
|
|
||||||
polkadot: "波点"
|
polkadot: "波点"
|
||||||
checker: "检查"
|
checker: "检查"
|
||||||
polkadotMainDotOpacity: "主波点的不透明度"
|
polkadotMainDotOpacity: "主波点的不透明度"
|
||||||
|
@ -3178,6 +3181,7 @@ _imageEffector:
|
||||||
title: "效果"
|
title: "效果"
|
||||||
addEffect: "添加效果"
|
addEffect: "添加效果"
|
||||||
discardChangesConfirm: "丢弃当前设置并退出?"
|
discardChangesConfirm: "丢弃当前设置并退出?"
|
||||||
|
nothingToConfigure: "还没有设置"
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "色差"
|
chromaticAberration: "色差"
|
||||||
glitch: "故障"
|
glitch: "故障"
|
||||||
|
@ -3195,6 +3199,38 @@ _imageEffector:
|
||||||
checker: "检查"
|
checker: "检查"
|
||||||
blockNoise: "块状噪点"
|
blockNoise: "块状噪点"
|
||||||
tearing: "撕裂"
|
tearing: "撕裂"
|
||||||
|
_fxProps:
|
||||||
|
angle: "角度"
|
||||||
|
scale: "大小"
|
||||||
|
size: "大小"
|
||||||
|
color: "颜色"
|
||||||
|
opacity: "不透明度"
|
||||||
|
normalize: "标准化"
|
||||||
|
amount: "数量"
|
||||||
|
lightness: "浅色"
|
||||||
|
contrast: "对比度"
|
||||||
|
hue: "色调"
|
||||||
|
brightness: "亮度"
|
||||||
|
saturation: "饱和度"
|
||||||
|
max: "最大值"
|
||||||
|
min: "最小值"
|
||||||
|
direction: "方向"
|
||||||
|
phase: "相位"
|
||||||
|
frequency: "频率"
|
||||||
|
strength: "强度"
|
||||||
|
glitchChannelShift: "错位"
|
||||||
|
seed: "种子"
|
||||||
|
redComponent: "红色成分"
|
||||||
|
greenComponent: "绿色成分"
|
||||||
|
blueComponent: "蓝色成分"
|
||||||
|
threshold: "阈值"
|
||||||
|
centerX: "中心 X "
|
||||||
|
centerY: "中心 Y"
|
||||||
|
zoomLinesSmoothing: "平滑"
|
||||||
|
zoomLinesSmoothingDescription: "平滑和集中线宽度设置不能同时使用。"
|
||||||
|
zoomLinesThreshold: "集中线宽度"
|
||||||
|
zoomLinesMaskSize: "中心直径"
|
||||||
|
zoomLinesBlack: "变成黑色"
|
||||||
drafts: "草稿"
|
drafts: "草稿"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "选择草稿"
|
select: "选择草稿"
|
||||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "空格代表「以及」(AND),斜線包圍
|
||||||
hiddenTags: "隱藏標籤"
|
hiddenTags: "隱藏標籤"
|
||||||
hiddenTagsDescription: "設定的標籤不會在趨勢中顯示,換行可以設定多個標籤。"
|
hiddenTagsDescription: "設定的標籤不會在趨勢中顯示,換行可以設定多個標籤。"
|
||||||
notesSearchNotAvailable: "無法使用搜尋貼文功能。"
|
notesSearchNotAvailable: "無法使用搜尋貼文功能。"
|
||||||
|
usersSearchNotAvailable: "無法使用使用者搜尋功能。"
|
||||||
license: "授權"
|
license: "授權"
|
||||||
unfavoriteConfirm: "要取消收錄我的最愛嗎?"
|
unfavoriteConfirm: "要取消收錄我的最愛嗎?"
|
||||||
myClips: "我的摘錄"
|
myClips: "我的摘錄"
|
||||||
|
@ -1373,7 +1374,7 @@ inDays: "日"
|
||||||
safeModeEnabled: "啟用安全模式"
|
safeModeEnabled: "啟用安全模式"
|
||||||
pluginsAreDisabledBecauseSafeMode: "由於啟用安全模式,所有的外掛都被停用。"
|
pluginsAreDisabledBecauseSafeMode: "由於啟用安全模式,所有的外掛都被停用。"
|
||||||
customCssIsDisabledBecauseSafeMode: "由於啟用安全模式,所有的客製 CSS 都被停用。"
|
customCssIsDisabledBecauseSafeMode: "由於啟用安全模式,所有的客製 CSS 都被停用。"
|
||||||
themeIsDefaultBecauseSafeMode: "啟用安全模式時將使用預設主題,關閉安全模式時將恢復預設主題。"
|
themeIsDefaultBecauseSafeMode: "在安全模式啟用期間將使用預設主題。關閉安全模式後會恢復原本的設定。"
|
||||||
_order:
|
_order:
|
||||||
newest: "最新的在前"
|
newest: "最新的在前"
|
||||||
oldest: "最舊的在前"
|
oldest: "最舊的在前"
|
||||||
|
@ -1465,6 +1466,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description2: "當即時模式開啟時,不論此設定為何,內容都會即時更新。"
|
contentsUpdateFrequency_description2: "當即時模式開啟時,不論此設定為何,內容都會即時更新。"
|
||||||
showUrlPreview: "顯示網址預覽"
|
showUrlPreview: "顯示網址預覽"
|
||||||
showAvailableReactionsFirstInNote: "將可用的反應顯示在頂部"
|
showAvailableReactionsFirstInNote: "將可用的反應顯示在頂部"
|
||||||
|
showPageTabBarBottom: "在底部顯示頁面的標籤列"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "顯示發送者的名稱"
|
showSenderName: "顯示發送者的名稱"
|
||||||
sendOnEnter: "按下 Enter 發送訊息"
|
sendOnEnter: "按下 Enter 發送訊息"
|
||||||
|
@ -1998,6 +2000,7 @@ _role:
|
||||||
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
|
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
|
||||||
canHideAds: "不顯示廣告"
|
canHideAds: "不顯示廣告"
|
||||||
canSearchNotes: "可否搜尋貼文"
|
canSearchNotes: "可否搜尋貼文"
|
||||||
|
canSearchUsers: "可使用使用者搜尋功能"
|
||||||
canUseTranslator: "使用翻譯功能"
|
canUseTranslator: "使用翻譯功能"
|
||||||
avatarDecorationLimit: "頭像可掛上的最大裝飾數量"
|
avatarDecorationLimit: "頭像可掛上的最大裝飾數量"
|
||||||
canImportAntennas: "允許匯入天線"
|
canImportAntennas: "允許匯入天線"
|
||||||
|
@ -3163,10 +3166,10 @@ _watermarkEditor:
|
||||||
type: "類型"
|
type: "類型"
|
||||||
image: "圖片"
|
image: "圖片"
|
||||||
advanced: "進階"
|
advanced: "進階"
|
||||||
|
angle: "角度"
|
||||||
stripe: "條紋"
|
stripe: "條紋"
|
||||||
stripeWidth: "線條寬度"
|
stripeWidth: "線條寬度"
|
||||||
stripeFrequency: "線條數量"
|
stripeFrequency: "線條數量"
|
||||||
angle: "角度"
|
|
||||||
polkadot: "波卡圓點"
|
polkadot: "波卡圓點"
|
||||||
checker: "棋盤格"
|
checker: "棋盤格"
|
||||||
polkadotMainDotOpacity: "主圓點的不透明度"
|
polkadotMainDotOpacity: "主圓點的不透明度"
|
||||||
|
@ -3178,6 +3181,7 @@ _imageEffector:
|
||||||
title: "特效"
|
title: "特效"
|
||||||
addEffect: "新增特效"
|
addEffect: "新增特效"
|
||||||
discardChangesConfirm: "捨棄更改並退出嗎?"
|
discardChangesConfirm: "捨棄更改並退出嗎?"
|
||||||
|
nothingToConfigure: "無可設定的項目"
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "色差"
|
chromaticAberration: "色差"
|
||||||
glitch: "異常雜訊效果"
|
glitch: "異常雜訊效果"
|
||||||
|
@ -3195,6 +3199,38 @@ _imageEffector:
|
||||||
checker: "棋盤格"
|
checker: "棋盤格"
|
||||||
blockNoise: "阻擋雜訊"
|
blockNoise: "阻擋雜訊"
|
||||||
tearing: "撕裂"
|
tearing: "撕裂"
|
||||||
|
_fxProps:
|
||||||
|
angle: "角度"
|
||||||
|
scale: "大小"
|
||||||
|
size: "大小"
|
||||||
|
color: "顏色"
|
||||||
|
opacity: "透明度"
|
||||||
|
normalize: "正規化"
|
||||||
|
amount: "數量"
|
||||||
|
lightness: "亮度"
|
||||||
|
contrast: "對比度"
|
||||||
|
hue: "色相"
|
||||||
|
brightness: "亮度"
|
||||||
|
saturation: "彩度"
|
||||||
|
max: "最大值"
|
||||||
|
min: "最小值"
|
||||||
|
direction: "方向"
|
||||||
|
phase: "相位"
|
||||||
|
frequency: "頻率"
|
||||||
|
strength: "強度"
|
||||||
|
glitchChannelShift: "偏移"
|
||||||
|
seed: "種子值"
|
||||||
|
redComponent: "紅色成分"
|
||||||
|
greenComponent: "綠色成分"
|
||||||
|
blueComponent: "青色成分"
|
||||||
|
threshold: "閾值"
|
||||||
|
centerX: "X中心座標"
|
||||||
|
centerY: "Y中心座標"
|
||||||
|
zoomLinesSmoothing: "平滑化"
|
||||||
|
zoomLinesSmoothingDescription: "平滑化與集中線寬度設定不能同時使用。"
|
||||||
|
zoomLinesThreshold: "集中線的寬度"
|
||||||
|
zoomLinesMaskSize: "中心直徑"
|
||||||
|
zoomLinesBlack: "變成黑色"
|
||||||
drafts: "草稿\n"
|
drafts: "草稿\n"
|
||||||
_drafts:
|
_drafts:
|
||||||
select: "選擇草槁"
|
select: "選擇草槁"
|
||||||
|
|
22
package.json
22
package.json
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "2025.8.0-alpha.5",
|
"version": "2025.8.0-alpha.12",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/misskey-dev/misskey.git"
|
"url": "https://github.com/misskey-dev/misskey.git"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.13.1",
|
"packageManager": "pnpm@10.14.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/frontend-shared",
|
"packages/frontend-shared",
|
||||||
"packages/frontend",
|
"packages/frontend",
|
||||||
|
@ -53,7 +53,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssnano": "7.1.0",
|
"cssnano": "7.1.0",
|
||||||
"esbuild": "0.25.6",
|
"esbuild": "0.25.8",
|
||||||
"execa": "9.6.0",
|
"execa": "9.6.0",
|
||||||
"fast-glob": "3.3.3",
|
"fast-glob": "3.3.3",
|
||||||
"glob": "11.0.3",
|
"glob": "11.0.3",
|
||||||
|
@ -62,20 +62,20 @@
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"tar": "7.4.3",
|
"tar": "7.4.3",
|
||||||
"terser": "5.43.1",
|
"terser": "5.43.1",
|
||||||
"typescript": "5.8.3"
|
"typescript": "5.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@misskey-dev/eslint-plugin": "2.1.0",
|
"@misskey-dev/eslint-plugin": "2.1.0",
|
||||||
"@types/node": "22.16.4",
|
"@types/node": "22.17.1",
|
||||||
"@typescript-eslint/eslint-plugin": "8.37.0",
|
"@typescript-eslint/eslint-plugin": "8.39.0",
|
||||||
"@typescript-eslint/parser": "8.37.0",
|
"@typescript-eslint/parser": "8.39.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "14.5.2",
|
"cypress": "14.5.4",
|
||||||
"eslint": "9.31.0",
|
"eslint": "9.33.0",
|
||||||
"globals": "16.3.0",
|
"globals": "16.3.0",
|
||||||
"ncp": "2.0.0",
|
"ncp": "2.0.0",
|
||||||
"pnpm": "10.13.1",
|
"pnpm": "10.14.0",
|
||||||
"start-server-and-test": "2.0.12"
|
"start-server-and-test": "2.0.13"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tensorflow/tfjs-core": "4.22.0"
|
"@tensorflow/tfjs-core": "4.22.0"
|
||||||
|
|
58
packages/backend/migration/1755168347001-PageCountInNote.js
Normal file
58
packages/backend/migration/1755168347001-PageCountInNote.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class PageCountInNote1755168347001 {
|
||||||
|
name = 'PageCountInNote1755168347001'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" ADD "pageCount" smallint NOT NULL DEFAULT '0'`);
|
||||||
|
|
||||||
|
// Update existing notes
|
||||||
|
// block_list CTE collects all page blocks on the pages including child blocks in the section blocks.
|
||||||
|
// The clipped_notes CTE counts how many distinct pages each note block is referenced in.
|
||||||
|
// Finally, we update the note table with the count of pages for each referenced note.
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH RECURSIVE block_list AS (
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
page.id as page_id,
|
||||||
|
block as block
|
||||||
|
FROM page
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(page.content) block
|
||||||
|
WHERE block->>'type' = 'note' OR block->>'type' = 'section'
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
block_list.page_id,
|
||||||
|
child_block AS block
|
||||||
|
FROM LATERAL (
|
||||||
|
SELECT page_id, block
|
||||||
|
FROM block_list
|
||||||
|
WHERE block_list.block->>'type' = 'section'
|
||||||
|
) block_list
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(block_list.block->'children') child_block
|
||||||
|
WHERE child_block->>'type' = 'note' OR child_block->>'type' = 'section'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
clipped_notes AS (
|
||||||
|
SELECT
|
||||||
|
(block->>'note') AS note_id,
|
||||||
|
COUNT(distinct block_list.page_id) AS count
|
||||||
|
FROM block_list
|
||||||
|
WHERE block_list.block->>'type' = 'note'
|
||||||
|
GROUP BY block->>'note'
|
||||||
|
)
|
||||||
|
UPDATE note
|
||||||
|
SET "pageCount" = clipped_notes.count
|
||||||
|
FROM clipped_notes
|
||||||
|
WHERE note.id = clipped_notes.note_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "pageCount"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,17 +38,17 @@
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@swc/core-android-arm64": "1.3.11",
|
"@swc/core-android-arm64": "1.3.11",
|
||||||
"@swc/core-darwin-arm64": "1.12.0",
|
"@swc/core-darwin-arm64": "1.13.3",
|
||||||
"@swc/core-darwin-x64": "1.12.0",
|
"@swc/core-darwin-x64": "1.13.3",
|
||||||
"@swc/core-freebsd-x64": "1.3.11",
|
"@swc/core-freebsd-x64": "1.3.11",
|
||||||
"@swc/core-linux-arm-gnueabihf": "1.12.0",
|
"@swc/core-linux-arm-gnueabihf": "1.13.3",
|
||||||
"@swc/core-linux-arm64-gnu": "1.12.0",
|
"@swc/core-linux-arm64-gnu": "1.13.3",
|
||||||
"@swc/core-linux-arm64-musl": "1.12.0",
|
"@swc/core-linux-arm64-musl": "1.13.3",
|
||||||
"@swc/core-linux-x64-gnu": "1.12.0",
|
"@swc/core-linux-x64-gnu": "1.13.3",
|
||||||
"@swc/core-linux-x64-musl": "1.12.0",
|
"@swc/core-linux-x64-musl": "1.13.3",
|
||||||
"@swc/core-win32-arm64-msvc": "1.12.0",
|
"@swc/core-win32-arm64-msvc": "1.13.3",
|
||||||
"@swc/core-win32-ia32-msvc": "1.12.0",
|
"@swc/core-win32-ia32-msvc": "1.13.3",
|
||||||
"@swc/core-win32-x64-msvc": "1.12.0",
|
"@swc/core-win32-x64-msvc": "1.13.3",
|
||||||
"@tensorflow/tfjs": "4.22.0",
|
"@tensorflow/tfjs": "4.22.0",
|
||||||
"@tensorflow/tfjs-node": "4.22.0",
|
"@tensorflow/tfjs-node": "4.22.0",
|
||||||
"bufferutil": "4.0.9",
|
"bufferutil": "4.0.9",
|
||||||
|
@ -68,8 +68,8 @@
|
||||||
"utf-8-validate": "6.0.5"
|
"utf-8-validate": "6.0.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.826.0",
|
"@aws-sdk/client-s3": "3.864.0",
|
||||||
"@aws-sdk/lib-storage": "3.826.0",
|
"@aws-sdk/lib-storage": "3.864.0",
|
||||||
"@discordapp/twemoji": "16.0.1",
|
"@discordapp/twemoji": "16.0.1",
|
||||||
"@fastify/accepts": "5.0.2",
|
"@fastify/accepts": "5.0.2",
|
||||||
"@fastify/cookie": "11.0.2",
|
"@fastify/cookie": "11.0.2",
|
||||||
|
@ -80,19 +80,19 @@
|
||||||
"@fastify/static": "8.2.0",
|
"@fastify/static": "8.2.0",
|
||||||
"@fastify/view": "10.0.2",
|
"@fastify/view": "10.0.2",
|
||||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||||
"@misskey-dev/summaly": "5.2.1",
|
"@misskey-dev/summaly": "5.2.3",
|
||||||
"@napi-rs/canvas": "0.1.71",
|
"@napi-rs/canvas": "0.1.77",
|
||||||
"@nestjs/common": "11.1.3",
|
"@nestjs/common": "11.1.6",
|
||||||
"@nestjs/core": "11.1.3",
|
"@nestjs/core": "11.1.6",
|
||||||
"@nestjs/testing": "11.1.3",
|
"@nestjs/testing": "11.1.6",
|
||||||
"@peertube/http-signature": "1.7.0",
|
"@peertube/http-signature": "1.7.0",
|
||||||
"@sentry/node": "8.55.0",
|
"@sentry/node": "8.55.0",
|
||||||
"@sentry/profiling-node": "8.55.0",
|
"@sentry/profiling-node": "8.55.0",
|
||||||
"@simplewebauthn/server": "12.0.0",
|
"@simplewebauthn/server": "12.0.0",
|
||||||
"@sinonjs/fake-timers": "11.3.1",
|
"@sinonjs/fake-timers": "11.3.1",
|
||||||
"@smithy/node-http-handler": "2.5.0",
|
"@smithy/node-http-handler": "2.5.0",
|
||||||
"@swc/cli": "0.7.7",
|
"@swc/cli": "0.7.8",
|
||||||
"@swc/core": "1.12.0",
|
"@swc/core": "1.13.3",
|
||||||
"@twemoji/parser": "16.0.0",
|
"@twemoji/parser": "16.0.0",
|
||||||
"@types/redis-info": "3.0.3",
|
"@types/redis-info": "3.0.3",
|
||||||
"accepts": "1.3.8",
|
"accepts": "1.3.8",
|
||||||
|
@ -102,10 +102,10 @@
|
||||||
"bcryptjs": "2.4.3",
|
"bcryptjs": "2.4.3",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"body-parser": "1.20.3",
|
"body-parser": "1.20.3",
|
||||||
"bullmq": "5.53.2",
|
"bullmq": "5.56.9",
|
||||||
"cacheable-lookup": "7.0.0",
|
"cacheable-lookup": "7.0.0",
|
||||||
"cbor": "9.0.2",
|
"cbor": "9.0.2",
|
||||||
"chalk": "5.4.1",
|
"chalk": "5.5.0",
|
||||||
"chalk-template": "1.1.0",
|
"chalk-template": "1.1.0",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"cli-highlight": "2.1.11",
|
"cli-highlight": "2.1.11",
|
||||||
|
@ -113,18 +113,18 @@
|
||||||
"content-disposition": "0.5.4",
|
"content-disposition": "0.5.4",
|
||||||
"date-fns": "2.30.0",
|
"date-fns": "2.30.0",
|
||||||
"deep-email-validator": "0.1.21",
|
"deep-email-validator": "0.1.21",
|
||||||
"fastify": "5.3.3",
|
"fastify": "5.4.0",
|
||||||
"fastify-raw-body": "5.0.0",
|
"fastify-raw-body": "5.0.0",
|
||||||
"feed": "4.2.2",
|
"feed": "4.2.2",
|
||||||
"file-type": "19.6.0",
|
"file-type": "19.6.0",
|
||||||
"fluent-ffmpeg": "2.1.3",
|
"fluent-ffmpeg": "2.1.3",
|
||||||
"form-data": "4.0.3",
|
"form-data": "4.0.4",
|
||||||
"got": "14.4.7",
|
"got": "14.4.7",
|
||||||
"happy-dom": "16.8.1",
|
"happy-dom": "16.8.1",
|
||||||
"hpagent": "1.2.0",
|
"hpagent": "1.2.0",
|
||||||
"htmlescape": "1.1.1",
|
"htmlescape": "1.1.1",
|
||||||
"http-link-header": "1.1.3",
|
"http-link-header": "1.1.3",
|
||||||
"ioredis": "5.6.1",
|
"ioredis": "5.7.0",
|
||||||
"ip-cidr": "4.0.2",
|
"ip-cidr": "4.0.2",
|
||||||
"ipaddr.js": "2.2.0",
|
"ipaddr.js": "2.2.0",
|
||||||
"is-svg": "5.1.0",
|
"is-svg": "5.1.0",
|
||||||
|
@ -136,7 +136,7 @@
|
||||||
"juice": "11.0.1",
|
"juice": "11.0.1",
|
||||||
"meilisearch": "0.51.0",
|
"meilisearch": "0.51.0",
|
||||||
"mfm-js": "0.25.0",
|
"mfm-js": "0.25.0",
|
||||||
"microformats-parser": "2.0.3",
|
"microformats-parser": "2.0.4",
|
||||||
"mime-types": "2.1.35",
|
"mime-types": "2.1.35",
|
||||||
"misskey-js": "workspace:*",
|
"misskey-js": "workspace:*",
|
||||||
"misskey-reversi": "workspace:*",
|
"misskey-reversi": "workspace:*",
|
||||||
|
@ -152,7 +152,7 @@
|
||||||
"os-utils": "0.0.14",
|
"os-utils": "0.0.14",
|
||||||
"otpauth": "9.4.0",
|
"otpauth": "9.4.0",
|
||||||
"parse5": "7.3.0",
|
"parse5": "7.3.0",
|
||||||
"pg": "8.16.0",
|
"pg": "8.16.3",
|
||||||
"pkce-challenge": "4.1.0",
|
"pkce-challenge": "4.1.0",
|
||||||
"probe-image-size": "7.2.3",
|
"probe-image-size": "7.2.3",
|
||||||
"promise-limit": "2.7.0",
|
"promise-limit": "2.7.0",
|
||||||
|
@ -174,25 +174,25 @@
|
||||||
"slacc": "0.0.10",
|
"slacc": "0.0.10",
|
||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"stringz": "2.1.0",
|
"stringz": "2.1.0",
|
||||||
"systeminformation": "5.27.1",
|
"systeminformation": "5.27.7",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
"tmp": "0.2.3",
|
"tmp": "0.2.3",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typeorm": "0.3.24",
|
"typeorm": "0.3.25",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.9.2",
|
||||||
"ulid": "2.4.0",
|
"ulid": "2.4.0",
|
||||||
"vary": "1.1.2",
|
"vary": "1.1.2",
|
||||||
"web-push": "3.6.7",
|
"web-push": "3.6.7",
|
||||||
"ws": "8.18.2",
|
"ws": "8.18.3",
|
||||||
"xev": "3.0.2"
|
"xev": "3.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@jest/globals": "29.7.0",
|
"@jest/globals": "29.7.0",
|
||||||
"@nestjs/platform-express": "10.4.19",
|
"@nestjs/platform-express": "10.4.20",
|
||||||
"@sentry/vue": "9.28.0",
|
"@sentry/vue": "9.45.0",
|
||||||
"@simplewebauthn/types": "12.0.0",
|
"@simplewebauthn/types": "12.0.0",
|
||||||
"@swc/jest": "0.2.38",
|
"@swc/jest": "0.2.39",
|
||||||
"@types/accepts": "1.3.7",
|
"@types/accepts": "1.3.7",
|
||||||
"@types/archiver": "6.0.3",
|
"@types/archiver": "6.0.3",
|
||||||
"@types/bcryptjs": "2.4.6",
|
"@types/bcryptjs": "2.4.6",
|
||||||
|
@ -209,12 +209,12 @@
|
||||||
"@types/jsrsasign": "10.5.15",
|
"@types/jsrsasign": "10.5.15",
|
||||||
"@types/mime-types": "2.1.4",
|
"@types/mime-types": "2.1.4",
|
||||||
"@types/ms": "0.7.34",
|
"@types/ms": "0.7.34",
|
||||||
"@types/node": "22.15.31",
|
"@types/node": "22.17.1",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
"@types/oauth": "0.9.6",
|
"@types/oauth": "0.9.6",
|
||||||
"@types/oauth2orize": "1.11.5",
|
"@types/oauth2orize": "1.11.5",
|
||||||
"@types/oauth2orize-pkce": "0.1.2",
|
"@types/oauth2orize-pkce": "0.1.2",
|
||||||
"@types/pg": "8.15.4",
|
"@types/pg": "8.15.5",
|
||||||
"@types/pug": "2.0.10",
|
"@types/pug": "2.0.10",
|
||||||
"@types/qrcode": "1.5.5",
|
"@types/qrcode": "1.5.5",
|
||||||
"@types/random-seed": "0.3.5",
|
"@types/random-seed": "0.3.5",
|
||||||
|
@ -230,11 +230,11 @@
|
||||||
"@types/vary": "1.1.3",
|
"@types/vary": "1.1.3",
|
||||||
"@types/web-push": "3.6.4",
|
"@types/web-push": "3.6.4",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "8.34.0",
|
"@typescript-eslint/eslint-plugin": "8.39.0",
|
||||||
"@typescript-eslint/parser": "8.34.0",
|
"@typescript-eslint/parser": "8.39.0",
|
||||||
"aws-sdk-client-mock": "4.1.0",
|
"aws-sdk-client-mock": "4.1.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.32.0",
|
||||||
"execa": "8.0.1",
|
"execa": "8.0.1",
|
||||||
"fkill": "9.0.0",
|
"fkill": "9.0.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
|
@ -242,6 +242,6 @@
|
||||||
"nodemon": "3.1.10",
|
"nodemon": "3.1.10",
|
||||||
"pid-port": "1.0.2",
|
"pid-port": "1.0.2",
|
||||||
"simple-oauth2": "5.1.0",
|
"simple-oauth2": "5.1.0",
|
||||||
"supertest": "7.1.1"
|
"supertest": "7.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,6 +78,7 @@ import { ChannelFollowingService } from './ChannelFollowingService.js';
|
||||||
import { ChatService } from './ChatService.js';
|
import { ChatService } from './ChatService.js';
|
||||||
import { RegistryApiService } from './RegistryApiService.js';
|
import { RegistryApiService } from './RegistryApiService.js';
|
||||||
import { ReversiService } from './ReversiService.js';
|
import { ReversiService } from './ReversiService.js';
|
||||||
|
import { PageService } from './PageService.js';
|
||||||
|
|
||||||
import { ChartLoggerService } from './chart/ChartLoggerService.js';
|
import { ChartLoggerService } from './chart/ChartLoggerService.js';
|
||||||
import FederationChart from './chart/charts/federation.js';
|
import FederationChart from './chart/charts/federation.js';
|
||||||
|
@ -227,6 +228,7 @@ const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService',
|
||||||
const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
|
const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
|
||||||
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
||||||
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
|
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
|
||||||
|
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
|
||||||
|
|
||||||
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
||||||
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
||||||
|
@ -379,6 +381,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ChatService,
|
ChatService,
|
||||||
RegistryApiService,
|
RegistryApiService,
|
||||||
ReversiService,
|
ReversiService,
|
||||||
|
PageService,
|
||||||
|
|
||||||
ChartLoggerService,
|
ChartLoggerService,
|
||||||
FederationChart,
|
FederationChart,
|
||||||
|
@ -527,6 +530,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$ChatService,
|
$ChatService,
|
||||||
$RegistryApiService,
|
$RegistryApiService,
|
||||||
$ReversiService,
|
$ReversiService,
|
||||||
|
$PageService,
|
||||||
|
|
||||||
$ChartLoggerService,
|
$ChartLoggerService,
|
||||||
$FederationChart,
|
$FederationChart,
|
||||||
|
@ -676,6 +680,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ChatService,
|
ChatService,
|
||||||
RegistryApiService,
|
RegistryApiService,
|
||||||
ReversiService,
|
ReversiService,
|
||||||
|
PageService,
|
||||||
|
|
||||||
FederationChart,
|
FederationChart,
|
||||||
NotesChart,
|
NotesChart,
|
||||||
|
@ -822,6 +827,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$ChatService,
|
$ChatService,
|
||||||
$RegistryApiService,
|
$RegistryApiService,
|
||||||
$ReversiService,
|
$ReversiService,
|
||||||
|
$PageService,
|
||||||
|
|
||||||
$FederationChart,
|
$FederationChart,
|
||||||
$NotesChart,
|
$NotesChart,
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
|
import * as stream from 'node:stream';
|
||||||
import ipaddr from 'ipaddr.js';
|
import ipaddr from 'ipaddr.js';
|
||||||
import CacheableLookup from 'cacheable-lookup';
|
import CacheableLookup from 'cacheable-lookup';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
@ -26,12 +27,6 @@ export type HttpRequestSendOptions = {
|
||||||
validators?: ((res: Response) => void)[];
|
validators?: ((res: Response) => void)[];
|
||||||
};
|
};
|
||||||
|
|
||||||
declare module 'node:http' {
|
|
||||||
interface Agent {
|
|
||||||
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class HttpRequestServiceAgent extends http.Agent {
|
class HttpRequestServiceAgent extends http.Agent {
|
||||||
constructor(
|
constructor(
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
@ -41,11 +36,11 @@ class HttpRequestServiceAgent extends http.Agent {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
public createConnection(options: http.ClientRequestArgs, callback?: (err: Error | null, stream: stream.Duplex) => void): stream.Duplex {
|
||||||
const socket = super.createConnection(options, callback)
|
const socket = super.createConnection(options, callback)
|
||||||
.on('connect', () => {
|
.on('connect', () => {
|
||||||
|
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
|
||||||
const address = socket.remoteAddress;
|
const address = socket.remoteAddress;
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
if (address && ipaddr.isValid(address)) {
|
if (address && ipaddr.isValid(address)) {
|
||||||
if (this.isPrivateIp(address)) {
|
if (this.isPrivateIp(address)) {
|
||||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||||
|
@ -80,11 +75,11 @@ class HttpsRequestServiceAgent extends https.Agent {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
public createConnection(options: http.ClientRequestArgs, callback?: (err: Error | null, stream: stream.Duplex) => void): stream.Duplex {
|
||||||
const socket = super.createConnection(options, callback)
|
const socket = super.createConnection(options, callback)
|
||||||
.on('connect', () => {
|
.on('connect', () => {
|
||||||
|
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
|
||||||
const address = socket.remoteAddress;
|
const address = socket.remoteAddress;
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
if (address && ipaddr.isValid(address)) {
|
if (address && ipaddr.isValid(address)) {
|
||||||
if (this.isPrivateIp(address)) {
|
if (this.isPrivateIp(address)) {
|
||||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||||
|
|
223
packages/backend/src/core/PageService.ts
Normal file
223
packages/backend/src/core/PageService.ts
Normal file
|
@ -0,0 +1,223 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, In, Not } from 'typeorm';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import {
|
||||||
|
type NotesRepository,
|
||||||
|
MiPage,
|
||||||
|
type PagesRepository,
|
||||||
|
MiDriveFile,
|
||||||
|
type UsersRepository,
|
||||||
|
MiNote,
|
||||||
|
} from '@/models/_.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import type { MiUser } from '@/models/User.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
|
|
||||||
|
export interface PageBody {
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
summary: string | null;
|
||||||
|
content: Array<Record<string, any>>;
|
||||||
|
variables: Array<Record<string, any>>;
|
||||||
|
script: string;
|
||||||
|
eyeCatchingImage?: MiDriveFile | null;
|
||||||
|
font: string;
|
||||||
|
alignCenter: boolean;
|
||||||
|
hideTitleWhenPinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PageService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.db)
|
||||||
|
private db: DataSource,
|
||||||
|
|
||||||
|
@Inject(DI.pagesRepository)
|
||||||
|
private pagesRepository: PagesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.notesRepository)
|
||||||
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
|
private roleService: RoleService,
|
||||||
|
private moderationLogService: ModerationLogService,
|
||||||
|
private idService: IdService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async create(
|
||||||
|
me: MiUser,
|
||||||
|
body: PageBody,
|
||||||
|
): Promise<MiPage> {
|
||||||
|
await this.pagesRepository.findBy({
|
||||||
|
userId: me.id,
|
||||||
|
name: body.name,
|
||||||
|
}).then(result => {
|
||||||
|
if (result.length > 0) {
|
||||||
|
throw new IdentifiableError('1a79e38e-3d83-4423-845b-a9d83ff93b61');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await this.pagesRepository.insertOne(new MiPage({
|
||||||
|
id: this.idService.gen(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
title: body.title,
|
||||||
|
name: body.name,
|
||||||
|
summary: body.summary,
|
||||||
|
content: body.content,
|
||||||
|
variables: body.variables,
|
||||||
|
script: body.script,
|
||||||
|
eyeCatchingImageId: body.eyeCatchingImage ? body.eyeCatchingImage.id : null,
|
||||||
|
userId: me.id,
|
||||||
|
visibility: 'public',
|
||||||
|
alignCenter: body.alignCenter,
|
||||||
|
hideTitleWhenPinned: body.hideTitleWhenPinned,
|
||||||
|
font: body.font,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const referencedNotes = this.collectReferencedNotes(page.content);
|
||||||
|
if (referencedNotes.length > 0) {
|
||||||
|
await this.notesRepository.increment({ id: In(referencedNotes) }, 'pageCount', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async update(
|
||||||
|
me: MiUser,
|
||||||
|
pageId: MiPage['id'],
|
||||||
|
body: Partial<PageBody>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.db.transaction(async (transaction) => {
|
||||||
|
const page = await transaction.findOne(MiPage, {
|
||||||
|
where: {
|
||||||
|
id: pageId,
|
||||||
|
},
|
||||||
|
lock: { mode: 'for_no_key_update' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (page == null) {
|
||||||
|
throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f');
|
||||||
|
}
|
||||||
|
if (page.userId !== me.id) {
|
||||||
|
throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.name != null) {
|
||||||
|
await transaction.findBy(MiPage, {
|
||||||
|
id: Not(pageId),
|
||||||
|
userId: me.id,
|
||||||
|
name: body.name,
|
||||||
|
}).then(result => {
|
||||||
|
if (result.length > 0) {
|
||||||
|
throw new IdentifiableError('d05bfe24-24b6-4ea2-a3ec-87cc9bf4daa4');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.update(MiPage, page.id, {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
title: body.title,
|
||||||
|
name: body.name,
|
||||||
|
summary: body.summary === undefined ? page.summary : body.summary,
|
||||||
|
content: body.content,
|
||||||
|
variables: body.variables,
|
||||||
|
script: body.script,
|
||||||
|
alignCenter: body.alignCenter,
|
||||||
|
hideTitleWhenPinned: body.hideTitleWhenPinned,
|
||||||
|
font: body.font,
|
||||||
|
eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("page.content", page.content);
|
||||||
|
|
||||||
|
if (body.content != null) {
|
||||||
|
const beforeReferencedNotes = this.collectReferencedNotes(page.content);
|
||||||
|
const afterReferencedNotes = this.collectReferencedNotes(body.content);
|
||||||
|
|
||||||
|
const removedNotes = beforeReferencedNotes.filter(noteId => !afterReferencedNotes.includes(noteId));
|
||||||
|
const addedNotes = afterReferencedNotes.filter(noteId => !beforeReferencedNotes.includes(noteId));
|
||||||
|
|
||||||
|
if (removedNotes.length > 0) {
|
||||||
|
await transaction.decrement(MiNote, { id: In(removedNotes) }, 'pageCount', 1);
|
||||||
|
}
|
||||||
|
if (addedNotes.length > 0) {
|
||||||
|
await transaction.increment(MiNote, { id: In(addedNotes) }, 'pageCount', 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async delete(me: MiUser, pageId: MiPage['id']): Promise<void> {
|
||||||
|
await this.db.transaction(async (transaction) => {
|
||||||
|
const page = await transaction.findOne(MiPage, {
|
||||||
|
where: {
|
||||||
|
id: pageId,
|
||||||
|
},
|
||||||
|
lock: { mode: 'pessimistic_write' }, // same lock level as DELETE
|
||||||
|
});
|
||||||
|
|
||||||
|
if (page == null) {
|
||||||
|
throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await this.roleService.isModerator(me) && page.userId !== me.id) {
|
||||||
|
throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616');
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.delete(MiPage, page.id);
|
||||||
|
|
||||||
|
if (page.userId !== me.id) {
|
||||||
|
const user = await this.usersRepository.findOneByOrFail({ id: page.userId });
|
||||||
|
this.moderationLogService.log(me, 'deletePage', {
|
||||||
|
pageId: page.id,
|
||||||
|
pageUserId: page.userId,
|
||||||
|
pageUserUsername: user.username,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencedNotes = this.collectReferencedNotes(page.content);
|
||||||
|
if (referencedNotes.length > 0) {
|
||||||
|
await transaction.decrement(MiNote, { id: In(referencedNotes) }, 'pageCount', 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
collectReferencedNotes(content: MiPage['content']): string[] {
|
||||||
|
const referencingNotes = new Set<string>();
|
||||||
|
const recursiveCollect = (content: unknown[]) => {
|
||||||
|
for (const contentElement of content) {
|
||||||
|
if (typeof contentElement === 'object'
|
||||||
|
&& contentElement !== null
|
||||||
|
&& 'type' in contentElement) {
|
||||||
|
if (contentElement.type === 'note'
|
||||||
|
&& 'note' in contentElement
|
||||||
|
&& typeof contentElement.note === 'string') {
|
||||||
|
referencingNotes.add(contentElement.note);
|
||||||
|
}
|
||||||
|
if (contentElement.type === 'section'
|
||||||
|
&& 'children' in contentElement
|
||||||
|
&& Array.isArray(contentElement.children)) {
|
||||||
|
recursiveCollect(contentElement.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recursiveCollect(content);
|
||||||
|
return [...referencingNotes];
|
||||||
|
}
|
||||||
|
}
|
|
@ -103,6 +103,7 @@ export class QueueService {
|
||||||
for (const def of REPEATABLE_SYSTEM_JOB_DEF) {
|
for (const def of REPEATABLE_SYSTEM_JOB_DEF) {
|
||||||
this.systemQueue.upsertJobScheduler(def.name, {
|
this.systemQueue.upsertJobScheduler(def.name, {
|
||||||
pattern: def.pattern,
|
pattern: def.pattern,
|
||||||
|
immediately: false,
|
||||||
}, {
|
}, {
|
||||||
name: def.name,
|
name: def.name,
|
||||||
opts: {
|
opts: {
|
||||||
|
|
|
@ -43,6 +43,7 @@ export type RolePolicies = {
|
||||||
canManageCustomEmojis: boolean;
|
canManageCustomEmojis: boolean;
|
||||||
canManageAvatarDecorations: boolean;
|
canManageAvatarDecorations: boolean;
|
||||||
canSearchNotes: boolean;
|
canSearchNotes: boolean;
|
||||||
|
canSearchUsers: boolean;
|
||||||
canUseTranslator: boolean;
|
canUseTranslator: boolean;
|
||||||
canHideAds: boolean;
|
canHideAds: boolean;
|
||||||
driveCapacityMb: number;
|
driveCapacityMb: number;
|
||||||
|
@ -82,6 +83,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||||
canManageCustomEmojis: false,
|
canManageCustomEmojis: false,
|
||||||
canManageAvatarDecorations: false,
|
canManageAvatarDecorations: false,
|
||||||
canSearchNotes: false,
|
canSearchNotes: false,
|
||||||
|
canSearchUsers: true,
|
||||||
canUseTranslator: true,
|
canUseTranslator: true,
|
||||||
canHideAds: false,
|
canHideAds: false,
|
||||||
driveCapacityMb: 100,
|
driveCapacityMb: 100,
|
||||||
|
@ -402,6 +404,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
|
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
|
||||||
canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)),
|
canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)),
|
||||||
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
|
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
|
||||||
|
canSearchUsers: calc('canSearchUsers', vs => vs.some(v => v === true)),
|
||||||
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
|
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
|
||||||
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
|
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
|
||||||
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
|
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
|
||||||
|
|
|
@ -85,6 +85,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
||||||
renoteCount: 10,
|
renoteCount: 10,
|
||||||
repliesCount: 5,
|
repliesCount: 5,
|
||||||
clippedCount: 0,
|
clippedCount: 0,
|
||||||
|
pageCount: 0,
|
||||||
reactions: {},
|
reactions: {},
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
uri: null,
|
uri: null,
|
||||||
|
|
|
@ -114,6 +114,13 @@ export class MiNote {
|
||||||
})
|
})
|
||||||
public clippedCount: number;
|
public clippedCount: number;
|
||||||
|
|
||||||
|
// The number of note page blocks referencing this note.
|
||||||
|
// This column is used by Remote Note Cleaning and manually updated rather than automatically with triggers.
|
||||||
|
@Column('smallint', {
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
public pageCount: number;
|
||||||
|
|
||||||
@Column('jsonb', {
|
@Column('jsonb', {
|
||||||
default: {},
|
default: {},
|
||||||
})
|
})
|
||||||
|
|
|
@ -212,6 +212,10 @@ export const packedRolePoliciesSchema = {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
},
|
},
|
||||||
|
canSearchUsers: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
canUseTranslator: {
|
canUseTranslator: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
|
|
||||||
import { setTimeout } from 'node:timers/promises';
|
import { setTimeout } from 'node:timers/promises';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { And, Brackets, In, IsNull, LessThan, MoreThan, Not } from 'typeorm';
|
import { DataSource, IsNull, LessThan, QueryFailedError, Not } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { MiMeta, MiNote, NoteFavoritesRepository, NotesRepository, UserNotePiningsRepository } from '@/models/_.js';
|
import type { MiMeta, MiNote, NotesRepository } from '@/models/_.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
@ -25,11 +25,8 @@ export class CleanRemoteNotesProcessorService {
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
@Inject(DI.noteFavoritesRepository)
|
@Inject(DI.db)
|
||||||
private noteFavoritesRepository: NoteFavoritesRepository,
|
private db: DataSource,
|
||||||
|
|
||||||
@Inject(DI.userNotePiningsRepository)
|
|
||||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
|
||||||
|
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private queueLoggerService: QueueLoggerService,
|
private queueLoggerService: QueueLoggerService,
|
||||||
|
@ -37,12 +34,22 @@ export class CleanRemoteNotesProcessorService {
|
||||||
this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-notes');
|
this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-notes');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private computeProgress(minId: string, maxId: string, cursorLeft: string) {
|
||||||
|
const minTs = this.idService.parse(minId).date.getTime();
|
||||||
|
const maxTs = this.idService.parse(maxId).date.getTime();
|
||||||
|
const cursorTs = this.idService.parse(cursorLeft).date.getTime();
|
||||||
|
|
||||||
|
return ((cursorTs - minTs) / (maxTs - minTs)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async process(job: Bull.Job<Record<string, unknown>>): Promise<{
|
public async process(job: Bull.Job<Record<string, unknown>>): Promise<{
|
||||||
deletedCount: number;
|
deletedCount: number;
|
||||||
oldest: number | null;
|
oldest: number | null;
|
||||||
newest: number | null;
|
newest: number | null;
|
||||||
skipped?: boolean;
|
skipped: boolean;
|
||||||
|
transientErrors: number;
|
||||||
}> {
|
}> {
|
||||||
if (!this.meta.enableRemoteNotesCleaning) {
|
if (!this.meta.enableRemoteNotesCleaning) {
|
||||||
this.logger.info('Remote notes cleaning is disabled, skipping...');
|
this.logger.info('Remote notes cleaning is disabled, skipping...');
|
||||||
|
@ -51,6 +58,7 @@ export class CleanRemoteNotesProcessorService {
|
||||||
oldest: null,
|
oldest: null,
|
||||||
newest: null,
|
newest: null,
|
||||||
skipped: true,
|
skipped: true,
|
||||||
|
transientErrors: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,27 +67,11 @@ export class CleanRemoteNotesProcessorService {
|
||||||
const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
|
const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
|
||||||
const startAt = Date.now();
|
const startAt = Date.now();
|
||||||
|
|
||||||
const MAX_NOTE_COUNT_PER_QUERY = 50;
|
//#region queries
|
||||||
|
|
||||||
const stats = {
|
|
||||||
deletedCount: 0,
|
|
||||||
oldest: null as number | null,
|
|
||||||
newest: null as number | null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// The date limit for the newest note to be considered for deletion.
|
// The date limit for the newest note to be considered for deletion.
|
||||||
// All notes newer than this limit will always be retained.
|
// All notes newer than this limit will always be retained.
|
||||||
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
|
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
|
||||||
|
|
||||||
let cursor = '0'; // oldest note ID to start from
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const batchBeginAt = Date.now();
|
|
||||||
|
|
||||||
// We use string literals instead of query builder for several reasons:
|
|
||||||
// - for removeCondition, we need to use it in having clause, which is not supported by Brackets.
|
|
||||||
// - for recursive part, we need to preserve the order of columns, but typeorm query builder does not guarantee the order of columns in the result query
|
|
||||||
|
|
||||||
// The condition for removing the notes.
|
// The condition for removing the notes.
|
||||||
// The note must be:
|
// The note must be:
|
||||||
// - old enough (older than the newestLimit)
|
// - old enough (older than the newestLimit)
|
||||||
|
@ -87,64 +79,167 @@ export class CleanRemoteNotesProcessorService {
|
||||||
// - not have clipped
|
// - not have clipped
|
||||||
// - not have pinned on the user profile
|
// - not have pinned on the user profile
|
||||||
// - not has been favorite by any user
|
// - not has been favorite by any user
|
||||||
const removeCondition = 'note.id < :newestLimit'
|
const removalCriteria = [
|
||||||
+ ' AND note."clippedCount" = 0'
|
'note."id" < :newestLimit',
|
||||||
+ ' AND note."userHost" IS NOT NULL'
|
'note."clippedCount" = 0',
|
||||||
// using both userId and noteId instead of just noteId to use index on user_note_pining table.
|
'note."pageCount" = 0',
|
||||||
// This is safe because notes are only pinned by the user who created them.
|
'note."userHost" IS NOT NULL',
|
||||||
+ ' AND NOT EXISTS(SELECT 1 FROM "user_note_pining" WHERE "noteId" = note."id" AND "userId" = note."userId")'
|
'NOT EXISTS (SELECT 1 FROM user_note_pining WHERE "noteId" = note."id")',
|
||||||
// We cannot use userId trick because users can favorite notes from other users.
|
'NOT EXISTS (SELECT 1 FROM note_favorite WHERE "noteId" = note."id")',
|
||||||
+ ' AND NOT EXISTS(SELECT 1 FROM "note_favorite" WHERE "noteId" = note."id")'
|
].join(' AND ');
|
||||||
;
|
|
||||||
|
|
||||||
// The initiator query contains the oldest ${MAX_NOTE_COUNT_PER_QUERY} remote non-clipped notes
|
const minId = (await this.notesRepository.createQueryBuilder('note')
|
||||||
const initiatorQuery = `
|
.select('MIN(note.id)', 'minId')
|
||||||
SELECT "note"."id" AS "id", "note"."replyId" AS "replyId", "note"."renoteId" AS "renoteId", "note"."id" AS "initiatorId"
|
.where({
|
||||||
FROM "note" "note" WHERE ${removeCondition} AND "note"."id" > :cursor ORDER BY "note"."id" ASC LIMIT ${MAX_NOTE_COUNT_PER_QUERY}`;
|
id: LessThan(newestLimit),
|
||||||
|
userHost: Not(IsNull()),
|
||||||
|
replyId: IsNull(),
|
||||||
|
renoteId: IsNull(),
|
||||||
|
})
|
||||||
|
.getRawOne<{ minId?: MiNote['id'] }>())?.minId;
|
||||||
|
|
||||||
// The union query queries the related notes and replies related to the initiator query
|
if (!minId) {
|
||||||
const unionQuery = `
|
this.logger.info('No notes can possibly be deleted, skipping...');
|
||||||
SELECT "note"."id", "note"."replyId", "note"."renoteId", rn."initiatorId"
|
return {
|
||||||
FROM "note" "note"
|
deletedCount: 0,
|
||||||
INNER JOIN "related_notes" "rn"
|
oldest: null,
|
||||||
ON "note"."replyId" = rn.id
|
newest: null,
|
||||||
OR "note"."renoteId" = rn.id
|
skipped: false,
|
||||||
OR "note"."id" = rn."replyId"
|
transientErrors: 0,
|
||||||
OR "note"."id" = rn."renoteId"
|
};
|
||||||
`;
|
}
|
||||||
const recursiveQuery = `(${initiatorQuery}) UNION (${unionQuery})`;
|
|
||||||
|
|
||||||
const removableInitiatorNotesQuery = this.notesRepository.createQueryBuilder('note')
|
// start with a conservative limit and adjust it based on the query duration
|
||||||
.select('rn."initiatorId"')
|
const minimumLimit = 10;
|
||||||
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
|
let currentLimit = 100;
|
||||||
.groupBy('rn."initiatorId"')
|
let cursorLeft = '0';
|
||||||
.having(`bool_and(${removeCondition})`);
|
|
||||||
|
|
||||||
const notesQuery = this.notesRepository.createQueryBuilder('note')
|
const candidateNotesCteName = 'candidate_notes';
|
||||||
.addCommonTableExpression(recursiveQuery, 'related_notes', { recursive: true })
|
|
||||||
|
// tree walk down all root notes, short-circuit when the first unremovable note is found
|
||||||
|
const candidateNotesQueryBase = this.notesRepository.createQueryBuilder('note')
|
||||||
|
.select('note."id"', 'id')
|
||||||
|
.addSelect('note."replyId"', 'replyId')
|
||||||
|
.addSelect('note."renoteId"', 'renoteId')
|
||||||
|
.addSelect('note."id"', 'rootId')
|
||||||
|
.addSelect('TRUE', 'isRemovable')
|
||||||
|
.addSelect('TRUE', 'isBase')
|
||||||
|
.where('note."id" > :cursorLeft')
|
||||||
|
.andWhere(removalCriteria)
|
||||||
|
.andWhere({ replyId: IsNull(), renoteId: IsNull() });
|
||||||
|
|
||||||
|
const candidateNotesQueryInductive = this.notesRepository.createQueryBuilder('note')
|
||||||
.select('note.id', 'id')
|
.select('note.id', 'id')
|
||||||
.addSelect('rn."initiatorId"')
|
.addSelect('note."replyId"', 'replyId')
|
||||||
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
|
.addSelect('note."renoteId"', 'renoteId')
|
||||||
.where(`rn."initiatorId" IN (${ removableInitiatorNotesQuery.getQuery() })`)
|
.addSelect('parent."rootId"', 'rootId')
|
||||||
.setParameters({ cursor, newestLimit });
|
.addSelect(removalCriteria, 'isRemovable')
|
||||||
|
.addSelect('FALSE', 'isBase')
|
||||||
|
.innerJoin(candidateNotesCteName, 'parent', 'parent."id" = note."replyId" OR parent."id" = note."renoteId"')
|
||||||
|
.where('parent."isRemovable" = TRUE');
|
||||||
|
|
||||||
const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.getRawMany();
|
// A note tree can be deleted if there are no unremovable rows with the same rootId.
|
||||||
|
//
|
||||||
|
// `candidate_notes` will have the following structure after recursive query (some columns omitted):
|
||||||
|
// After performing a LEFT JOIN with `candidate_notes` as `unremovable`,
|
||||||
|
// the note tree containing unremovable notes will be anti-joined.
|
||||||
|
// For removable rows, the `unremovable` columns will have `NULL` values.
|
||||||
|
// | id | rootId | isRemovable |
|
||||||
|
// |-----|--------|-------------|
|
||||||
|
// | aaa | aaa | TRUE |
|
||||||
|
// | bbb | aaa | FALSE |
|
||||||
|
// | ccc | aaa | FALSE |
|
||||||
|
// | ddd | ddd | TRUE |
|
||||||
|
// | eee | ddd | TRUE |
|
||||||
|
// | fff | fff | TRUE |
|
||||||
|
// | ggg | ggg | FALSE |
|
||||||
|
//
|
||||||
|
const candidateNotesQuery = this.db.createQueryBuilder()
|
||||||
|
.select(`"${candidateNotesCteName}"."id"`, 'id')
|
||||||
|
.addSelect('unremovable."id" IS NULL', 'isRemovable')
|
||||||
|
.addSelect(`BOOL_OR("${candidateNotesCteName}"."isBase")`, 'isBase')
|
||||||
|
.addCommonTableExpression(
|
||||||
|
`((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(currentLimit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
|
||||||
|
candidateNotesCteName,
|
||||||
|
{ recursive: true },
|
||||||
|
)
|
||||||
|
.from(candidateNotesCteName, candidateNotesCteName)
|
||||||
|
.leftJoin(candidateNotesCteName, 'unremovable', `unremovable."rootId" = "${candidateNotesCteName}"."rootId" AND unremovable."isRemovable" = FALSE`)
|
||||||
|
.groupBy(`"${candidateNotesCteName}"."id"`)
|
||||||
|
.addGroupBy('unremovable."id" IS NULL');
|
||||||
|
|
||||||
const fetchedCount = notes.length;
|
const stats = {
|
||||||
|
deletedCount: 0,
|
||||||
|
oldest: null as number | null,
|
||||||
|
newest: null as number | null,
|
||||||
|
};
|
||||||
|
|
||||||
// update the cursor to the newest initiatorId found in the fetched notes.
|
let lowThroughputWarned = false;
|
||||||
// We don't use 'id' since the note can be newer than the initiator note.
|
let transientErrors = 0;
|
||||||
for (const note of notes) {
|
for (;;) {
|
||||||
if (cursor < note.initiatorId) {
|
//#region check time
|
||||||
cursor = note.initiatorId;
|
const batchBeginAt = Date.now();
|
||||||
}
|
|
||||||
|
const elapsed = batchBeginAt - startAt;
|
||||||
|
|
||||||
|
const progress = this.computeProgress(minId, newestLimit, cursorLeft > minId ? cursorLeft : minId);
|
||||||
|
|
||||||
|
if (elapsed >= maxDuration) {
|
||||||
|
job.log(`Reached maximum duration of ${maxDuration}ms, stopping... (last cursor: ${cursorLeft}, final progress ${progress}%)`);
|
||||||
|
job.updateProgress(100);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notes.length > 0) {
|
const wallClockUsage = elapsed / maxDuration;
|
||||||
await this.notesRepository.delete(notes.map(note => note.id));
|
if (wallClockUsage > 0.5 && progress < 50 && !lowThroughputWarned) {
|
||||||
|
const msg = `Not projected to finish in time! (wall clock usage ${wallClockUsage * 100}% at ${progress}%, current limit ${currentLimit})`;
|
||||||
|
this.logger.warn(msg);
|
||||||
|
job.log(msg);
|
||||||
|
lowThroughputWarned = true;
|
||||||
|
}
|
||||||
|
job.updateProgress(progress);
|
||||||
|
//#endregion
|
||||||
|
|
||||||
for (const note of notes) {
|
const queryBegin = performance.now();
|
||||||
const t = this.idService.parse(note.id).date.getTime();
|
let noteIds = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
noteIds = await candidateNotesQuery.setParameters(
|
||||||
|
{ newestLimit, cursorLeft },
|
||||||
|
).getRawMany<{ id: MiNote['id'], isRemovable: boolean, isBase: boolean }>();
|
||||||
|
} catch (e) {
|
||||||
|
if (currentLimit > minimumLimit && e instanceof QueryFailedError && e.driverError?.code === '57014') {
|
||||||
|
// Statement timeout (maybe suddenly hit a large note tree), reduce the limit and try again
|
||||||
|
// continuous failures will eventually converge to currentLimit == minimumLimit and then throw
|
||||||
|
currentLimit = Math.max(minimumLimit, Math.floor(currentLimit * 0.25));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noteIds.length === 0) {
|
||||||
|
job.log('No more notes to clean.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryDuration = performance.now() - queryBegin;
|
||||||
|
// try to adjust such that each query takes about 1~5 seconds and reasonable NodeJS heap so the task stays responsive
|
||||||
|
// this should not oscillate..
|
||||||
|
if (queryDuration > 5000 || noteIds.length > 5000) {
|
||||||
|
currentLimit = Math.floor(currentLimit * 0.5);
|
||||||
|
} else if (queryDuration < 1000 && noteIds.length < 1000) {
|
||||||
|
currentLimit = Math.floor(currentLimit * 1.5);
|
||||||
|
}
|
||||||
|
// clamp to a sane range
|
||||||
|
currentLimit = Math.min(Math.max(currentLimit, minimumLimit), 5000);
|
||||||
|
|
||||||
|
const deletableNoteIds = noteIds.filter(result => result.isRemovable).map(result => result.id);
|
||||||
|
if (deletableNoteIds.length > 0) {
|
||||||
|
try {
|
||||||
|
await this.notesRepository.delete(deletableNoteIds);
|
||||||
|
|
||||||
|
for (const id of deletableNoteIds) {
|
||||||
|
const t = this.idService.parse(id).date.getTime();
|
||||||
if (stats.oldest === null || t < stats.oldest) {
|
if (stats.oldest === null || t < stats.oldest) {
|
||||||
stats.oldest = t;
|
stats.oldest = t;
|
||||||
}
|
}
|
||||||
|
@ -153,25 +248,33 @@ export class CleanRemoteNotesProcessorService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.deletedCount += notes.length;
|
stats.deletedCount += deletableNoteIds.length;
|
||||||
|
} catch (e) {
|
||||||
|
// check for integrity violation errors (class 23) that might have occurred between the check and the delete
|
||||||
|
// we can safely continue to the next batch
|
||||||
|
if (e instanceof QueryFailedError && e.driverError?.code?.startsWith('23')) {
|
||||||
|
transientErrors++;
|
||||||
|
job.log(`Error deleting notes: ${e} (transient race condition?)`);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
job.log(`Deleted ${notes.length} of ${fetchedCount}; ${Date.now() - batchBeginAt}ms`);
|
cursorLeft = noteIds.filter(result => result.isBase).reduce((max, { id }) => id > max ? id : max, cursorLeft);
|
||||||
|
|
||||||
const elapsed = Date.now() - startAt;
|
job.log(`Deleted ${noteIds.length} notes; ${Date.now() - batchBeginAt}ms`);
|
||||||
|
|
||||||
if (elapsed >= maxDuration) {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
this.logger.info(`Reached maximum duration of ${maxDuration}ms, stopping...`);
|
await setTimeout(Math.min(1000 * 5, queryDuration)); // Wait a moment to avoid overwhelming the db
|
||||||
job.log('Reached maximum duration, stopping cleaning.');
|
|
||||||
job.updateProgress(100);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
job.updateProgress((elapsed / maxDuration) * 100);
|
if (transientErrors > 0) {
|
||||||
|
const msg = `${transientErrors} transient errors occurred while cleaning remote notes. You may need a second pass to complete the cleaning.`;
|
||||||
await setTimeout(1000 * 5); // Wait a moment to avoid overwhelming the db
|
this.logger.warn(msg);
|
||||||
|
job.log(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.succ('cleaning of remote notes completed.');
|
this.logger.succ('cleaning of remote notes completed.');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -179,6 +282,7 @@ export class CleanRemoteNotesProcessorService {
|
||||||
oldest: stats.oldest,
|
oldest: stats.oldest,
|
||||||
newest: stats.newest,
|
newest: stats.newest,
|
||||||
skipped: false,
|
skipped: false,
|
||||||
|
transientErrors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { MoreThan } from 'typeorm';
|
import { MoreThan } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
import type { DriveFilesRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { DriveService } from '@/core/DriveService.js';
|
import { DriveService } from '@/core/DriveService.js';
|
||||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||||
|
@ -14,6 +14,7 @@ import type { MiNote } from '@/models/Note.js';
|
||||||
import { EmailService } from '@/core/EmailService.js';
|
import { EmailService } from '@/core/EmailService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { SearchService } from '@/core/SearchService.js';
|
import { SearchService } from '@/core/SearchService.js';
|
||||||
|
import { PageService } from '@/core/PageService.js';
|
||||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||||
import type * as Bull from 'bullmq';
|
import type * as Bull from 'bullmq';
|
||||||
import type { DbUserDeleteJobData } from '../types.js';
|
import type { DbUserDeleteJobData } from '../types.js';
|
||||||
|
@ -35,7 +36,11 @@ export class DeleteAccountProcessorService {
|
||||||
@Inject(DI.driveFilesRepository)
|
@Inject(DI.driveFilesRepository)
|
||||||
private driveFilesRepository: DriveFilesRepository,
|
private driveFilesRepository: DriveFilesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.pagesRepository)
|
||||||
|
private pagesRepository: PagesRepository,
|
||||||
|
|
||||||
private driveService: DriveService,
|
private driveService: DriveService,
|
||||||
|
private pageService: PageService,
|
||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
private queueLoggerService: QueueLoggerService,
|
private queueLoggerService: QueueLoggerService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
|
@ -112,6 +117,28 @@ export class DeleteAccountProcessorService {
|
||||||
this.logger.succ('All of files deleted');
|
this.logger.succ('All of files deleted');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// delete pages. Necessary for decrementing pageCount of notes.
|
||||||
|
while (true) {
|
||||||
|
const pages = await this.pagesRepository.find({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
order: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (const page of pages) {
|
||||||
|
await this.pageService.delete(user, page.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{ // Send email notification
|
{ // Send email notification
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||||
if (profile.email && profile.emailVerified) {
|
if (profile.email && profile.emailVerified) {
|
||||||
|
|
|
@ -48,8 +48,8 @@ export const paramDef = {
|
||||||
},
|
},
|
||||||
secret: {
|
secret: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
minLength: 1,
|
|
||||||
maxLength: 1024,
|
maxLength: 1024,
|
||||||
|
default: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
|
@ -57,7 +57,6 @@ export const paramDef = {
|
||||||
'name',
|
'name',
|
||||||
'on',
|
'on',
|
||||||
'url',
|
'url',
|
||||||
'secret',
|
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
@ -52,8 +52,8 @@ export const paramDef = {
|
||||||
},
|
},
|
||||||
secret: {
|
secret: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
minLength: 1,
|
|
||||||
maxLength: 1024,
|
maxLength: 1024,
|
||||||
|
default: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
|
@ -62,7 +62,6 @@ export const paramDef = {
|
||||||
'name',
|
'name',
|
||||||
'on',
|
'on',
|
||||||
'url',
|
'url',
|
||||||
'secret',
|
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private chatService: ChatService,
|
private chatService: ChatService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
await this.chatService.checkChatAvailability(me.id, 'read');
|
||||||
|
|
||||||
await this.chatService.readAllChatMessages(me.id);
|
await this.chatService.readAllChatMessages(me.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { QueryService } from '@/core/QueryService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
|
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
|
||||||
|
import { ChatService } from '@/core/ChatService.js';
|
||||||
import { ApiError } from '../../../error.js';
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -60,14 +61,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
@Inject(DI.chatMessagesRepository)
|
@Inject(DI.chatMessagesRepository)
|
||||||
private chatMessagesRepository: ChatMessagesRepository,
|
private chatMessagesRepository: ChatMessagesRepository,
|
||||||
|
|
||||||
|
private chatService: ChatService,
|
||||||
private chatEntityService: ChatEntityService,
|
private chatEntityService: ChatEntityService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const isModerator = await this.roleService.isModerator(me);
|
||||||
|
|
||||||
|
if (!isModerator) {
|
||||||
|
await this.chatService.checkChatAvailability(me.id, 'read');
|
||||||
|
}
|
||||||
|
|
||||||
const file = await this.driveFilesRepository.findOneBy({
|
const file = await this.driveFilesRepository.findOneBy({
|
||||||
id: ps.fileId,
|
id: ps.fileId,
|
||||||
userId: await this.roleService.isModerator(me) ? undefined : me.id,
|
userId: isModerator ? undefined : me.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
|
|
|
@ -5,12 +5,13 @@
|
||||||
|
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
|
import type { DriveFilesRepository, MiDriveFile, PagesRepository } from '@/models/_.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { pageNameSchema } from '@/models/Page.js';
|
||||||
import { MiPage, pageNameSchema } from '@/models/Page.js';
|
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { PageService } from '@/core/PageService.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -77,11 +78,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
@Inject(DI.driveFilesRepository)
|
@Inject(DI.driveFilesRepository)
|
||||||
private driveFilesRepository: DriveFilesRepository,
|
private driveFilesRepository: DriveFilesRepository,
|
||||||
|
|
||||||
|
private pageService: PageService,
|
||||||
private pageEntityService: PageEntityService,
|
private pageEntityService: PageEntityService,
|
||||||
private idService: IdService,
|
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
let eyeCatchingImage = null;
|
let eyeCatchingImage: MiDriveFile | null = null;
|
||||||
if (ps.eyeCatchingImageId != null) {
|
if (ps.eyeCatchingImageId != null) {
|
||||||
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||||
id: ps.eyeCatchingImageId,
|
id: ps.eyeCatchingImageId,
|
||||||
|
@ -102,24 +103,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = await this.pagesRepository.insertOne(new MiPage({
|
try {
|
||||||
id: this.idService.gen(),
|
const page = await this.pageService.create(me, {
|
||||||
updatedAt: new Date(),
|
...ps,
|
||||||
title: ps.title,
|
eyeCatchingImage,
|
||||||
name: ps.name,
|
summary: ps.summary ?? null,
|
||||||
summary: ps.summary,
|
});
|
||||||
content: ps.content,
|
|
||||||
variables: ps.variables,
|
|
||||||
script: ps.script,
|
|
||||||
eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null,
|
|
||||||
userId: me.id,
|
|
||||||
visibility: 'public',
|
|
||||||
alignCenter: ps.alignCenter,
|
|
||||||
hideTitleWhenPinned: ps.hideTitleWhenPinned,
|
|
||||||
font: ps.font,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return await this.pageEntityService.pack(page);
|
return await this.pageEntityService.pack(page);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof IdentifiableError && err.id === '1a79e38e-3d83-4423-845b-a9d83ff93b61') {
|
||||||
|
throw new ApiError(meta.errors.nameAlreadyExists);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,12 +4,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { PagesRepository, UsersRepository } from '@/models/_.js';
|
import type { MiDriveFile, PagesRepository, UsersRepository } from '@/models/_.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
import { PageService } from '@/core/PageService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['pages'],
|
tags: ['pages'],
|
||||||
|
@ -44,36 +46,17 @@ export const paramDef = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.pagesRepository)
|
private pageService: PageService,
|
||||||
private pagesRepository: PagesRepository,
|
|
||||||
|
|
||||||
@Inject(DI.usersRepository)
|
|
||||||
private usersRepository: UsersRepository,
|
|
||||||
|
|
||||||
private moderationLogService: ModerationLogService,
|
|
||||||
private roleService: RoleService,
|
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
|
try {
|
||||||
|
await this.pageService.delete(me, ps.pageId);
|
||||||
if (page == null) {
|
} catch (err) {
|
||||||
throw new ApiError(meta.errors.noSuchPage);
|
if (err instanceof IdentifiableError) {
|
||||||
|
if (err.id === '66aefd3c-fdb2-4a71-85ae-cc18bea85d3f') throw new ApiError(meta.errors.noSuchPage);
|
||||||
|
if (err.id === 'd0017699-8256-46f1-aed4-bc03bed73616') throw new ApiError(meta.errors.accessDenied);
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
if (!await this.roleService.isModerator(me) && page.userId !== me.id) {
|
|
||||||
throw new ApiError(meta.errors.accessDenied);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.pagesRepository.delete(page.id);
|
|
||||||
|
|
||||||
if (page.userId !== me.id) {
|
|
||||||
const user = await this.usersRepository.findOneByOrFail({ id: page.userId });
|
|
||||||
this.moderationLogService.log(me, 'deletePage', {
|
|
||||||
pageId: page.id,
|
|
||||||
pageUserId: page.userId,
|
|
||||||
pageUserUsername: user.username,
|
|
||||||
page,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,13 +4,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import { Not } from 'typeorm';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { PagesRepository, DriveFilesRepository } from '@/models/_.js';
|
import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
import { pageNameSchema } from '@/models/Page.js';
|
import { pageNameSchema } from '@/models/Page.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
import { PageService } from '@/core/PageService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['pages'],
|
tags: ['pages'],
|
||||||
|
@ -75,24 +76,17 @@ export const paramDef = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.pagesRepository)
|
|
||||||
private pagesRepository: PagesRepository,
|
|
||||||
|
|
||||||
@Inject(DI.driveFilesRepository)
|
@Inject(DI.driveFilesRepository)
|
||||||
private driveFilesRepository: DriveFilesRepository,
|
private driveFilesRepository: DriveFilesRepository,
|
||||||
|
|
||||||
|
private pageService: PageService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
|
try {
|
||||||
if (page == null) {
|
let eyeCatchingImage: MiDriveFile | null | undefined | string = ps.eyeCatchingImageId;
|
||||||
throw new ApiError(meta.errors.noSuchPage);
|
if (eyeCatchingImage != null) {
|
||||||
}
|
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||||
if (page.userId !== me.id) {
|
id: eyeCatchingImage,
|
||||||
throw new ApiError(meta.errors.accessDenied);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ps.eyeCatchingImageId != null) {
|
|
||||||
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
|
||||||
id: ps.eyeCatchingImageId,
|
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -101,31 +95,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.name != null) {
|
await this.pageService.update(me, ps.pageId, {
|
||||||
await this.pagesRepository.findBy({
|
...ps,
|
||||||
id: Not(ps.pageId),
|
eyeCatchingImage,
|
||||||
userId: me.id,
|
});
|
||||||
name: ps.name,
|
} catch (err) {
|
||||||
}).then(result => {
|
if (err instanceof IdentifiableError) {
|
||||||
if (result.length > 0) {
|
if (err.id === '66aefd3c-fdb2-4a71-85ae-cc18bea85d3f') throw new ApiError(meta.errors.noSuchPage);
|
||||||
throw new ApiError(meta.errors.nameAlreadyExists);
|
if (err.id === 'd0017699-8256-46f1-aed4-bc03bed73616') throw new ApiError(meta.errors.accessDenied);
|
||||||
|
if (err.id === 'd05bfe24-24b6-4ea2-a3ec-87cc9bf4daa4') throw new ApiError(meta.errors.nameAlreadyExists);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pagesRepository.update(page.id, {
|
|
||||||
updatedAt: new Date(),
|
|
||||||
title: ps.title,
|
|
||||||
name: ps.name,
|
|
||||||
summary: ps.summary === undefined ? page.summary : ps.summary,
|
|
||||||
content: ps.content,
|
|
||||||
variables: ps.variables,
|
|
||||||
script: ps.script,
|
|
||||||
alignCenter: ps.alignCenter,
|
|
||||||
hideTitleWhenPinned: ps.hideTitleWhenPinned,
|
|
||||||
font: ps.font,
|
|
||||||
eyeCatchingImageId: ps.eyeCatchingImageId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ export const meta = {
|
||||||
tags: ['users'],
|
tags: ['users'],
|
||||||
|
|
||||||
requireCredential: false,
|
requireCredential: false,
|
||||||
|
requiredRolePolicy: 'canSearchUsers',
|
||||||
|
|
||||||
description: 'Search for users.',
|
description: 'Search for users.',
|
||||||
|
|
||||||
|
|
|
@ -32,7 +32,6 @@ export default class Connection {
|
||||||
public subscriber: StreamEventEmitter;
|
public subscriber: StreamEventEmitter;
|
||||||
private channels: Channel[] = [];
|
private channels: Channel[] = [];
|
||||||
private subscribingNotes: Partial<Record<string, number>> = {};
|
private subscribingNotes: Partial<Record<string, number>> = {};
|
||||||
private cachedNotes: Packed<'Note'>[] = [];
|
|
||||||
public userProfile: MiUserProfile | null = null;
|
public userProfile: MiUserProfile | null = null;
|
||||||
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
||||||
public followingChannels: Set<string> = new Set();
|
public followingChannels: Set<string> = new Set();
|
||||||
|
@ -132,26 +131,6 @@ export default class Connection {
|
||||||
this.sendMessageToWs(data.type, data.body);
|
this.sendMessageToWs(data.type, data.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
public cacheNote(note: Packed<'Note'>) {
|
|
||||||
const add = (note: Packed<'Note'>) => {
|
|
||||||
const existIndex = this.cachedNotes.findIndex(n => n.id === note.id);
|
|
||||||
if (existIndex > -1) {
|
|
||||||
this.cachedNotes[existIndex] = note;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cachedNotes.unshift(note);
|
|
||||||
if (this.cachedNotes.length > 32) {
|
|
||||||
this.cachedNotes.splice(32);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
add(note);
|
|
||||||
if (note.reply) add(note.reply);
|
|
||||||
if (note.renote) add(note.renote);
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private onReadNotification(payload: JsonValue | undefined) {
|
private onReadNotification(payload: JsonValue | undefined) {
|
||||||
this.notificationService.readAllNotification(this.user!.id);
|
this.notificationService.readAllNotification(this.user!.id);
|
||||||
|
|
|
@ -43,8 +43,6 @@ class AntennaChannel extends Channel {
|
||||||
|
|
||||||
if (this.isNoteMutedOrBlocked(note)) return;
|
if (this.isNoteMutedOrBlocked(note)) return;
|
||||||
|
|
||||||
this.connection.cacheNote(note);
|
|
||||||
|
|
||||||
this.send('note', note);
|
this.send('note', note);
|
||||||
} else {
|
} else {
|
||||||
this.send(data.type, data.body);
|
this.send(data.type, data.body);
|
||||||
|
|
|
@ -49,8 +49,6 @@ class ChannelChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connection.cacheNote(note);
|
|
||||||
|
|
||||||
this.send('note', note);
|
this.send('note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -65,8 +65,6 @@ class GlobalTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connection.cacheNote(note);
|
|
||||||
|
|
||||||
this.send('note', note);
|
this.send('note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -53,8 +53,6 @@ class HashtagChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connection.cacheNote(note);
|
|
||||||
|
|
||||||
this.send('note', note);
|
this.send('note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -86,8 +86,6 @@ class HomeTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connection.cacheNote(note);
|
|
||||||
|
|
||||||
this.send('note', note);
|
this.send('note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -100,8 +100,6 @@ class HybridTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connection.cacheNote(note);
|
|
||||||
|
|
||||||
this.send('note', note);
|
this.send('note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -75,8 +75,6 @@ class LocalTimelineChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connection.cacheNote(note);
|
|
||||||
|
|
||||||
this.send('note', note);
|
this.send('note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,6 @@ class MainChannel extends Channel {
|
||||||
const note = await this.noteEntityService.pack(data.body.note.id, this.user, {
|
const note = await this.noteEntityService.pack(data.body.note.id, this.user, {
|
||||||
detail: true,
|
detail: true,
|
||||||
});
|
});
|
||||||
this.connection.cacheNote(note);
|
|
||||||
data.body.note = note;
|
data.body.note = note;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -52,7 +51,6 @@ class MainChannel extends Channel {
|
||||||
const note = await this.noteEntityService.pack(data.body.id, this.user, {
|
const note = await this.noteEntityService.pack(data.body.id, this.user, {
|
||||||
detail: true,
|
detail: true,
|
||||||
});
|
});
|
||||||
this.connection.cacheNote(note);
|
|
||||||
data.body = note;
|
data.body = note;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -118,8 +118,6 @@ class UserListChannel extends Channel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connection.cacheNote(note);
|
|
||||||
|
|
||||||
this.send('note', note);
|
this.send('note', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -190,7 +190,8 @@ export async function uploadFile(
|
||||||
path = '../../test/resources/192.jpg',
|
path = '../../test/resources/192.jpg',
|
||||||
): Promise<Misskey.entities.DriveFile> {
|
): Promise<Misskey.entities.DriveFile> {
|
||||||
const filename = path.split('/').pop() ?? 'untitled';
|
const filename = path.split('/').pop() ?? 'untitled';
|
||||||
const blob = new Blob([await readFile(join(__dirname, path))]);
|
const buffer = await readFile(join(__dirname, path));
|
||||||
|
const blob = new Blob([new Uint8Array(buffer)]);
|
||||||
|
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append('i', user.i);
|
body.append('i', user.i);
|
||||||
|
|
|
@ -40,6 +40,7 @@ describe('NoteCreateService', () => {
|
||||||
renoteCount: 0,
|
renoteCount: 0,
|
||||||
repliesCount: 0,
|
repliesCount: 0,
|
||||||
clippedCount: 0,
|
clippedCount: 0,
|
||||||
|
pageCount: 0,
|
||||||
reactions: {},
|
reactions: {},
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
uri: null,
|
uri: null,
|
||||||
|
|
|
@ -23,6 +23,7 @@ const base: MiNote = {
|
||||||
renoteCount: 0,
|
renoteCount: 0,
|
||||||
repliesCount: 0,
|
repliesCount: 0,
|
||||||
clippedCount: 0,
|
clippedCount: 0,
|
||||||
|
pageCount: 0,
|
||||||
reactions: {},
|
reactions: {},
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
uri: null,
|
uri: null,
|
||||||
|
|
|
@ -0,0 +1,652 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import ms from 'ms';
|
||||||
|
import {
|
||||||
|
type MiNote,
|
||||||
|
type MiUser,
|
||||||
|
type NotesRepository,
|
||||||
|
type NoteFavoritesRepository,
|
||||||
|
type UserNotePiningsRepository,
|
||||||
|
type UsersRepository,
|
||||||
|
type UserProfilesRepository,
|
||||||
|
MiMeta,
|
||||||
|
} from '@/models/_.js';
|
||||||
|
import { CleanRemoteNotesProcessorService } from '@/queue/processors/CleanRemoteNotesProcessorService.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
|
||||||
|
import { GlobalModule } from '@/GlobalModule.js';
|
||||||
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
|
||||||
|
describe('CleanRemoteNotesProcessorService', () => {
|
||||||
|
let app: TestingModule;
|
||||||
|
let service: CleanRemoteNotesProcessorService;
|
||||||
|
let idService: IdService;
|
||||||
|
let notesRepository: NotesRepository;
|
||||||
|
let noteFavoritesRepository: NoteFavoritesRepository;
|
||||||
|
let userNotePiningsRepository: UserNotePiningsRepository;
|
||||||
|
let usersRepository: UsersRepository;
|
||||||
|
let userProfilesRepository: UserProfilesRepository;
|
||||||
|
|
||||||
|
// Local user
|
||||||
|
let alice: MiUser;
|
||||||
|
// Remote user 1
|
||||||
|
let bob: MiUser;
|
||||||
|
// Remote user 2
|
||||||
|
let carol: MiUser;
|
||||||
|
|
||||||
|
const meta = new MiMeta();
|
||||||
|
|
||||||
|
// Mock job object
|
||||||
|
const createMockJob = () => ({
|
||||||
|
log: jest.fn(),
|
||||||
|
updateProgress: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createUser(data: Partial<MiUser> = {}) {
|
||||||
|
const id = idService.gen();
|
||||||
|
const un = data.username || secureRndstr(16);
|
||||||
|
const user = await usersRepository
|
||||||
|
.insert({
|
||||||
|
id,
|
||||||
|
username: un,
|
||||||
|
usernameLower: un.toLowerCase(),
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||||
|
|
||||||
|
await userProfilesRepository.save({
|
||||||
|
userId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNote(data: Partial<MiNote>, user: MiUser, time?: number): Promise<MiNote> {
|
||||||
|
const id = idService.gen(time);
|
||||||
|
const note = await notesRepository
|
||||||
|
.insert({
|
||||||
|
id: id,
|
||||||
|
text: `note_${id}`,
|
||||||
|
userId: user.id,
|
||||||
|
userHost: user.host,
|
||||||
|
visibility: 'public',
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
.then(x => notesRepository.findOneByOrFail(x.identifiers[0]));
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await Test
|
||||||
|
.createTestingModule({
|
||||||
|
imports: [
|
||||||
|
GlobalModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
CleanRemoteNotesProcessorService,
|
||||||
|
IdService,
|
||||||
|
{
|
||||||
|
provide: QueueLoggerService,
|
||||||
|
useFactory: () => ({
|
||||||
|
logger: {
|
||||||
|
createSubLogger: () => ({
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
succ: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideProvider(DI.meta).useFactory({ factory: () => meta })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
service = app.get(CleanRemoteNotesProcessorService);
|
||||||
|
idService = app.get(IdService);
|
||||||
|
notesRepository = app.get(DI.notesRepository);
|
||||||
|
noteFavoritesRepository = app.get(DI.noteFavoritesRepository);
|
||||||
|
userNotePiningsRepository = app.get(DI.userNotePiningsRepository);
|
||||||
|
usersRepository = app.get(DI.usersRepository);
|
||||||
|
userProfilesRepository = app.get(DI.userProfilesRepository);
|
||||||
|
|
||||||
|
alice = await createUser({ username: 'alice', host: null });
|
||||||
|
bob = await createUser({ username: 'bob', host: 'remote1.example.com' });
|
||||||
|
carol = await createUser({ username: 'carol', host: 'remote2.example.com' });
|
||||||
|
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Set default meta values
|
||||||
|
meta.enableRemoteNotesCleaning = true;
|
||||||
|
meta.remoteNotesCleaningMaxProcessingDurationInMinutes = 0.3;
|
||||||
|
meta.remoteNotesCleaningExpiryDaysForEachNotes = 30;
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up test data
|
||||||
|
await Promise.all([
|
||||||
|
notesRepository.createQueryBuilder().delete().execute(),
|
||||||
|
userNotePiningsRepository.createQueryBuilder().delete().execute(),
|
||||||
|
noteFavoritesRepository.createQueryBuilder().delete().execute(),
|
||||||
|
]);
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('basic', () => {
|
||||||
|
test('should skip cleaning when enableRemoteNotesCleaning is false', async () => {
|
||||||
|
meta.enableRemoteNotesCleaning = false;
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
deletedCount: 0,
|
||||||
|
oldest: null,
|
||||||
|
newest: null,
|
||||||
|
skipped: true,
|
||||||
|
transientErrors: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return success result when enableRemoteNotesCleaning is true and no notes to clean', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
await createNote({}, alice);
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
deletedCount: 0,
|
||||||
|
oldest: null,
|
||||||
|
newest: null,
|
||||||
|
skipped: false,
|
||||||
|
transientErrors: 0,
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
test('should clean remote notes and return stats', async () => {
|
||||||
|
// Remote notes
|
||||||
|
const remoteNotes = await Promise.all([
|
||||||
|
createNote({}, bob),
|
||||||
|
createNote({}, carol),
|
||||||
|
createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000),
|
||||||
|
createNote({}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000), // Note older than expiry
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Local notes
|
||||||
|
const localNotes = await Promise.all([
|
||||||
|
createNote({}, alice),
|
||||||
|
createNote({}, alice, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
deletedCount: 2,
|
||||||
|
oldest: expect.any(Number),
|
||||||
|
newest: expect.any(Number),
|
||||||
|
skipped: false,
|
||||||
|
transientErrors: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check side-by-side from all notes
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.length).toBe(4);
|
||||||
|
expect(remainingNotes.some(n => n.id === remoteNotes[0].id)).toBe(true);
|
||||||
|
expect(remainingNotes.some(n => n.id === remoteNotes[1].id)).toBe(true);
|
||||||
|
expect(remainingNotes.some(n => n.id === remoteNotes[2].id)).toBe(false);
|
||||||
|
expect(remainingNotes.some(n => n.id === remoteNotes[3].id)).toBe(false);
|
||||||
|
expect(remainingNotes.some(n => n.id === localNotes[0].id)).toBe(true);
|
||||||
|
expect(remainingNotes.some(n => n.id === localNotes[1].id)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('advanced', () => {
|
||||||
|
// お気に入り
|
||||||
|
test('should not delete note that is favorited by any user', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote note that should be deleted
|
||||||
|
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
|
||||||
|
// Favorite the note
|
||||||
|
await noteFavoritesRepository.save({
|
||||||
|
id: idService.gen(),
|
||||||
|
userId: alice.id,
|
||||||
|
noteId: olderRemoteNote.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNote = await notesRepository.findOneBy({ id: olderRemoteNote.id });
|
||||||
|
expect(remainingNote).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ピン留め
|
||||||
|
test('should not delete note that is pinned by the user', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote note that should be deleted
|
||||||
|
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
|
||||||
|
// Pin the note by the user who created it
|
||||||
|
await userNotePiningsRepository.save({
|
||||||
|
id: idService.gen(),
|
||||||
|
userId: bob.id, // Same user as the note creator
|
||||||
|
noteId: olderRemoteNote.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNote = await notesRepository.findOneBy({ id: olderRemoteNote.id });
|
||||||
|
expect(remainingNote).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// クリップ
|
||||||
|
test('should not delete note that is clipped', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote note that is clipped
|
||||||
|
const clippedNote = await createNote({
|
||||||
|
clippedCount: 1, // Clipped
|
||||||
|
}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNote = await notesRepository.findOneBy({ id: clippedNote.id });
|
||||||
|
expect(remainingNote).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ページ
|
||||||
|
test('should not delete note that is embedded in a page', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote note that is embedded in a page
|
||||||
|
const clippedNote = await createNote({
|
||||||
|
pageCount: 1, // Embedded in a page
|
||||||
|
}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNote = await notesRepository.findOneBy({ id: clippedNote.id });
|
||||||
|
expect(remainingNote).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 古いreply, renoteが含まれている時の挙動
|
||||||
|
test('should handle reply/renote relationships correctly', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote notes with reply/renote relationships
|
||||||
|
const originalNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
const replyNote = await createNote({
|
||||||
|
replyId: originalNote.id,
|
||||||
|
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||||
|
const renoteNote = await createNote({
|
||||||
|
renoteId: originalNote.id,
|
||||||
|
}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 3000);
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
// Should delete all three notes as they are all old and remote
|
||||||
|
expect(result.deletedCount).toBe(3);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.some(n => n.id === originalNote.id)).toBe(false);
|
||||||
|
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(false);
|
||||||
|
expect(remainingNotes.some(n => n.id === renoteNote.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 古いリモートノートに新しいリプライがある時、どちらも削除されない
|
||||||
|
test('should not delete both old remote note with new reply', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote note that should be deleted
|
||||||
|
const oldNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
|
||||||
|
// Create a reply note that is newer than the expiry period
|
||||||
|
const recentReplyNote = await createNote({
|
||||||
|
replyId: oldNote.id,
|
||||||
|
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) + 1000);
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0); // Only the old note should be deleted
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.some(n => n.id === oldNote.id)).toBe(true);
|
||||||
|
expect(remainingNotes.some(n => n.id === recentReplyNote.id)).toBe(true); // Recent reply note should remain
|
||||||
|
});
|
||||||
|
|
||||||
|
// 古いリモートノートに新しいリプライと古いリプライがある時、全て残る
|
||||||
|
test('should not delete old remote note with new reply and old reply', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote note that should be deleted
|
||||||
|
const oldNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
|
||||||
|
// Create a reply note that is newer than the expiry period
|
||||||
|
const recentReplyNote = await createNote({
|
||||||
|
replyId: oldNote.id,
|
||||||
|
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) + 1000);
|
||||||
|
|
||||||
|
// Create an old reply note that should be deleted
|
||||||
|
const oldReplyNote = await createNote({
|
||||||
|
replyId: oldNote.id,
|
||||||
|
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.some(n => n.id === oldNote.id)).toBe(true);
|
||||||
|
expect(remainingNotes.some(n => n.id === recentReplyNote.id)).toBe(true); // Recent reply note should remain
|
||||||
|
expect(remainingNotes.some(n => n.id === oldReplyNote.id)).toBe(true); // Old reply note should be deleted
|
||||||
|
});
|
||||||
|
|
||||||
|
// リプライがお気に入りされているとき、どちらも削除されない
|
||||||
|
test('should not delete reply note that is favorited', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote note that should be deleted
|
||||||
|
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
|
||||||
|
// Create a reply note that is newer than the expiry period
|
||||||
|
const replyNote = await createNote({
|
||||||
|
replyId: olderRemoteNote.id,
|
||||||
|
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||||
|
|
||||||
|
// Favorite the reply note
|
||||||
|
await noteFavoritesRepository.save({
|
||||||
|
id: idService.gen(),
|
||||||
|
userId: alice.id,
|
||||||
|
noteId: replyNote.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0); // Only the old note should be deleted
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.some(n => n.id === olderRemoteNote.id)).toBe(true);
|
||||||
|
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(true); // Recent reply note should remain
|
||||||
|
});
|
||||||
|
|
||||||
|
// リプライがピン留めされているとき、どちらも削除されない
|
||||||
|
test('should not delete reply note that is pinned', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote note that should be deleted
|
||||||
|
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
|
||||||
|
// Create a reply note that is newer than the expiry period
|
||||||
|
const replyNote = await createNote({
|
||||||
|
replyId: olderRemoteNote.id,
|
||||||
|
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||||
|
|
||||||
|
// Pin the reply note
|
||||||
|
await userNotePiningsRepository.save({
|
||||||
|
id: idService.gen(),
|
||||||
|
userId: carol.id,
|
||||||
|
noteId: replyNote.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0); // Only the old note should be deleted
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.some(n => n.id === olderRemoteNote.id)).toBe(true);
|
||||||
|
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(true); // Reply note should remain
|
||||||
|
});
|
||||||
|
|
||||||
|
// リプライがクリップされているとき、どちらも削除されない
|
||||||
|
test('should not delete reply note that is clipped', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create old remote note that should be deleted
|
||||||
|
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||||
|
|
||||||
|
// Create a reply note that is old but clipped
|
||||||
|
const replyNote = await createNote({
|
||||||
|
replyId: olderRemoteNote.id,
|
||||||
|
clippedCount: 1, // Clipped
|
||||||
|
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0); // Both notes should be kept because reply is clipped
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.some(n => n.id === olderRemoteNote.id)).toBe(true);
|
||||||
|
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mixed scenarios with multiple conditions', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create various types of notes
|
||||||
|
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||||
|
|
||||||
|
// Should be deleted: old remote note with no special conditions
|
||||||
|
const deletableNote = await createNote({}, bob, oldTime);
|
||||||
|
|
||||||
|
// Should NOT be deleted: old remote note but favorited
|
||||||
|
const favoritedNote = await createNote({}, carol, oldTime);
|
||||||
|
await noteFavoritesRepository.save({
|
||||||
|
id: idService.gen(),
|
||||||
|
userId: alice.id,
|
||||||
|
noteId: favoritedNote.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT be deleted: old remote note but pinned
|
||||||
|
const pinnedNote = await createNote({}, bob, oldTime);
|
||||||
|
await userNotePiningsRepository.save({
|
||||||
|
id: idService.gen(),
|
||||||
|
userId: bob.id,
|
||||||
|
noteId: pinnedNote.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT be deleted: old remote note but clipped
|
||||||
|
const clippedNote = await createNote({
|
||||||
|
clippedCount: 2,
|
||||||
|
}, carol, oldTime);
|
||||||
|
|
||||||
|
// Should NOT be deleted: old local note
|
||||||
|
const localNote = await createNote({}, alice, oldTime);
|
||||||
|
|
||||||
|
// Should NOT be deleted: new remote note
|
||||||
|
const newerRemoteNote = await createNote({}, bob);
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(1); // Only deletableNote should be deleted
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.length).toBe(5);
|
||||||
|
expect(remainingNotes.some(n => n.id === deletableNote.id)).toBe(false); // Deleted
|
||||||
|
expect(remainingNotes.some(n => n.id === favoritedNote.id)).toBe(true); // Kept
|
||||||
|
expect(remainingNotes.some(n => n.id === pinnedNote.id)).toBe(true); // Kept
|
||||||
|
expect(remainingNotes.some(n => n.id === clippedNote.id)).toBe(true); // Kept
|
||||||
|
expect(remainingNotes.some(n => n.id === localNote.id)).toBe(true); // Kept
|
||||||
|
expect(remainingNotes.some(n => n.id === newerRemoteNote.id)).toBe(true); // Kept
|
||||||
|
});
|
||||||
|
|
||||||
|
// 大量のノート
|
||||||
|
test('should handle large number of notes correctly', async () => {
|
||||||
|
const AMOUNT = 130;
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||||
|
const noteIds = [];
|
||||||
|
for (let i = 0; i < AMOUNT; i++) {
|
||||||
|
const note = await createNote({}, bob, oldTime - i);
|
||||||
|
noteIds.push(note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
// Should delete all notes, but may require multiple batches
|
||||||
|
expect(result.deletedCount).toBe(AMOUNT);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 大量のノート + リプライ or リノート
|
||||||
|
test('should handle large number of notes with replies correctly', async () => {
|
||||||
|
const AMOUNT = 130;
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||||
|
const noteIds = [];
|
||||||
|
for (let i = 0; i < AMOUNT; i++) {
|
||||||
|
const note = await createNote({}, bob, oldTime - i - AMOUNT);
|
||||||
|
noteIds.push(note.id);
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
// Create a reply for every second note
|
||||||
|
await createNote({ replyId: note.id }, carol, oldTime - i);
|
||||||
|
} else {
|
||||||
|
// Create a renote for every second note
|
||||||
|
await createNote({ renoteId: note.id }, bob, oldTime - i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
// Should delete all notes, but may require multiple batches
|
||||||
|
expect(result.deletedCount).toBe(AMOUNT * 2);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 大量の古いノート + 新しいリプライ or リノート
|
||||||
|
test('should handle large number of old notes with new replies correctly', async () => {
|
||||||
|
const AMOUNT = 130;
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||||
|
const newTime = Date.now();
|
||||||
|
const noteIds = [];
|
||||||
|
for (let i = 0; i < AMOUNT; i++) {
|
||||||
|
const note = await createNote({}, bob, oldTime - i);
|
||||||
|
noteIds.push(note.id);
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
// Create a reply for every second note
|
||||||
|
await createNote({ replyId: note.id }, carol, newTime + i);
|
||||||
|
} else {
|
||||||
|
// Create a renote for every second note
|
||||||
|
await createNote({ renoteId: note.id }, bob, newTime + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(0);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 大量の残す対象(clippedCount: 1)と大量の削除対象
|
||||||
|
test('should handle large number of notes, mixed conditions with clippedCount', async () => {
|
||||||
|
const AMOUNT_BASE = 70;
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||||
|
const noteIds = [];
|
||||||
|
for (let i = 0; i < AMOUNT_BASE; i++) {
|
||||||
|
const note = await createNote({ clippedCount: 1 }, bob, oldTime - i - AMOUNT_BASE);
|
||||||
|
noteIds.push(note.id);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < AMOUNT_BASE; i++) {
|
||||||
|
const note = await createNote({}, carol, oldTime - i);
|
||||||
|
noteIds.push(note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(AMOUNT_BASE); // Assuming half are deletable
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 大量の残す対象(リプライ)と大量の削除対象
|
||||||
|
test('should handle large number of notes, mixed conditions with replies', async () => {
|
||||||
|
const AMOUNT_BASE = 70;
|
||||||
|
const job = createMockJob();
|
||||||
|
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||||
|
const newTime = Date.now();
|
||||||
|
for (let i = 0; i < AMOUNT_BASE; i++) {
|
||||||
|
// should remain
|
||||||
|
const note = await createNote({}, carol, oldTime - AMOUNT_BASE - i);
|
||||||
|
// should remain
|
||||||
|
await createNote({ replyId: note.id }, bob, newTime + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteIdsExpectedToBeDeleted = [];
|
||||||
|
for (let i = 0; i < AMOUNT_BASE; i++) {
|
||||||
|
// should be deleted
|
||||||
|
const note = await createNote({}, bob, oldTime - i);
|
||||||
|
noteIdsExpectedToBeDeleted.push(note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
expect(result.deletedCount).toBe(AMOUNT_BASE); // Assuming all replies are deletable
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
|
||||||
|
const remainingNotes = await notesRepository.find();
|
||||||
|
expect(remainingNotes.length).toBe(AMOUNT_BASE * 2); // Only replies should remain
|
||||||
|
noteIdsExpectedToBeDeleted.forEach(id => {
|
||||||
|
expect(remainingNotes.some(n => n.id === id)).toBe(false); // All original notes should be deleted
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update cursor correctly during batch processing', async () => {
|
||||||
|
const job = createMockJob();
|
||||||
|
|
||||||
|
// Create notes with specific timing to test cursor behavior
|
||||||
|
const baseTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 10000;
|
||||||
|
|
||||||
|
const note1 = await createNote({}, bob, baseTime);
|
||||||
|
const note2 = await createNote({}, carol, baseTime - 1000);
|
||||||
|
const note3 = await createNote({}, bob, baseTime - 2000);
|
||||||
|
|
||||||
|
const result = await service.process(job as any);
|
||||||
|
|
||||||
|
expect(result.deletedCount).toBe(3);
|
||||||
|
expect(result.newest).toBe(idService.parse(note1.id).date.getTime());
|
||||||
|
expect(result.oldest).toBe(idService.parse(note3.id).date.getTime());
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -317,7 +317,7 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', blob ??
|
formData.append('file', blob ??
|
||||||
new File([await readFile(absPath)], basename(absPath.toString())));
|
new File([new Uint8Array(await readFile(absPath))], basename(absPath.toString())));
|
||||||
formData.append('force', 'true');
|
formData.append('force', 'true');
|
||||||
if (name) {
|
if (name) {
|
||||||
formData.append('name', name);
|
formData.append('name', name);
|
||||||
|
@ -608,8 +608,8 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
|
||||||
username: config.db.user,
|
username: config.db.user,
|
||||||
password: config.db.pass,
|
password: config.db.pass,
|
||||||
database: config.db.db,
|
database: config.db.db,
|
||||||
synchronize: true && !justBorrow,
|
synchronize: !justBorrow,
|
||||||
dropSchema: true && !justBorrow,
|
dropSchema: !justBorrow,
|
||||||
entities: initEntities ?? entities,
|
entities: initEntities ?? entities,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -661,7 +661,9 @@ export async function captureWebhook<T = SystemWebhookPayload>(postAction: () =>
|
||||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||||
const result = await new Promise<string>(async (resolve, reject) => {
|
const result = await new Promise<string>(async (resolve, reject) => {
|
||||||
fastify.all('/', async (req, res) => {
|
fastify.all('/', async (req, res) => {
|
||||||
timeoutHandle && clearTimeout(timeoutHandle);
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
|
||||||
const body = JSON.stringify(req.body);
|
const body = JSON.stringify(req.body);
|
||||||
res.status(200).send('ok');
|
res.status(200).send('ok');
|
||||||
|
|
|
@ -69,8 +69,10 @@ export class LocaleInliner {
|
||||||
async saveAllLocales(locales: Record<string, Locale>) {
|
async saveAllLocales(locales: Record<string, Locale>) {
|
||||||
const localeNames = Object.keys(locales);
|
const localeNames = Object.keys(locales);
|
||||||
for (const localeName of localeNames) {
|
for (const localeName of localeNames) {
|
||||||
|
this.logger.info(`Creating bundle for ${localeName}`);
|
||||||
await this.saveLocale(localeName, locales[localeName]);
|
await this.saveLocale(localeName, locales[localeName]);
|
||||||
}
|
}
|
||||||
|
this.logger.info('Done');
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveLocale(localeName: string, localeJson: Locale) {
|
async saveLocale(localeName: string, localeJson: Locale) {
|
||||||
|
|
|
@ -585,6 +585,14 @@ defineExpose({
|
||||||
grid-template-columns: var(--columns);
|
grid-template-columns: var(--columns);
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
|
|
||||||
|
> .config {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
> .item {
|
> .item {
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.root">
|
<div v-if="form.modified.value" :class="$style.root">
|
||||||
<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
|
<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
|
||||||
<div style="margin-left: auto;" class="_buttons">
|
<div style="margin-left: auto;" class="_buttons">
|
||||||
<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
|
<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
|
||||||
|
@ -16,16 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { } from 'vue';
|
import { } from 'vue';
|
||||||
import MkButton from './MkButton.vue';
|
import MkButton from './MkButton.vue';
|
||||||
|
import type { useForm } from '@/composables/use-form.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
form: {
|
form: ReturnType<typeof useForm>;
|
||||||
modifiedCount: {
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
discard: () => void;
|
|
||||||
save: () => void;
|
|
||||||
};
|
|
||||||
canSaving?: boolean;
|
canSaving?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
canSaving: true,
|
canSaving: true,
|
||||||
|
|
|
@ -14,73 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div :class="$style.root" class="_gaps">
|
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" />
|
||||||
<div v-for="[k, v] in Object.entries(fx.params)" :key="k">
|
|
||||||
<MkSwitch
|
|
||||||
v-if="v.type === 'boolean'"
|
|
||||||
v-model="layer.params[k]"
|
|
||||||
>
|
|
||||||
<template #label>{{ fx.params[k].label ?? k }}</template>
|
|
||||||
</MkSwitch>
|
|
||||||
<MkRange
|
|
||||||
v-else-if="v.type === 'number'"
|
|
||||||
v-model="layer.params[k]"
|
|
||||||
continuousUpdate
|
|
||||||
:min="v.min"
|
|
||||||
:max="v.max"
|
|
||||||
:step="v.step"
|
|
||||||
:textConverter="fx.params[k].toViewValue"
|
|
||||||
@thumbDoubleClicked="() => {
|
|
||||||
if (fx.params[k].default != null) {
|
|
||||||
layer.params[k] = fx.params[k].default;
|
|
||||||
} else {
|
|
||||||
layer.params[k] = v.min;
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #label>{{ fx.params[k].label ?? k }}</template>
|
|
||||||
</MkRange>
|
|
||||||
<MkRadios
|
|
||||||
v-else-if="v.type === 'number:enum'"
|
|
||||||
v-model="layer.params[k]"
|
|
||||||
>
|
|
||||||
<template #label>{{ fx.params[k].label ?? k }}</template>
|
|
||||||
<option v-for="item in v.enum" :value="item.value">{{ item.label }}</option>
|
|
||||||
</MkRadios>
|
|
||||||
<div v-else-if="v.type === 'seed'">
|
|
||||||
<MkRange
|
|
||||||
v-model="layer.params[k]"
|
|
||||||
continuousUpdate
|
|
||||||
type="number"
|
|
||||||
:min="0"
|
|
||||||
:max="10000"
|
|
||||||
:step="1"
|
|
||||||
>
|
|
||||||
<template #label>{{ fx.params[k].label ?? k }}</template>
|
|
||||||
</MkRange>
|
|
||||||
</div>
|
|
||||||
<MkInput
|
|
||||||
v-else-if="v.type === 'color'"
|
|
||||||
:modelValue="getHex(layer.params[k])"
|
|
||||||
type="color"
|
|
||||||
@update:modelValue="v => { const c = getRgb(v); if (c != null) layer.params[k] = c; }"
|
|
||||||
>
|
|
||||||
<template #label>{{ fx.params[k].label ?? k }}</template>
|
|
||||||
</MkInput>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
|
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
|
||||||
import { i18n } from '@/i18n.js';
|
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue';
|
||||||
import MkRadios from '@/components/MkRadios.vue';
|
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
|
||||||
import MkRange from '@/components/MkRange.vue';
|
|
||||||
import { FXS } from '@/utility/image-effector/fxs.js';
|
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||||
|
|
||||||
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
|
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
|
||||||
|
@ -94,28 +36,4 @@ const emit = defineEmits<{
|
||||||
(e: 'swapUp'): void;
|
(e: 'swapUp'): void;
|
||||||
(e: 'swapDown'): void;
|
(e: 'swapDown'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function getHex(c: [number, number, number]) {
|
|
||||||
return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRgb(hex: string | number): [number, number, number] | null {
|
|
||||||
if (
|
|
||||||
typeof hex === 'number' ||
|
|
||||||
typeof hex !== 'string' ||
|
|
||||||
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
|
|
||||||
if (m == null) return [0, 0, 0];
|
|
||||||
return m.map(x => parseInt(x, 16) / 255) as [number, number, number];
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style module>
|
|
||||||
.root {
|
|
||||||
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
95
packages/frontend/src/components/MkImageEffectorFxForm.vue
Normal file
95
packages/frontend/src/components/MkImageEffectorFxForm.vue
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="_gaps">
|
||||||
|
<div v-for="v, k in paramDefs" :key="k">
|
||||||
|
<MkSwitch
|
||||||
|
v-if="v.type === 'boolean'"
|
||||||
|
v-model="params[k]">
|
||||||
|
<template #label>{{ v.label ?? k }}</template>
|
||||||
|
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkRange
|
||||||
|
v-else-if="v.type === 'number'"
|
||||||
|
v-model="params[k]"
|
||||||
|
continuousUpdate
|
||||||
|
:min="v.min"
|
||||||
|
:max="v.max"
|
||||||
|
:step="v.step"
|
||||||
|
:textConverter="v.toViewValue"
|
||||||
|
@thumbDoubleClicked="() => {
|
||||||
|
params[k] = v.default;
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #label>{{ v.label ?? k }}</template>
|
||||||
|
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
|
||||||
|
</MkRange>
|
||||||
|
<MkRadios v-else-if="v.type === 'number:enum'" v-model="params[k]">
|
||||||
|
<template #label>{{ v.label ?? k }}</template>
|
||||||
|
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
|
||||||
|
<option v-for="item in v.enum" :value="item.value">
|
||||||
|
<i v-if="item.icon" :class="item.icon"></i>
|
||||||
|
<template v-else>{{ item.label }}</template>
|
||||||
|
</option>
|
||||||
|
</MkRadios>
|
||||||
|
<div v-else-if="v.type === 'seed'">
|
||||||
|
<MkRange v-model="params[k]" continuousUpdate type="number" :min="0" :max="10000" :step="1">
|
||||||
|
<template #label>{{ v.label ?? k }}</template>
|
||||||
|
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
|
||||||
|
</MkRange>
|
||||||
|
</div>
|
||||||
|
<MkInput v-else-if="v.type === 'color'" :modelValue="getHex(params[k])" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params[k] = c; }">
|
||||||
|
<template #label>{{ v.label ?? k }}</template>
|
||||||
|
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</div>
|
||||||
|
<div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure">
|
||||||
|
{{ i18n.ts._imageEffector.nothingToConfigure }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import MkInput from '@/components/MkInput.vue';
|
||||||
|
import MkRadios from '@/components/MkRadios.vue';
|
||||||
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import MkRange from '@/components/MkRange.vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import type { ImageEffectorRGB, ImageEffectorFxParamDefs } from '@/utility/image-effector/ImageEffector.js';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
paramDefs: ImageEffectorFxParamDefs;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const params = defineModel<Record<string, any>>({ required: true });
|
||||||
|
|
||||||
|
function getHex(c: ImageEffectorRGB) {
|
||||||
|
return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRgb(hex: string | number): ImageEffectorRGB | null {
|
||||||
|
if (
|
||||||
|
typeof hex === 'number' ||
|
||||||
|
typeof hex !== 'string' ||
|
||||||
|
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
|
||||||
|
if (m == null) return [0, 0, 0];
|
||||||
|
return m.map(x => parseInt(x, 16) / 255) as ImageEffectorRGB;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style module>
|
||||||
|
.nothingToConfigure {
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -44,6 +44,11 @@ const y = defineModel<string>('y', { default: 'center' });
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: var(--MI_THEME-panel);
|
background: var(--MI_THEME-panel);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
|
||||||
|
&:not(.active):hover {
|
||||||
|
background: var(--MI_THEME-buttonHoverBg);
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--MI_THEME-accentedBg);
|
background: var(--MI_THEME-accentedBg);
|
||||||
|
|
|
@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
|
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
|
||||||
</template>
|
</template>
|
||||||
</component>
|
</component>
|
||||||
<button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
|
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
|
||||||
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
|
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
|
||||||
<MkLoading v-else :inline="true"/>
|
<MkLoading v-else :inline="true"/>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
|
<XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
|
||||||
</div>
|
</div>
|
||||||
</component>
|
</component>
|
||||||
<button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
|
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
|
||||||
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
|
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
|
||||||
<MkLoading v-else/>
|
<MkLoading v-else/>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -62,6 +62,8 @@ export type Column = {
|
||||||
withSensitive?: boolean;
|
withSensitive?: boolean;
|
||||||
onlyFiles?: boolean;
|
onlyFiles?: boolean;
|
||||||
soundSetting?: SoundStore;
|
soundSetting?: SoundStore;
|
||||||
|
// The cache for the name of the antenna, channel, list, or role
|
||||||
|
timelineNameCache?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _currentProfile = prefer.s['deck.profiles'].find(p => p.name === prefer.s['deck.profile']);
|
const _currentProfile = prefer.s['deck.profiles'].find(p => p.name === prefer.s['deck.profile']);
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { throttle } from 'throttle-debounce';
|
||||||
import type { Directive } from 'vue';
|
import type { Directive } from 'vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -10,12 +11,14 @@ export default {
|
||||||
const fn = binding.value;
|
const fn = binding.value;
|
||||||
if (fn == null) return;
|
if (fn == null) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(entries => {
|
const check = throttle(1000, (entries) => {
|
||||||
if (entries.some(entry => entry.isIntersecting)) {
|
if (entries.some(entry => entry.isIntersecting)) {
|
||||||
fn();
|
fn();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(check);
|
||||||
|
|
||||||
observer.observe(src);
|
observer.observe(src);
|
||||||
|
|
||||||
src._observer_ = observer;
|
src._observer_ = observer;
|
||||||
|
|
|
@ -111,6 +111,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div>
|
<div>
|
||||||
<a style="display: inline-block;" class="purpledotdigital" title="Purple Dot Digital" href="https://purpledotdigital.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/purple-dot-digital.jpg" alt="Purple Dot Digital"></a>
|
<a style="display: inline-block;" class="purpledotdigital" title="Purple Dot Digital" href="https://purpledotdigital.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/purple-dot-digital.jpg" alt="Purple Dot Digital"></a>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<a style="display: inline-block;" class="sads-llc" title="合同会社サッズ" href="https://sads-llc.co.jp/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/sads-llc.png" alt="合同会社サッズ"></a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
<FormSection>
|
<FormSection>
|
||||||
|
@ -286,6 +289,9 @@ const patronsWithIcon = [{
|
||||||
}, {
|
}, {
|
||||||
name: '井上千二十四',
|
name: '井上千二十四',
|
||||||
icon: 'https://assets.misskey-hub.net/patrons/193afa1f039b4c339866039c3dcd74bf.jpg',
|
icon: 'https://assets.misskey-hub.net/patrons/193afa1f039b4c339866039c3dcd74bf.jpg',
|
||||||
|
}, {
|
||||||
|
name: 'NigN',
|
||||||
|
icon: 'https://assets.misskey-hub.net/patrons/1ccaef8e73ec4a50b59ff7cd688ceb84.jpg',
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const patrons = [
|
const patrons = [
|
||||||
|
@ -399,6 +405,8 @@ const patrons = [
|
||||||
'みりめい',
|
'みりめい',
|
||||||
'東雲 琥珀',
|
'東雲 琥珀',
|
||||||
'ほとラズ',
|
'ほとラズ',
|
||||||
|
'スズカケン',
|
||||||
|
'蒼井よみこ',
|
||||||
];
|
];
|
||||||
|
|
||||||
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));
|
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));
|
||||||
|
|
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
|
<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
|
||||||
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
|
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
|
||||||
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
|
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
|
||||||
<template #footer>
|
<template v-if="botProtectionForm.modified.value" #footer>
|
||||||
<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
|
<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -346,6 +346,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchUsers, 'canSearchUsers'])">
|
||||||
|
<template #label>{{ i18n.ts._role._options.canSearchUsers }}</template>
|
||||||
|
<template #suffix>
|
||||||
|
<span v-if="role.policies.canSearchUsers.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||||
|
<span v-else>{{ role.policies.canSearchUsers.value ? i18n.ts.yes : i18n.ts.no }}</span>
|
||||||
|
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canSearchUsers)"></i></span>
|
||||||
|
</template>
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkSwitch v-model="role.policies.canSearchUsers.useDefault" :readonly="readonly">
|
||||||
|
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-model="role.policies.canSearchUsers.value" :disabled="role.policies.canSearchUsers.useDefault" :readonly="readonly">
|
||||||
|
<template #label>{{ i18n.ts.enable }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkRange v-model="role.policies.canSearchUsers.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||||
|
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||||
|
</MkRange>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])">
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])">
|
||||||
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
|
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
|
|
|
@ -122,6 +122,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkSwitch>
|
</MkSwitch>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchUsers, 'canSearchUsers'])">
|
||||||
|
<template #label>{{ i18n.ts._role._options.canSearchUsers }}</template>
|
||||||
|
<template #suffix>{{ policies.canSearchUsers ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||||
|
<MkSwitch v-model="policies.canSearchUsers">
|
||||||
|
<template #label>{{ i18n.ts.enable }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])">
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])">
|
||||||
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
|
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
|
||||||
<template #suffix>{{ policies.canUseTranslator ? i18n.ts.yes : i18n.ts.no }}</template>
|
<template #suffix>{{ policies.canUseTranslator ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||||
|
|
|
@ -112,7 +112,7 @@ const favorited = ref(false);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const searchPaginator = shallowRef();
|
const searchPaginator = shallowRef();
|
||||||
const searchKey = ref('');
|
const searchKey = ref('');
|
||||||
const featuredPaginator = markRaw(new Paginator('channels/featured', {
|
const featuredPaginator = markRaw(new Paginator('notes/featured', {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
computedParams: computed(() => ({
|
computedParams: computed(() => ({
|
||||||
channelId: props.channelId,
|
channelId: props.channelId,
|
||||||
|
|
|
@ -15,16 +15,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="tab === 'user'" class="_spacer" style="--MI_SPACER-w: 800px;">
|
<div v-else-if="tab === 'user'" class="_spacer" style="--MI_SPACER-w: 800px;">
|
||||||
|
<div v-if="usersSearchAvailable">
|
||||||
<XUser v-bind="props"/>
|
<XUser v-bind="props"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<MkInfo warn>{{ i18n.ts.usersSearchNotAvailable }}</MkInfo>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PageWithHeader>
|
</PageWithHeader>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineAsyncComponent, ref, toRef } from 'vue';
|
import { computed, defineAsyncComponent, ref, toRef } from 'vue';
|
||||||
|
import { $i } from '@/i.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePage } from '@/page.js';
|
import { definePage } from '@/page.js';
|
||||||
import { notesSearchAvailable } from '@/utility/check-permissions.js';
|
import { notesSearchAvailable, usersSearchAvailable } from '@/utility/check-permissions.js';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
|
|
|
@ -128,9 +128,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
<template v-if="$i.policies.chatAvailability !== 'unavailable'">
|
||||||
<MkButton @click="readAllChatMessages">Read all chat messages</MkButton>
|
<MkButton @click="readAllChatMessages">Read all chat messages</MkButton>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
</template>
|
||||||
|
|
||||||
<FormSlot>
|
<FormSlot>
|
||||||
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>
|
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>
|
||||||
|
|
|
@ -3,14 +3,13 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, defineAsyncComponent } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
|
|
||||||
import { compareVersions } from 'compare-versions';
|
import { compareVersions } from 'compare-versions';
|
||||||
import { isSafeMode } from '@@/js/config.js';
|
import { isSafeMode } from '@@/js/config.js';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
import type { Parser, Interpreter, values } from '@syuilo/aiscript';
|
||||||
import type { FormWithDefault } from '@/utility/form.js';
|
import type { FormWithDefault } from '@/utility/form.js';
|
||||||
import { genId } from '@/utility/id.js';
|
import { genId } from '@/utility/id.js';
|
||||||
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
|
|
||||||
import { store } from '@/store.js';
|
import { store } from '@/store.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
@ -39,7 +38,13 @@ export type AiScriptPluginMeta = {
|
||||||
config?: Record<string, any>;
|
config?: Record<string, any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parser = new Parser();
|
let _parser: Parser | null = null;
|
||||||
|
|
||||||
|
async function getParser(): Promise<Parser> {
|
||||||
|
const { Parser } = await import('@syuilo/aiscript');
|
||||||
|
_parser ??= new Parser();
|
||||||
|
return _parser;
|
||||||
|
}
|
||||||
|
|
||||||
export function isSupportedAiScriptVersion(version: string): boolean {
|
export function isSupportedAiScriptVersion(version: string): boolean {
|
||||||
try {
|
try {
|
||||||
|
@ -54,6 +59,8 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta>
|
||||||
throw new Error('code is required');
|
throw new Error('code is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { Interpreter, utils } = await import('@syuilo/aiscript');
|
||||||
|
|
||||||
const lv = utils.getLangVersion(code);
|
const lv = utils.getLangVersion(code);
|
||||||
if (lv == null) {
|
if (lv == null) {
|
||||||
throw new Error('No language version annotation found');
|
throw new Error('No language version annotation found');
|
||||||
|
@ -63,6 +70,7 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta>
|
||||||
|
|
||||||
let ast;
|
let ast;
|
||||||
try {
|
try {
|
||||||
|
const parser = await getParser();
|
||||||
ast = parser.parse(code);
|
ast = parser.parse(code);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error('Aiscript syntax error');
|
throw new Error('Aiscript syntax error');
|
||||||
|
@ -255,7 +263,10 @@ async function launchPlugin(id: Plugin['installId']): Promise<void> {
|
||||||
|
|
||||||
await authorizePlugin(plugin);
|
await authorizePlugin(plugin);
|
||||||
|
|
||||||
const aiscript = new Interpreter(createPluginEnv({
|
const { Interpreter, utils } = await import('@syuilo/aiscript');
|
||||||
|
const { aiScriptReadline } = await import('@/aiscript/api.js');
|
||||||
|
|
||||||
|
const aiscript = new Interpreter(await createPluginEnv({
|
||||||
plugin: plugin,
|
plugin: plugin,
|
||||||
storageKey: 'plugins:' + plugin.installId,
|
storageKey: 'plugins:' + plugin.installId,
|
||||||
}), {
|
}), {
|
||||||
|
@ -280,6 +291,7 @@ async function launchPlugin(id: Plugin['installId']): Promise<void> {
|
||||||
|
|
||||||
pluginContexts.set(plugin.installId, aiscript);
|
pluginContexts.set(plugin.installId, aiscript);
|
||||||
|
|
||||||
|
const parser = await getParser();
|
||||||
aiscript.exec(parser.parse(plugin.src)).then(
|
aiscript.exec(parser.parse(plugin.src)).then(
|
||||||
() => {
|
() => {
|
||||||
console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
|
console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
|
||||||
|
@ -336,9 +348,12 @@ export function changePluginActive(plugin: Plugin, active: boolean) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> {
|
async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Promise<Record<string, values.Value>> {
|
||||||
const id = opts.plugin.installId;
|
const id = opts.plugin.installId;
|
||||||
|
|
||||||
|
const { utils, values } = await import('@syuilo/aiscript');
|
||||||
|
const { createAiScriptEnv } = await import('@/aiscript/api.js');
|
||||||
|
|
||||||
const config = new Map<string, values.Value>();
|
const config = new Map<string, values.Value>();
|
||||||
for (const [k, v] of Object.entries(opts.plugin.config ?? {})) {
|
for (const [k, v] of Object.entries(opts.plugin.config ?? {})) {
|
||||||
config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default));
|
config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default));
|
||||||
|
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
|
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
|
||||||
<template #header>
|
<template #header>
|
||||||
<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || antennaName || i18n.ts._deck._columns.antenna }}</span>
|
<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.antenna }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<MkStreamingNotesTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/>
|
<MkStreamingNotesTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/>
|
||||||
|
@ -35,18 +35,13 @@ const props = defineProps<{
|
||||||
|
|
||||||
const timeline = useTemplateRef('timeline');
|
const timeline = useTemplateRef('timeline');
|
||||||
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
|
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
|
||||||
const antennaName = ref<string | null>(null);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.column.antennaId == null) {
|
if (props.column.antennaId == null) {
|
||||||
setAntenna();
|
setAntenna();
|
||||||
}
|
} else if (props.column.timelineNameCache == null) {
|
||||||
});
|
|
||||||
|
|
||||||
watch([() => props.column.name, () => props.column.antennaId], () => {
|
|
||||||
if (!props.column.name && props.column.antennaId) {
|
|
||||||
misskeyApi('antennas/show', { antennaId: props.column.antennaId })
|
misskeyApi('antennas/show', { antennaId: props.column.antennaId })
|
||||||
.then(value => antennaName.value = value.name);
|
.then(value => updateColumn(props.column.id, { timelineNameCache: value.name }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -77,6 +72,7 @@ async function setAntenna() {
|
||||||
antennasCache.delete();
|
antennasCache.delete();
|
||||||
updateColumn(props.column.id, {
|
updateColumn(props.column.id, {
|
||||||
antennaId: newAntenna.id,
|
antennaId: newAntenna.id,
|
||||||
|
timelineNameCache: newAntenna.name,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
closed: () => {
|
closed: () => {
|
||||||
|
@ -88,6 +84,7 @@ async function setAntenna() {
|
||||||
|
|
||||||
updateColumn(props.column.id, {
|
updateColumn(props.column.id, {
|
||||||
antennaId: antenna.id,
|
antennaId: antenna.id,
|
||||||
|
timelineNameCache: antenna.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
|
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
|
||||||
<template #header>
|
<template #header>
|
||||||
<i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name || channel?.name || i18n.ts._deck._columns.channel }}</span>
|
<i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.channel }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="column.channelId">
|
<template v-if="column.channelId">
|
||||||
|
@ -46,13 +46,9 @@ const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null,
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.column.channelId == null) {
|
if (props.column.channelId == null) {
|
||||||
setChannel();
|
setChannel();
|
||||||
}
|
} else if (!props.column.name && props.column.channelId) {
|
||||||
});
|
|
||||||
|
|
||||||
watch([() => props.column.name, () => props.column.channelId], () => {
|
|
||||||
if (!props.column.name && props.column.channelId) {
|
|
||||||
misskeyApi('channels/show', { channelId: props.column.channelId })
|
misskeyApi('channels/show', { channelId: props.column.channelId })
|
||||||
.then(value => channel.value = value);
|
.then(value => updateColumn(props.column.id, { timelineNameCache: value.name }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -72,7 +68,7 @@ async function setChannel() {
|
||||||
if (canceled || chosenChannel == null) return;
|
if (canceled || chosenChannel == null) return;
|
||||||
updateColumn(props.column.id, {
|
updateColumn(props.column.id, {
|
||||||
channelId: chosenChannel.id,
|
channelId: chosenChannel.id,
|
||||||
name: chosenChannel.name,
|
timelineNameCache: chosenChannel.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
|
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
|
||||||
<template #header>
|
<template #header>
|
||||||
<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ (column.name || listName) ?? i18n.ts._deck._columns.list }}</span>
|
<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.list }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<MkStreamingNotesTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/>
|
<MkStreamingNotesTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/>
|
||||||
|
@ -36,18 +36,13 @@ const props = defineProps<{
|
||||||
const timeline = useTemplateRef('timeline');
|
const timeline = useTemplateRef('timeline');
|
||||||
const withRenotes = ref(props.column.withRenotes ?? true);
|
const withRenotes = ref(props.column.withRenotes ?? true);
|
||||||
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
|
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
|
||||||
const listName = ref<string | null>(null);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.column.listId == null) {
|
if (props.column.listId == null) {
|
||||||
setList();
|
setList();
|
||||||
}
|
} else if (props.column.timelineNameCache == null) {
|
||||||
});
|
|
||||||
|
|
||||||
watch([() => props.column.name, () => props.column.listId], () => {
|
|
||||||
if (!props.column.name && props.column.listId) {
|
|
||||||
misskeyApi('users/lists/show', { listId: props.column.listId })
|
misskeyApi('users/lists/show', { listId: props.column.listId })
|
||||||
.then(value => listName.value = value.name);
|
.then(value => updateColumn(props.column.id, { timelineNameCache: value.name }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -89,10 +84,12 @@ async function setList() {
|
||||||
|
|
||||||
updateColumn(props.column.id, {
|
updateColumn(props.column.id, {
|
||||||
listId: res.id,
|
listId: res.id,
|
||||||
|
timelineNameCache: res.name,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateColumn(props.column.id, {
|
updateColumn(props.column.id, {
|
||||||
listId: list.id,
|
listId: list.id,
|
||||||
|
timelineNameCache: list.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
|
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
|
||||||
<template #header>
|
<template #header>
|
||||||
<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || roleName || i18n.ts._deck._columns.roleTimeline }}</span>
|
<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.roleTimeline }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<MkStreamingNotesTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/>
|
<MkStreamingNotesTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/>
|
||||||
|
@ -33,18 +33,13 @@ const props = defineProps<{
|
||||||
|
|
||||||
const timeline = useTemplateRef('timeline');
|
const timeline = useTemplateRef('timeline');
|
||||||
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
|
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
|
||||||
const roleName = ref<string | null>(null);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.column.roleId == null) {
|
if (props.column.roleId == null) {
|
||||||
setRole();
|
setRole();
|
||||||
}
|
} else if (props.column.timelineNameCache == null) {
|
||||||
});
|
|
||||||
|
|
||||||
watch([() => props.column.name, () => props.column.roleId], () => {
|
|
||||||
if (!props.column.name && props.column.roleId) {
|
|
||||||
misskeyApi('roles/show', { roleId: props.column.roleId })
|
misskeyApi('roles/show', { roleId: props.column.roleId })
|
||||||
.then(value => roleName.value = value.name);
|
.then(value => updateColumn(props.column.id, { timelineNameCache: value.name }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -64,6 +59,7 @@ async function setRole() {
|
||||||
if (canceled || role == null) return;
|
if (canceled || role == null) return;
|
||||||
updateColumn(props.column.id, {
|
updateColumn(props.column.id, {
|
||||||
roleId: role.id,
|
roleId: role.id,
|
||||||
|
timelineNameCache: role.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -78,7 +78,10 @@ export class Autocomplete {
|
||||||
const caretPos = Number(this.textarea.selectionStart);
|
const caretPos = Number(this.textarea.selectionStart);
|
||||||
const text = this.text.substring(0, caretPos).split('\n').pop()!;
|
const text = this.text.substring(0, caretPos).split('\n').pop()!;
|
||||||
|
|
||||||
const mentionIndex = text.lastIndexOf('@');
|
// メンションに含められる文字のみで構成された、最も末尾にある文字列を抽出
|
||||||
|
const mentionCandidate = text.split(/[^a-zA-Z0-9_@.\-]+/).pop()!;
|
||||||
|
|
||||||
|
const mentionIndex = mentionCandidate.lastIndexOf('@');
|
||||||
const hashtagIndex = text.lastIndexOf('#');
|
const hashtagIndex = text.lastIndexOf('#');
|
||||||
const emojiIndex = text.lastIndexOf(':');
|
const emojiIndex = text.lastIndexOf(':');
|
||||||
const mfmTagIndex = text.lastIndexOf('$');
|
const mfmTagIndex = text.lastIndexOf('$');
|
||||||
|
@ -97,7 +100,7 @@ export class Autocomplete {
|
||||||
|
|
||||||
const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop();
|
const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop();
|
||||||
|
|
||||||
const isMention = mentionIndex !== -1;
|
const maybeMention = mentionIndex !== -1;
|
||||||
const isHashtag = hashtagIndex !== -1;
|
const isHashtag = hashtagIndex !== -1;
|
||||||
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
|
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
|
||||||
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
|
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
|
||||||
|
@ -107,15 +110,21 @@ export class Autocomplete {
|
||||||
|
|
||||||
let opened = false;
|
let opened = false;
|
||||||
|
|
||||||
if (isMention && this.onlyType.includes('user')) {
|
if (maybeMention && this.onlyType.includes('user')) {
|
||||||
// ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう
|
// ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう
|
||||||
// この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、
|
// この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、
|
||||||
// ホスト名を含むリモートのユーザ名を全て拾えるようにする
|
// ホスト名を含むリモートのユーザ名を全て拾えるようにする
|
||||||
const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1);
|
const mentionIndexAlt = mentionCandidate.lastIndexOf('@', mentionIndex - 1);
|
||||||
const username = mentionIndexAlt === -1
|
|
||||||
? text.substring(mentionIndex + 1)
|
// @が連続している場合、1つ目を無視する
|
||||||
: text.substring(mentionIndexAlt + 1);
|
const mentionIndexLeft = (mentionIndexAlt !== -1 && mentionIndexAlt !== mentionIndex - 1) ? mentionIndexAlt : mentionIndex;
|
||||||
if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) {
|
|
||||||
|
// メンションを構成する条件を満たしているか確認する
|
||||||
|
const isMention = mentionIndexLeft === 0 || '_@.-'.includes(mentionCandidate[mentionIndexLeft - 1]);
|
||||||
|
|
||||||
|
if (isMention) {
|
||||||
|
const username = mentionCandidate.substring(mentionIndexLeft + 1);
|
||||||
|
if (username !== '' && username.match(/^[a-zA-Z0-9_@.\-]+$/)) {
|
||||||
this.open('user', username);
|
this.open('user', username);
|
||||||
opened = true;
|
opened = true;
|
||||||
} else if (username === '') {
|
} else if (username === '') {
|
||||||
|
@ -123,6 +132,7 @@ export class Autocomplete {
|
||||||
opened = true;
|
opened = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isHashtag && !opened && this.onlyType.includes('hashtag')) {
|
if (isHashtag && !opened && this.onlyType.includes('hashtag')) {
|
||||||
const hashtag = text.substring(hashtagIndex + 1);
|
const hashtag = text.substring(hashtagIndex + 1);
|
||||||
|
|
|
@ -17,3 +17,11 @@ export const notesSearchAvailable = (
|
||||||
export const canSearchNonLocalNotes = (
|
export const canSearchNonLocalNotes = (
|
||||||
instance.noteSearchableScope === 'global'
|
instance.noteSearchableScope === 'global'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const usersSearchAvailable = (
|
||||||
|
// FIXME: instance.policies would be null in Vitest
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
($i == null && instance.policies != null && instance.policies.canSearchUsers) ||
|
||||||
|
($i != null && $i.policies.canSearchUsers) ||
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
|
@ -6,22 +6,78 @@
|
||||||
import { getProxiedImageUrl } from '../media-proxy.js';
|
import { getProxiedImageUrl } from '../media-proxy.js';
|
||||||
import { initShaderProgram } from '../webgl.js';
|
import { initShaderProgram } from '../webgl.js';
|
||||||
|
|
||||||
|
export type ImageEffectorRGB = [r: number, g: number, b: number];
|
||||||
|
|
||||||
type ParamTypeToPrimitive = {
|
type ParamTypeToPrimitive = {
|
||||||
'number': number;
|
[K in ImageEffectorFxParamDef['type']]: (ImageEffectorFxParamDef & { type: K })['default'];
|
||||||
'number:enum': number;
|
|
||||||
'boolean': boolean;
|
|
||||||
'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
|
|
||||||
'seed': number;
|
|
||||||
'texture': { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null;
|
|
||||||
'color': [r: number, g: number, b: number];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ImageEffectorFxParamDefs = Record<string, {
|
interface CommonParamDef {
|
||||||
type: keyof ParamTypeToPrimitive;
|
type: string;
|
||||||
default: any;
|
|
||||||
label?: string;
|
label?: string;
|
||||||
toViewValue?: (v: any) => string;
|
caption?: string;
|
||||||
}>;
|
default: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NumberParamDef extends CommonParamDef {
|
||||||
|
type: 'number';
|
||||||
|
default: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
toViewValue?: (v: number) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NumberEnumParamDef extends CommonParamDef {
|
||||||
|
type: 'number:enum';
|
||||||
|
enum: {
|
||||||
|
value: number;
|
||||||
|
label?: string;
|
||||||
|
icon?: string;
|
||||||
|
}[];
|
||||||
|
default: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BooleanParamDef extends CommonParamDef {
|
||||||
|
type: 'boolean';
|
||||||
|
default: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AlignParamDef extends CommonParamDef {
|
||||||
|
type: 'align';
|
||||||
|
default: {
|
||||||
|
x: 'left' | 'center' | 'right';
|
||||||
|
y: 'top' | 'center' | 'bottom';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SeedParamDef extends CommonParamDef {
|
||||||
|
type: 'seed';
|
||||||
|
default: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TextureParamDef extends CommonParamDef {
|
||||||
|
type: 'texture';
|
||||||
|
default: { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ColorParamDef extends CommonParamDef {
|
||||||
|
type: 'color';
|
||||||
|
default: ImageEffectorRGB;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | TextureParamDef | ColorParamDef;
|
||||||
|
|
||||||
|
export type ImageEffectorFxParamDefs = Record<string, ImageEffectorFxParamDef>;
|
||||||
|
|
||||||
|
export type GetParamType<T extends ImageEffectorFxParamDef> =
|
||||||
|
T extends NumberEnumParamDef
|
||||||
|
? T['enum'][number]['value']
|
||||||
|
: ParamTypeToPrimitive[T['type']];
|
||||||
|
|
||||||
|
export type ParamsRecordTypeToDefRecord<PS extends ImageEffectorFxParamDefs> = {
|
||||||
|
[K in keyof PS]: GetParamType<PS[K]>;
|
||||||
|
};
|
||||||
|
|
||||||
export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) {
|
export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) {
|
||||||
return fx;
|
return fx;
|
||||||
|
@ -36,9 +92,7 @@ export type ImageEffectorFx<ID extends string = string, PS extends ImageEffector
|
||||||
main: (ctx: {
|
main: (ctx: {
|
||||||
gl: WebGL2RenderingContext;
|
gl: WebGL2RenderingContext;
|
||||||
program: WebGLProgram;
|
program: WebGLProgram;
|
||||||
params: {
|
params: ParamsRecordTypeToDefRecord<PS>;
|
||||||
[key in keyof PS]: ParamTypeToPrimitive[PS[key]['type']];
|
|
||||||
};
|
|
||||||
u: Record<US[number], WebGLUniformLocation>;
|
u: Record<US[number], WebGLUniformLocation>;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|
|
@ -48,20 +48,22 @@ void main() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FX_blockNoise = defineImageEffectorFx({
|
export const FX_blockNoise = defineImageEffectorFx({
|
||||||
id: 'blockNoise' as const,
|
id: 'blockNoise',
|
||||||
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
|
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
|
||||||
shader,
|
shader,
|
||||||
uniforms: ['amount', 'channelShift'] as const,
|
uniforms: ['amount', 'channelShift'] as const,
|
||||||
params: {
|
params: {
|
||||||
amount: {
|
amount: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.amount,
|
||||||
|
type: 'number',
|
||||||
default: 50,
|
default: 50,
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 100,
|
max: 100,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
strength: {
|
strength: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.strength,
|
||||||
|
type: 'number',
|
||||||
default: 0.05,
|
default: 0.05,
|
||||||
min: -1,
|
min: -1,
|
||||||
max: 1,
|
max: 1,
|
||||||
|
@ -69,7 +71,8 @@ export const FX_blockNoise = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
type: 'number' as const,
|
label: i18n.ts.width,
|
||||||
|
type: 'number',
|
||||||
default: 0.05,
|
default: 0.05,
|
||||||
min: 0.01,
|
min: 0.01,
|
||||||
max: 1,
|
max: 1,
|
||||||
|
@ -77,7 +80,8 @@ export const FX_blockNoise = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: 'number' as const,
|
label: i18n.ts.height,
|
||||||
|
type: 'number',
|
||||||
default: 0.01,
|
default: 0.01,
|
||||||
min: 0.01,
|
min: 0.01,
|
||||||
max: 1,
|
max: 1,
|
||||||
|
@ -85,7 +89,8 @@ export const FX_blockNoise = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
channelShift: {
|
channelShift: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.glitchChannelShift,
|
||||||
|
type: 'number',
|
||||||
default: 0,
|
default: 0,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 10,
|
max: 10,
|
||||||
|
@ -93,7 +98,8 @@ export const FX_blockNoise = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
seed: {
|
seed: {
|
||||||
type: 'seed' as const,
|
label: i18n.ts._imageEffector._fxProps.seed,
|
||||||
|
type: 'seed',
|
||||||
default: 100,
|
default: 100,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -47,13 +47,14 @@ void main() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FX_checker = defineImageEffectorFx({
|
export const FX_checker = defineImageEffectorFx({
|
||||||
id: 'checker' as const,
|
id: 'checker',
|
||||||
name: i18n.ts._imageEffector._fxs.checker,
|
name: i18n.ts._imageEffector._fxs.checker,
|
||||||
shader,
|
shader,
|
||||||
uniforms: ['angle', 'scale', 'color', 'opacity'] as const,
|
uniforms: ['angle', 'scale', 'color', 'opacity'] as const,
|
||||||
params: {
|
params: {
|
||||||
angle: {
|
angle: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.angle,
|
||||||
|
type: 'number',
|
||||||
default: 0,
|
default: 0,
|
||||||
min: -1.0,
|
min: -1.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
|
@ -61,18 +62,21 @@ export const FX_checker = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 90) + '°',
|
toViewValue: v => Math.round(v * 90) + '°',
|
||||||
},
|
},
|
||||||
scale: {
|
scale: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.scale,
|
||||||
|
type: 'number',
|
||||||
default: 3.0,
|
default: 3.0,
|
||||||
min: 1.0,
|
min: 1.0,
|
||||||
max: 10.0,
|
max: 10.0,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
},
|
},
|
||||||
color: {
|
color: {
|
||||||
type: 'color' as const,
|
label: i18n.ts._imageEffector._fxProps.color,
|
||||||
|
type: 'color',
|
||||||
default: [1, 1, 1],
|
default: [1, 1, 1],
|
||||||
},
|
},
|
||||||
opacity: {
|
opacity: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.opacity,
|
||||||
|
type: 'number',
|
||||||
default: 0.5,
|
default: 0.5,
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
|
|
|
@ -52,17 +52,19 @@ void main() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FX_chromaticAberration = defineImageEffectorFx({
|
export const FX_chromaticAberration = defineImageEffectorFx({
|
||||||
id: 'chromaticAberration' as const,
|
id: 'chromaticAberration',
|
||||||
name: i18n.ts._imageEffector._fxs.chromaticAberration,
|
name: i18n.ts._imageEffector._fxs.chromaticAberration,
|
||||||
shader,
|
shader,
|
||||||
uniforms: ['amount', 'start', 'normalize'] as const,
|
uniforms: ['amount', 'start', 'normalize'] as const,
|
||||||
params: {
|
params: {
|
||||||
normalize: {
|
normalize: {
|
||||||
type: 'boolean' as const,
|
label: i18n.ts._imageEffector._fxProps.normalize,
|
||||||
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
amount: {
|
amount: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.amount,
|
||||||
|
type: 'number',
|
||||||
default: 0.1,
|
default: 0.1,
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
|
|
|
@ -85,13 +85,14 @@ void main() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FX_colorAdjust = defineImageEffectorFx({
|
export const FX_colorAdjust = defineImageEffectorFx({
|
||||||
id: 'colorAdjust' as const,
|
id: 'colorAdjust',
|
||||||
name: i18n.ts._imageEffector._fxs.colorAdjust,
|
name: i18n.ts._imageEffector._fxs.colorAdjust,
|
||||||
shader,
|
shader,
|
||||||
uniforms: ['lightness', 'contrast', 'hue', 'brightness', 'saturation'] as const,
|
uniforms: ['lightness', 'contrast', 'hue', 'brightness', 'saturation'] as const,
|
||||||
params: {
|
params: {
|
||||||
lightness: {
|
lightness: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.lightness,
|
||||||
|
type: 'number',
|
||||||
default: 0,
|
default: 0,
|
||||||
min: -1,
|
min: -1,
|
||||||
max: 1,
|
max: 1,
|
||||||
|
@ -99,7 +100,8 @@ export const FX_colorAdjust = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
contrast: {
|
contrast: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.contrast,
|
||||||
|
type: 'number',
|
||||||
default: 1,
|
default: 1,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 4,
|
max: 4,
|
||||||
|
@ -107,7 +109,8 @@ export const FX_colorAdjust = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
hue: {
|
hue: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.hue,
|
||||||
|
type: 'number',
|
||||||
default: 0,
|
default: 0,
|
||||||
min: -1,
|
min: -1,
|
||||||
max: 1,
|
max: 1,
|
||||||
|
@ -115,7 +118,8 @@ export const FX_colorAdjust = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 180) + '°',
|
toViewValue: v => Math.round(v * 180) + '°',
|
||||||
},
|
},
|
||||||
brightness: {
|
brightness: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.brightness,
|
||||||
|
type: 'number',
|
||||||
default: 1,
|
default: 1,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 4,
|
max: 4,
|
||||||
|
@ -123,7 +127,8 @@ export const FX_colorAdjust = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
saturation: {
|
saturation: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.saturation,
|
||||||
|
type: 'number',
|
||||||
default: 1,
|
default: 1,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 4,
|
max: 4,
|
||||||
|
|
|
@ -26,13 +26,14 @@ void main() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FX_colorClamp = defineImageEffectorFx({
|
export const FX_colorClamp = defineImageEffectorFx({
|
||||||
id: 'colorClamp' as const,
|
id: 'colorClamp',
|
||||||
name: i18n.ts._imageEffector._fxs.colorClamp,
|
name: i18n.ts._imageEffector._fxs.colorClamp,
|
||||||
shader,
|
shader,
|
||||||
uniforms: ['max', 'min'] as const,
|
uniforms: ['max', 'min'] as const,
|
||||||
params: {
|
params: {
|
||||||
max: {
|
max: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.max,
|
||||||
|
type: 'number',
|
||||||
default: 1.0,
|
default: 1.0,
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
|
@ -40,7 +41,8 @@ export const FX_colorClamp = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
min: {
|
min: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.min,
|
||||||
|
type: 'number',
|
||||||
default: -1.0,
|
default: -1.0,
|
||||||
min: -1.0,
|
min: -1.0,
|
||||||
max: 0.0,
|
max: 0.0,
|
||||||
|
|
|
@ -30,13 +30,14 @@ void main() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FX_colorClampAdvanced = defineImageEffectorFx({
|
export const FX_colorClampAdvanced = defineImageEffectorFx({
|
||||||
id: 'colorClampAdvanced' as const,
|
id: 'colorClampAdvanced',
|
||||||
name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
|
name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
|
||||||
shader,
|
shader,
|
||||||
uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
|
uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
|
||||||
params: {
|
params: {
|
||||||
rMax: {
|
rMax: {
|
||||||
type: 'number' as const,
|
label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.redComponent})`,
|
||||||
|
type: 'number',
|
||||||
default: 1.0,
|
default: 1.0,
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
|
@ -44,7 +45,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
rMin: {
|
rMin: {
|
||||||
type: 'number' as const,
|
label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.redComponent})`,
|
||||||
|
type: 'number',
|
||||||
default: -1.0,
|
default: -1.0,
|
||||||
min: -1.0,
|
min: -1.0,
|
||||||
max: 0.0,
|
max: 0.0,
|
||||||
|
@ -52,7 +54,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
gMax: {
|
gMax: {
|
||||||
type: 'number' as const,
|
label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.greenComponent})`,
|
||||||
|
type: 'number',
|
||||||
default: 1.0,
|
default: 1.0,
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
|
@ -60,7 +63,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
gMin: {
|
gMin: {
|
||||||
type: 'number' as const,
|
label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.greenComponent})`,
|
||||||
|
type: 'number',
|
||||||
default: -1.0,
|
default: -1.0,
|
||||||
min: -1.0,
|
min: -1.0,
|
||||||
max: 0.0,
|
max: 0.0,
|
||||||
|
@ -68,7 +72,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
bMax: {
|
bMax: {
|
||||||
type: 'number' as const,
|
label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.blueComponent})`,
|
||||||
|
type: 'number',
|
||||||
default: 1.0,
|
default: 1.0,
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
|
@ -76,7 +81,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
bMin: {
|
bMin: {
|
||||||
type: 'number' as const,
|
label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.blueComponent})`,
|
||||||
|
type: 'number',
|
||||||
default: -1.0,
|
default: -1.0,
|
||||||
min: -1.0,
|
min: -1.0,
|
||||||
max: 0.0,
|
max: 0.0,
|
||||||
|
|
|
@ -34,18 +34,23 @@ void main() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FX_distort = defineImageEffectorFx({
|
export const FX_distort = defineImageEffectorFx({
|
||||||
id: 'distort' as const,
|
id: 'distort',
|
||||||
name: i18n.ts._imageEffector._fxs.distort,
|
name: i18n.ts._imageEffector._fxs.distort,
|
||||||
shader,
|
shader,
|
||||||
uniforms: ['phase', 'frequency', 'strength', 'direction'] as const,
|
uniforms: ['phase', 'frequency', 'strength', 'direction'] as const,
|
||||||
params: {
|
params: {
|
||||||
direction: {
|
direction: {
|
||||||
type: 'number:enum' as const,
|
label: i18n.ts._imageEffector._fxProps.direction,
|
||||||
enum: [{ value: 0, label: 'v' }, { value: 1, label: 'h' }],
|
type: 'number:enum',
|
||||||
|
enum: [
|
||||||
|
{ value: 0 as const, label: i18n.ts.horizontal },
|
||||||
|
{ value: 1 as const, label: i18n.ts.vertical },
|
||||||
|
],
|
||||||
default: 1,
|
default: 1,
|
||||||
},
|
},
|
||||||
phase: {
|
phase: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.phase,
|
||||||
|
type: 'number',
|
||||||
default: 0.0,
|
default: 0.0,
|
||||||
min: -1.0,
|
min: -1.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
|
@ -53,14 +58,16 @@ export const FX_distort = defineImageEffectorFx({
|
||||||
toViewValue: v => Math.round(v * 100) + '%',
|
toViewValue: v => Math.round(v * 100) + '%',
|
||||||
},
|
},
|
||||||
frequency: {
|
frequency: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.frequency,
|
||||||
|
type: 'number',
|
||||||
default: 30,
|
default: 30,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
},
|
},
|
||||||
strength: {
|
strength: {
|
||||||
type: 'number' as const,
|
label: i18n.ts._imageEffector._fxProps.strength,
|
||||||
|
type: 'number',
|
||||||
default: 0.05,
|
default: 0.05,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1,
|
max: 1,
|
||||||
|
|
|
@ -26,7 +26,7 @@ void main() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FX_grayscale = defineImageEffectorFx({
|
export const FX_grayscale = defineImageEffectorFx({
|
||||||
id: 'grayscale' as const,
|
id: 'grayscale',
|
||||||
name: i18n.ts._imageEffector._fxs.grayscale,
|
name: i18n.ts._imageEffector._fxs.grayscale,
|
||||||
shader,
|
shader,
|
||||||
uniforms: [] as const,
|
uniforms: [] as const,
|
||||||
|
|
|
@ -27,21 +27,24 @@ void main() {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FX_invert = defineImageEffectorFx({
|
export const FX_invert = defineImageEffectorFx({
|
||||||
id: 'invert' as const,
|
id: 'invert',
|
||||||
name: i18n.ts._imageEffector._fxs.invert,
|
name: i18n.ts._imageEffector._fxs.invert,
|
||||||
shader,
|
shader,
|
||||||
uniforms: ['r', 'g', 'b'] as const,
|
uniforms: ['r', 'g', 'b'] as const,
|
||||||
params: {
|
params: {
|
||||||
r: {
|
r: {
|
||||||
type: 'boolean' as const,
|
label: i18n.ts._imageEffector._fxProps.redComponent,
|
||||||
|
type: 'boolean',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
g: {
|
g: {
|
||||||
type: 'boolean' as const,
|
label: i18n.ts._imageEffector._fxProps.greenComponent,
|
||||||
|
type: 'boolean',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
b: {
|
b: {
|
||||||
type: 'boolean' as const,
|
label: i18n.ts._imageEffector._fxProps.blueComponent,
|
||||||
|
type: 'boolean',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue