mirror of
https://github.com/misskey-dev/misskey
synced 2025-08-16 09:02:50 +02:00
Compare commits
24 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 |
77 changed files with 2864 additions and 2705 deletions
2
.github/workflows/dockle.yml
vendored
2
.github/workflows/dockle.yml
vendored
|
@ -25,7 +25,7 @@ jobs:
|
|||
cp ./compose_example.yml ./compose.yml
|
||||
- run: |
|
||||
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: |
|
||||
cmd="dockle --exit-code 1 misskey-web:latest ${image_name}"
|
||||
echo "> ${cmd}"
|
||||
|
|
|
@ -13,10 +13,13 @@
|
|||
- 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。
|
||||
- サーバーの初期設定が完了するまでは連合がオンにならないようになりました
|
||||
- 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました
|
||||
- 実際の動作に即した名称になり、馴染みのない人でも理解しやすくなりました
|
||||
- 他サービスにおける「ダイレクトメッセージ」に相当するMisskeyの機能は「チャット」ですが、「ダイレクト投稿」という名称の機能が存在するとそちらがダイレクトメッセージ機能であるような誤解を生んでいました
|
||||
- mfm.jsをアップデートしました
|
||||
- Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応
|
||||
- Enhance: acctに `.` が入っているユーザーのメンションに対応
|
||||
- Fix: Unicode絵文字に隣接する異体字セレクタ(`U+FE0F`)が絵文字として認識される問題を修正
|
||||
- Enhance: ユーザー検索をロールポリシーで制限できるように
|
||||
|
||||
### Client
|
||||
- Feat: AiScriptが1.0に更新されました
|
||||
|
@ -30,6 +33,7 @@
|
|||
- URLに`?safemode=true`を付ける
|
||||
- PWAのショートカットで Safemode を選択して起動する
|
||||
- Feat: ページのタブバーを下部に表示できるように
|
||||
- Enhance: 「自動でもっと見る」オプションが有効になり、安定性が向上しました
|
||||
- Enhance: コントロールパネルを検索できるように
|
||||
- Enhance: トルコ語 (tr-TR) に対応
|
||||
- Enhance: 不必要な翻訳データを読み込まなくなり、パフォーマンスが向上しました
|
||||
|
@ -41,6 +45,8 @@
|
|||
- Fix: テーマエディタが動作しない問題を修正
|
||||
- Fix: チャンネルのハイライトページにノートが表示されない問題を修正
|
||||
- Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正
|
||||
- Fix: 複数のメンションを1行に記述した場合に、サジェストが正しく表示されない問題を修正
|
||||
- Fix: メンションとしての条件を満たしていても、特定の条件(`-`が含まれる場合など)で正しくサジェストされない問題を一部修正
|
||||
|
||||
### Server
|
||||
- Enhance: ノートの削除処理の効率化
|
||||
|
|
|
@ -1599,3 +1599,9 @@ _watermarkEditor:
|
|||
type: "نوع"
|
||||
image: "صور"
|
||||
advanced: "متقدم"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
scale: "الحجم"
|
||||
size: "الحجم"
|
||||
color: "اللون"
|
||||
opacity: "الشفافية"
|
||||
|
|
|
@ -1357,3 +1357,10 @@ _watermarkEditor:
|
|||
text: "লেখা"
|
||||
image: "ছবি"
|
||||
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"
|
||||
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."
|
||||
usersSearchNotAvailable: "La cerca d'usuaris no està disponible."
|
||||
license: "Llicència"
|
||||
unfavoriteConfirm: "Esborrar dels favorits?"
|
||||
myClips: "Els meus retalls"
|
||||
|
@ -1999,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "Límits baixos són menys restrictius, límits alts són més restrictius."
|
||||
canHideAds: "Pot amagar la publicitat"
|
||||
canSearchNotes: "Pot cercar notes"
|
||||
canSearchUsers: "Pot cercar usuaris"
|
||||
canUseTranslator: "Pot fer servir el traductor"
|
||||
avatarDecorationLimit: "Nombre màxim de decoracions que es poden aplicar els avatars"
|
||||
canImportAntennas: "Autoritza la importació d'antenes "
|
||||
|
@ -3164,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "Tipus"
|
||||
image: "Imatges"
|
||||
advanced: "Avançat"
|
||||
angle: "Angle"
|
||||
stripe: "Bandes"
|
||||
stripeWidth: "Amplada de la banda"
|
||||
stripeFrequency: "Freqüència de la banda"
|
||||
angle: "Angle"
|
||||
polkadot: "Lunars"
|
||||
checker: "Escacs"
|
||||
polkadotMainDotOpacity: "Opacitat del lunar principal"
|
||||
|
@ -3179,6 +3181,7 @@ _imageEffector:
|
|||
title: "Efecte"
|
||||
addEffect: "Afegeix un efecte"
|
||||
discardChangesConfirm: "Vols descartar els canvis i sortir?"
|
||||
nothingToConfigure: "No hi ha opcions de configuració disponibles"
|
||||
_fxs:
|
||||
chromaticAberration: "Aberració cromàtica"
|
||||
glitch: "Glitch"
|
||||
|
@ -3196,6 +3199,38 @@ _imageEffector:
|
|||
checker: "Escacs"
|
||||
blockNoise: "Bloqueig de soroll"
|
||||
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:
|
||||
select: "Seleccionar esborrany"
|
||||
|
|
|
@ -2053,3 +2053,10 @@ _watermarkEditor:
|
|||
type: "Typ"
|
||||
image: "Obrázky"
|
||||
advanced: "Pokročilé"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
scale: "Velikost"
|
||||
size: "Velikost"
|
||||
color: "Barva"
|
||||
opacity: "Průhlednost"
|
||||
lightness: "Zesvětlit"
|
||||
|
|
|
@ -3147,10 +3147,10 @@ _watermarkEditor:
|
|||
type: "Art"
|
||||
image: "Bilder"
|
||||
advanced: "Fortgeschritten"
|
||||
angle: "Winkel"
|
||||
stripe: "Streifen"
|
||||
stripeWidth: "Linienbreite"
|
||||
stripeFrequency: "Linienanzahl"
|
||||
angle: "Winkel"
|
||||
polkadot: "Punktmuster"
|
||||
polkadotMainDotOpacity: "Deckkraft des Hauptpunktes"
|
||||
polkadotMainDotRadius: "Größe des Hauptpunktes"
|
||||
|
@ -3173,6 +3173,13 @@ _imageEffector:
|
|||
distort: "Verzerrung"
|
||||
stripe: "Streifen"
|
||||
polkadot: "Punktmuster"
|
||||
_fxProps:
|
||||
angle: "Winkel"
|
||||
scale: "Größe"
|
||||
size: "Größe"
|
||||
color: "Farbe"
|
||||
opacity: "Transparenz"
|
||||
lightness: "Erhellen"
|
||||
drafts: "Entwurf"
|
||||
_drafts:
|
||||
select: "Entwurf auswählen"
|
||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Using spaces will create AND expressions and surro
|
|||
hiddenTags: "Hidden hashtags"
|
||||
hiddenTagsDescription: "Select tags which will not shown on trend list.\nMultiple tags could be registered by lines."
|
||||
notesSearchNotAvailable: "Note search is unavailable."
|
||||
usersSearchNotAvailable: "User search is not available."
|
||||
license: "License"
|
||||
unfavoriteConfirm: "Really remove from favorites?"
|
||||
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."
|
||||
showUrlPreview: "Show URL preview"
|
||||
showAvailableReactionsFirstInNote: "Show available reactions at the top."
|
||||
showPageTabBarBottom: "Show page tab bar at the bottom"
|
||||
_chat:
|
||||
showSenderName: "Show sender's name"
|
||||
sendOnEnter: "Press Enter to send"
|
||||
|
@ -1998,19 +2000,20 @@ _role:
|
|||
descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. "
|
||||
canHideAds: "Can hide ads"
|
||||
canSearchNotes: "Usage of note search"
|
||||
canSearchUsers: "User search"
|
||||
canUseTranslator: "Translator usage"
|
||||
avatarDecorationLimit: "Maximum number of avatar decorations that can be applied"
|
||||
canImportAntennas: "Allow importing antennas"
|
||||
canImportBlocking: "Allow importing blocking"
|
||||
canImportFollowing: "Allow importing following"
|
||||
canImportMuting: "Allow importing muting"
|
||||
canImportUserLists: "Allow importing lists"
|
||||
chatAvailability: "Allow Chat"
|
||||
avatarDecorationLimit: "Maximum number of avatar decorations"
|
||||
canImportAntennas: "Can import antennas"
|
||||
canImportBlocking: "Can import blocking"
|
||||
canImportFollowing: "Can import following"
|
||||
canImportMuting: "Can import muting"
|
||||
canImportUserLists: "Can import lists"
|
||||
chatAvailability: "Chat"
|
||||
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_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"
|
||||
watermarkAvailable: "Availability of watermark function"
|
||||
watermarkAvailable: "Watermark function"
|
||||
_condition:
|
||||
roleAssignedTo: "Assigned to manual roles"
|
||||
isLocal: "Local user"
|
||||
|
@ -3163,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "Type"
|
||||
image: "Images"
|
||||
advanced: "Advanced"
|
||||
angle: "Angle"
|
||||
stripe: "Stripes"
|
||||
stripeWidth: "Line width"
|
||||
stripeFrequency: "Lines count"
|
||||
angle: "Angle"
|
||||
polkadot: "Polkadot"
|
||||
checker: "Checker"
|
||||
polkadotMainDotOpacity: "Opacity of the main dot"
|
||||
|
@ -3178,6 +3181,7 @@ _imageEffector:
|
|||
title: "Effects"
|
||||
addEffect: "Add Effects"
|
||||
discardChangesConfirm: "Are you sure you want to leave? You have unsaved changes."
|
||||
nothingToConfigure: "No configurable options available"
|
||||
_fxs:
|
||||
chromaticAberration: "Chromatic Aberration"
|
||||
glitch: "Glitch"
|
||||
|
@ -3195,6 +3199,38 @@ _imageEffector:
|
|||
checker: "Checker"
|
||||
blockNoise: "Block Noise"
|
||||
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:
|
||||
select: "Select Draft"
|
||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Si se usan espacios se crearán expresiones AND y
|
|||
hiddenTags: "Hashtags ocultos"
|
||||
hiddenTagsDescription: "Selecciona las etiquetas que no se mostrarán en tendencias. Una etiqueta por línea."
|
||||
notesSearchNotAvailable: "No se puede buscar una nota"
|
||||
usersSearchNotAvailable: "La búsqueda de usuarios no está disponible."
|
||||
license: "Licencia"
|
||||
unfavoriteConfirm: "¿Desea quitar de favoritos?"
|
||||
myClips: "Mis clips"
|
||||
|
@ -1999,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos"
|
||||
canHideAds: "Puede ocultar anuncios"
|
||||
canSearchNotes: "Uso de la búsqueda de notas"
|
||||
canSearchUsers: "Uso de la búsqueda de usuarios"
|
||||
canUseTranslator: "Uso de traductor"
|
||||
avatarDecorationLimit: "Número máximo de decoraciones de avatar"
|
||||
canImportAntennas: "Permitir la importación de antenas"
|
||||
|
@ -3164,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "Tipo"
|
||||
image: "Imágenes"
|
||||
advanced: "Avanzado"
|
||||
angle: "Ángulo"
|
||||
stripe: "Rayas"
|
||||
stripeWidth: "Anchura de línea"
|
||||
stripeFrequency: "Número de líneas."
|
||||
angle: "Ángulo"
|
||||
polkadot: "Lunares"
|
||||
checker: "verificador"
|
||||
polkadotMainDotOpacity: "Opacidad del círculo principal"
|
||||
|
@ -3179,6 +3181,7 @@ _imageEffector:
|
|||
title: "Efecto"
|
||||
addEffect: "Añadir Efecto"
|
||||
discardChangesConfirm: "¿Ignorar cambios y salir?"
|
||||
nothingToConfigure: "No hay opciones configurables disponibles."
|
||||
_fxs:
|
||||
chromaticAberration: "Aberración Cromática"
|
||||
glitch: "Glitch"
|
||||
|
@ -3196,6 +3199,38 @@ _imageEffector:
|
|||
checker: "Corrector"
|
||||
blockNoise: "Bloquear Ruido"
|
||||
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:
|
||||
select: "Seleccionar borradores"
|
||||
|
|
|
@ -2372,3 +2372,11 @@ _watermarkEditor:
|
|||
image: "Images"
|
||||
advanced: "Avancé"
|
||||
angle: "Angle"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
angle: "Angle"
|
||||
scale: "Taille"
|
||||
size: "Taille"
|
||||
color: "Couleur"
|
||||
opacity: "Transparence"
|
||||
lightness: "Clair"
|
||||
|
|
|
@ -2627,3 +2627,11 @@ _watermarkEditor:
|
|||
image: "Gambar"
|
||||
advanced: "Tingkat lanjut"
|
||||
angle: "Sudut"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
angle: "Sudut"
|
||||
scale: "Ukuran"
|
||||
size: "Ukuran"
|
||||
color: "Warna"
|
||||
opacity: "Opasitas"
|
||||
lightness: "Menerangkan"
|
||||
|
|
8
locales/index.d.ts
vendored
8
locales/index.d.ts
vendored
|
@ -4386,6 +4386,10 @@ export interface Locale extends ILocale {
|
|||
* ノート検索は利用できません。
|
||||
*/
|
||||
"notesSearchNotAvailable": string;
|
||||
/**
|
||||
* ユーザー検索は利用できません。
|
||||
*/
|
||||
"usersSearchNotAvailable": string;
|
||||
/**
|
||||
* ライセンス
|
||||
*/
|
||||
|
@ -7799,6 +7803,10 @@ export interface Locale extends ILocale {
|
|||
* ノート検索の利用
|
||||
*/
|
||||
"canSearchNotes": string;
|
||||
/**
|
||||
* ユーザー検索の利用
|
||||
*/
|
||||
"canSearchUsers": string;
|
||||
/**
|
||||
* 翻訳機能の利用
|
||||
*/
|
||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Gli spazi creano la relazione \"E\" tra parole (qu
|
|||
hiddenTags: "Hashtag nascosti"
|
||||
hiddenTagsDescription: "Impedire la visualizzazione del tag impostato nei trend. Puoi impostare più valori, uno per riga."
|
||||
notesSearchNotAvailable: "Non è possibile cercare tra le Note."
|
||||
usersSearchNotAvailable: "La ricerca profili non è disponibile."
|
||||
license: "Licenza"
|
||||
unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?"
|
||||
myClips: "Le mie Clip"
|
||||
|
@ -1999,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più."
|
||||
canHideAds: "Nascondere i banner"
|
||||
canSearchNotes: "Ricercare nelle Note"
|
||||
canSearchUsers: "Può cercare profili"
|
||||
canUseTranslator: "Tradurre le Note"
|
||||
avatarDecorationLimit: "Numero massimo di decorazioni foto profilo installabili"
|
||||
canImportAntennas: "Può importare Antenne"
|
||||
|
@ -3164,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "Tipo"
|
||||
image: "Immagini"
|
||||
advanced: "Avanzato"
|
||||
angle: "Angolo"
|
||||
stripe: "Strisce"
|
||||
stripeWidth: "Larghezza della linea"
|
||||
stripeFrequency: "Il numero di linee"
|
||||
angle: "Angolo"
|
||||
polkadot: "A pallini"
|
||||
checker: "revisore"
|
||||
polkadotMainDotOpacity: "Opacità del punto principale"
|
||||
|
@ -3179,6 +3181,7 @@ _imageEffector:
|
|||
title: "Effetto"
|
||||
addEffect: "Aggiungi effetto"
|
||||
discardChangesConfirm: "Scarta le modifiche ed esci?"
|
||||
nothingToConfigure: "Nessuna impostazione configurabile."
|
||||
_fxs:
|
||||
chromaticAberration: "Aberrazione cromatica"
|
||||
glitch: "Glitch"
|
||||
|
@ -3196,6 +3199,38 @@ _imageEffector:
|
|||
checker: "revisore"
|
||||
blockNoise: "Attenua rumore"
|
||||
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:
|
||||
select: "Selezionare bozza"
|
||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "スペースで区切るとAND指定になり、
|
|||
hiddenTags: "非表示ハッシュタグ"
|
||||
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
|
||||
notesSearchNotAvailable: "ノート検索は利用できません。"
|
||||
usersSearchNotAvailable: "ユーザー検索は利用できません。"
|
||||
license: "ライセンス"
|
||||
unfavoriteConfirm: "お気に入り解除しますか?"
|
||||
myClips: "自分のクリップ"
|
||||
|
@ -2020,6 +2021,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。"
|
||||
canHideAds: "広告の非表示"
|
||||
canSearchNotes: "ノート検索の利用"
|
||||
canSearchUsers: "ユーザー検索の利用"
|
||||
canUseTranslator: "翻訳機能の利用"
|
||||
avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
|
||||
canImportAntennas: "アンテナのインポートを許可"
|
||||
|
|
|
@ -3020,6 +3020,13 @@ _watermarkEditor:
|
|||
angle: "角度"
|
||||
_imageEffector:
|
||||
discardChangesConfirm: "変更をせんで終わるか?"
|
||||
_fxProps:
|
||||
angle: "角度"
|
||||
scale: "大きさ"
|
||||
size: "大きさ"
|
||||
color: "色"
|
||||
opacity: "不透明度"
|
||||
lightness: "明るさ"
|
||||
_drafts:
|
||||
cannotCreateDraftAnymore: "下書きはこれ以上は作れへんな。"
|
||||
cannotCreateDraft: "この内容で下書きは作れへんな。"
|
||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며,
|
|||
hiddenTags: "숨긴 해시태그"
|
||||
hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다."
|
||||
notesSearchNotAvailable: "노트 검색을 이용하실 수 없습니다."
|
||||
usersSearchNotAvailable: "유저 검색을 이용하실 수 없습니다."
|
||||
license: "라이선스"
|
||||
unfavoriteConfirm: "즐겨찾기를 해제하시겠습니까?"
|
||||
myClips: "내 클립"
|
||||
|
@ -1999,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다."
|
||||
canHideAds: "광고 숨기기"
|
||||
canSearchNotes: "노트 검색 이용 가능 여부"
|
||||
canSearchUsers: "유저 검색 이용"
|
||||
canUseTranslator: "번역 기능의 사용"
|
||||
avatarDecorationLimit: "아바타 장식의 최대 붙임 개수"
|
||||
canImportAntennas: "안테나 가져오기 허용"
|
||||
|
@ -3164,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "종류"
|
||||
image: "이미지"
|
||||
advanced: "고급"
|
||||
angle: "각도"
|
||||
stripe: "줄무늬"
|
||||
stripeWidth: "라인의 폭"
|
||||
stripeFrequency: "라인의 수"
|
||||
angle: "각도"
|
||||
polkadot: "물방울 무늬"
|
||||
checker: "체크 무늬"
|
||||
polkadotMainDotOpacity: "주요 물방울의 불투명도"
|
||||
|
@ -3179,6 +3181,7 @@ _imageEffector:
|
|||
title: "이펙트"
|
||||
addEffect: "이펙트를 추가"
|
||||
discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?"
|
||||
nothingToConfigure: "설정 항목이 없습니다."
|
||||
_fxs:
|
||||
chromaticAberration: "색수차"
|
||||
glitch: "글리치"
|
||||
|
@ -3196,6 +3199,38 @@ _imageEffector:
|
|||
checker: "체크 무늬"
|
||||
blockNoise: "노이즈 방지"
|
||||
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:
|
||||
select: "초안 선택"
|
||||
|
|
|
@ -742,3 +742,8 @@ _watermarkEditor:
|
|||
text: "Tekst"
|
||||
type: "Type"
|
||||
image: "Bilder"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
scale: "Størrelse"
|
||||
size: "Størrelse"
|
||||
color: "Farge"
|
||||
|
|
|
@ -1593,3 +1593,10 @@ _watermarkEditor:
|
|||
type: "Typ"
|
||||
image: "Zdjęcia"
|
||||
advanced: "Zaawansowane"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
scale: "Rozmiar"
|
||||
size: "Rozmiar"
|
||||
color: "Kolor"
|
||||
opacity: "Przezroczystość"
|
||||
lightness: "Rozjaśnij"
|
||||
|
|
|
@ -3150,10 +3150,10 @@ _watermarkEditor:
|
|||
type: "Tipo"
|
||||
image: "imagem"
|
||||
advanced: "Avançado"
|
||||
angle: "Ângulo"
|
||||
stripe: "Listras"
|
||||
stripeWidth: "Largura da linha"
|
||||
stripeFrequency: "Número de linhas"
|
||||
angle: "Ângulo"
|
||||
polkadot: "Bolinhas"
|
||||
checker: "Xadrez"
|
||||
polkadotMainDotOpacity: "Opacidade da bolinha principal"
|
||||
|
@ -3182,6 +3182,13 @@ _imageEffector:
|
|||
checker: "Xadrez"
|
||||
blockNoise: "Bloquear Ruído"
|
||||
tearing: "Descontinuidade"
|
||||
_fxProps:
|
||||
angle: "Ângulo"
|
||||
scale: "Tamanho"
|
||||
size: "Tamanho"
|
||||
color: "Cor"
|
||||
opacity: "Opacidade"
|
||||
lightness: "Esclarecer"
|
||||
drafts: "Rascunhos"
|
||||
_drafts:
|
||||
select: "Selecionar Rascunho"
|
||||
|
|
|
@ -1400,3 +1400,7 @@ _watermarkEditor:
|
|||
type: "Tip"
|
||||
image: "Imagini"
|
||||
advanced: "Avansat"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
scale: "Dimensiune"
|
||||
size: "Dimensiune"
|
||||
|
|
|
@ -2257,4 +2257,12 @@ _watermarkEditor:
|
|||
image: "Изображения"
|
||||
advanced: "Для продвинутых"
|
||||
angle: "Угол"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
angle: "Угол"
|
||||
scale: "Размер"
|
||||
size: "Размер"
|
||||
color: "Цвет"
|
||||
opacity: "Непрозрачность"
|
||||
lightness: "Осветление"
|
||||
drafts: "Черновик"
|
||||
|
|
|
@ -1459,3 +1459,10 @@ _watermarkEditor:
|
|||
type: "Typ"
|
||||
image: "Obrázky"
|
||||
advanced: "Rozšírené"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
scale: "Veľkosť"
|
||||
size: "Veľkosť"
|
||||
color: "Farba"
|
||||
opacity: "Priehľadnosť"
|
||||
lightness: "Zosvetliť"
|
||||
|
|
|
@ -716,3 +716,8 @@ _search:
|
|||
_watermarkEditor:
|
||||
scale: "Storlek"
|
||||
image: "Bilder"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
scale: "Storlek"
|
||||
size: "Storlek"
|
||||
color: "Färg"
|
||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "ถ้าแยกด้วยเว้นวร
|
|||
hiddenTags: "แฮชแท็กที่ซ่อนอยู่"
|
||||
hiddenTagsDescription: "เลือกแท็กที่จะไม่แสดงในรายการเทรนด์ สามารถลงทะเบียนหลายแท็กได้โดยขึ้นบรรทัดใหม่"
|
||||
notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งาน"
|
||||
usersSearchNotAvailable: "การค้นหาผู้ใช้ไม่พร้อมใช้งาน"
|
||||
license: "ใบอนุญาต"
|
||||
unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?"
|
||||
myClips: "คลิปของฉัน"
|
||||
|
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "ความละเอียดเริ่ม
|
|||
defaultImageCompressionLevel_description: "หากตั้งค่าต่ำ จะรักษาคุณภาพภาพได้ดีขึ้นแต่ขนาดไฟล์จะเพิ่มขึ้น<br>หากตั้งค่าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพภาพจะลดลง"
|
||||
inMinutes: "นาที"
|
||||
inDays: "วัน"
|
||||
safeModeEnabled: "โหมดปลอดภัยถูกเปิดใช้งาน"
|
||||
pluginsAreDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน ปลั๊กอินทั้งหมดจึงถูกปิดใช้งาน"
|
||||
customCssIsDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน CSS แบบกำหนดเองจึงไม่ได้ถูกนำมาใช้"
|
||||
themeIsDefaultBecauseSafeMode: "ในระหว่างที่โหมดปลอดภัยถูกเปิดใช้งาน จะใช้ธีมเริ่มต้น เมื่อปิดโหมดปลอดภัยจะกลับคืนดังเดิม"
|
||||
_order:
|
||||
newest: "เรียงจากใหม่ไปเก่า"
|
||||
oldest: "เรียงจากเก่าไปใหม่"
|
||||
|
@ -1995,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "ยิ่งตัวเลขน้อยก็ยิ่งจำกัดน้อย ยิ่งมากก็ยิ่งเข้มงวดมากขึ้น"
|
||||
canHideAds: "ซ่อนโฆษณา"
|
||||
canSearchNotes: "การใช้การค้นหาโน้ต"
|
||||
canSearchUsers: "ค้นหาผู้ใช้"
|
||||
canUseTranslator: "การใช้งานแปล"
|
||||
avatarDecorationLimit: "จำนวนของตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
|
||||
canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ"
|
||||
|
@ -3069,6 +3075,7 @@ _bootErrors:
|
|||
otherOption1: "ลบการตั้งค่าและแคชของไคลเอนต์"
|
||||
otherOption2: "เริ่มใช้งานไคลเอนต์แบบง่าย"
|
||||
otherOption3: "เปิดเครื่องมือซ่อมแซม"
|
||||
otherOption4: "เริ่มทำงาน Misskey ในโหมดปลอดภัย"
|
||||
_search:
|
||||
searchScopeAll: "ทั้งหมด"
|
||||
searchScopeLocal: "ท้องถิ่น"
|
||||
|
@ -3159,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "รูปแบบ"
|
||||
image: "รูปภาพ"
|
||||
advanced: "ขั้นสูง"
|
||||
angle: "แองเกิล"
|
||||
stripe: "ริ้ว"
|
||||
stripeWidth: "ความกว้างเส้น"
|
||||
stripeFrequency: "จำนวนเส้น"
|
||||
angle: "แองเกิล"
|
||||
polkadot: "ลายจุด"
|
||||
checker: "ช่องตาราง"
|
||||
polkadotMainDotOpacity: "ความทึบของจุดหลัก"
|
||||
|
@ -3174,6 +3181,7 @@ _imageEffector:
|
|||
title: "เอฟเฟกต์"
|
||||
addEffect: "เพิ่มเอฟเฟกต์"
|
||||
discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?"
|
||||
nothingToConfigure: "ไม่มีอะไรให้ตั้งค่า"
|
||||
_fxs:
|
||||
chromaticAberration: "ความคลาดสี"
|
||||
glitch: "กลิตช์"
|
||||
|
@ -3191,6 +3199,38 @@ _imageEffector:
|
|||
checker: "ช่องตาราง"
|
||||
blockNoise: "บล็อกที่มีการรบกวน"
|
||||
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:
|
||||
select: "เลือกฉบับร่าง"
|
||||
|
|
1027
locales/tr-TR.yml
1027
locales/tr-TR.yml
File diff suppressed because it is too large
Load diff
|
@ -1648,3 +1648,10 @@ _watermarkEditor:
|
|||
type: "Тип"
|
||||
image: "Зображення"
|
||||
advanced: "Розширені"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
scale: "Розмір"
|
||||
size: "Розмір"
|
||||
color: "Колір"
|
||||
opacity: "Непрозорість"
|
||||
lightness: "Яскравість"
|
||||
|
|
|
@ -1102,3 +1102,7 @@ _watermarkEditor:
|
|||
type: "turi"
|
||||
image: "Rasmlar"
|
||||
advanced: "Murakkab"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
color: "Rang"
|
||||
lightness: "Yoritish"
|
||||
|
|
|
@ -2091,3 +2091,11 @@ _watermarkEditor:
|
|||
image: "Hình ảnh"
|
||||
advanced: "Nâng cao"
|
||||
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: "隐藏标签"
|
||||
hiddenTagsDescription: "设定的标签将不会在时间线上显示。可使用换行来设置多个标签。"
|
||||
notesSearchNotAvailable: "帖子检索不可用"
|
||||
usersSearchNotAvailable: "用户检索不可用"
|
||||
license: "许可信息"
|
||||
unfavoriteConfirm: "确定要取消收藏吗?"
|
||||
myClips: "我的便签"
|
||||
|
@ -1999,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
|
||||
canHideAds: "可以隐藏广告"
|
||||
canSearchNotes: "是否可以搜索帖子"
|
||||
canSearchUsers: "使用用户检索"
|
||||
canUseTranslator: "使用翻译功能"
|
||||
avatarDecorationLimit: "可添加头像挂件的最大个数"
|
||||
canImportAntennas: "允许导入天线"
|
||||
|
@ -3164,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "类型"
|
||||
image: "图片"
|
||||
advanced: "高级"
|
||||
angle: "角度"
|
||||
stripe: "条纹"
|
||||
stripeWidth: "线条宽度"
|
||||
stripeFrequency: "线条数量"
|
||||
angle: "角度"
|
||||
polkadot: "波点"
|
||||
checker: "检查"
|
||||
polkadotMainDotOpacity: "主波点的不透明度"
|
||||
|
@ -3179,6 +3181,7 @@ _imageEffector:
|
|||
title: "效果"
|
||||
addEffect: "添加效果"
|
||||
discardChangesConfirm: "丢弃当前设置并退出?"
|
||||
nothingToConfigure: "还没有设置"
|
||||
_fxs:
|
||||
chromaticAberration: "色差"
|
||||
glitch: "故障"
|
||||
|
@ -3196,6 +3199,38 @@ _imageEffector:
|
|||
checker: "检查"
|
||||
blockNoise: "块状噪点"
|
||||
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:
|
||||
select: "选择草稿"
|
||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "空格代表「以及」(AND),斜線包圍
|
|||
hiddenTags: "隱藏標籤"
|
||||
hiddenTagsDescription: "設定的標籤不會在趨勢中顯示,換行可以設定多個標籤。"
|
||||
notesSearchNotAvailable: "無法使用搜尋貼文功能。"
|
||||
usersSearchNotAvailable: "無法使用使用者搜尋功能。"
|
||||
license: "授權"
|
||||
unfavoriteConfirm: "要取消收錄我的最愛嗎?"
|
||||
myClips: "我的摘錄"
|
||||
|
@ -1999,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
|
||||
canHideAds: "不顯示廣告"
|
||||
canSearchNotes: "可否搜尋貼文"
|
||||
canSearchUsers: "可使用使用者搜尋功能"
|
||||
canUseTranslator: "使用翻譯功能"
|
||||
avatarDecorationLimit: "頭像可掛上的最大裝飾數量"
|
||||
canImportAntennas: "允許匯入天線"
|
||||
|
@ -3164,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "類型"
|
||||
image: "圖片"
|
||||
advanced: "進階"
|
||||
angle: "角度"
|
||||
stripe: "條紋"
|
||||
stripeWidth: "線條寬度"
|
||||
stripeFrequency: "線條數量"
|
||||
angle: "角度"
|
||||
polkadot: "波卡圓點"
|
||||
checker: "棋盤格"
|
||||
polkadotMainDotOpacity: "主圓點的不透明度"
|
||||
|
@ -3179,6 +3181,7 @@ _imageEffector:
|
|||
title: "特效"
|
||||
addEffect: "新增特效"
|
||||
discardChangesConfirm: "捨棄更改並退出嗎?"
|
||||
nothingToConfigure: "無可設定的項目"
|
||||
_fxs:
|
||||
chromaticAberration: "色差"
|
||||
glitch: "異常雜訊效果"
|
||||
|
@ -3196,6 +3199,38 @@ _imageEffector:
|
|||
checker: "棋盤格"
|
||||
blockNoise: "阻擋雜訊"
|
||||
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:
|
||||
select: "選擇草槁"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.8.0-alpha.7",
|
||||
"version": "2025.8.0-alpha.12",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
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": {
|
||||
"@swc/core-android-arm64": "1.3.11",
|
||||
"@swc/core-darwin-arm64": "1.12.0",
|
||||
"@swc/core-darwin-x64": "1.12.0",
|
||||
"@swc/core-darwin-arm64": "1.13.3",
|
||||
"@swc/core-darwin-x64": "1.13.3",
|
||||
"@swc/core-freebsd-x64": "1.3.11",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.12.0",
|
||||
"@swc/core-linux-arm64-gnu": "1.12.0",
|
||||
"@swc/core-linux-arm64-musl": "1.12.0",
|
||||
"@swc/core-linux-x64-gnu": "1.12.0",
|
||||
"@swc/core-linux-x64-musl": "1.12.0",
|
||||
"@swc/core-win32-arm64-msvc": "1.12.0",
|
||||
"@swc/core-win32-ia32-msvc": "1.12.0",
|
||||
"@swc/core-win32-x64-msvc": "1.12.0",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.13.3",
|
||||
"@swc/core-linux-arm64-gnu": "1.13.3",
|
||||
"@swc/core-linux-arm64-musl": "1.13.3",
|
||||
"@swc/core-linux-x64-gnu": "1.13.3",
|
||||
"@swc/core-linux-x64-musl": "1.13.3",
|
||||
"@swc/core-win32-arm64-msvc": "1.13.3",
|
||||
"@swc/core-win32-ia32-msvc": "1.13.3",
|
||||
"@swc/core-win32-x64-msvc": "1.13.3",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"@tensorflow/tfjs-node": "4.22.0",
|
||||
"bufferutil": "4.0.9",
|
||||
|
@ -68,8 +68,8 @@
|
|||
"utf-8-validate": "6.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.826.0",
|
||||
"@aws-sdk/lib-storage": "3.826.0",
|
||||
"@aws-sdk/client-s3": "3.864.0",
|
||||
"@aws-sdk/lib-storage": "3.864.0",
|
||||
"@discordapp/twemoji": "16.0.1",
|
||||
"@fastify/accepts": "5.0.2",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
|
@ -80,19 +80,19 @@
|
|||
"@fastify/static": "8.2.0",
|
||||
"@fastify/view": "10.0.2",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.2.1",
|
||||
"@napi-rs/canvas": "0.1.71",
|
||||
"@nestjs/common": "11.1.3",
|
||||
"@nestjs/core": "11.1.3",
|
||||
"@nestjs/testing": "11.1.3",
|
||||
"@misskey-dev/summaly": "5.2.3",
|
||||
"@napi-rs/canvas": "0.1.77",
|
||||
"@nestjs/common": "11.1.6",
|
||||
"@nestjs/core": "11.1.6",
|
||||
"@nestjs/testing": "11.1.6",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "8.55.0",
|
||||
"@sentry/profiling-node": "8.55.0",
|
||||
"@simplewebauthn/server": "12.0.0",
|
||||
"@sinonjs/fake-timers": "11.3.1",
|
||||
"@smithy/node-http-handler": "2.5.0",
|
||||
"@swc/cli": "0.7.7",
|
||||
"@swc/core": "1.12.0",
|
||||
"@swc/cli": "0.7.8",
|
||||
"@swc/core": "1.13.3",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
"@types/redis-info": "3.0.3",
|
||||
"accepts": "1.3.8",
|
||||
|
@ -102,10 +102,10 @@
|
|||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.3",
|
||||
"bullmq": "5.53.2",
|
||||
"bullmq": "5.56.9",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.2",
|
||||
"chalk": "5.4.1",
|
||||
"chalk": "5.5.0",
|
||||
"chalk-template": "1.1.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cli-highlight": "2.1.11",
|
||||
|
@ -113,18 +113,18 @@
|
|||
"content-disposition": "0.5.4",
|
||||
"date-fns": "2.30.0",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"fastify": "5.3.3",
|
||||
"fastify": "5.4.0",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "19.6.0",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"form-data": "4.0.3",
|
||||
"form-data": "4.0.4",
|
||||
"got": "14.4.7",
|
||||
"happy-dom": "16.8.1",
|
||||
"hpagent": "1.2.0",
|
||||
"htmlescape": "1.1.1",
|
||||
"http-link-header": "1.1.3",
|
||||
"ioredis": "5.6.1",
|
||||
"ioredis": "5.7.0",
|
||||
"ip-cidr": "4.0.2",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"is-svg": "5.1.0",
|
||||
|
@ -136,7 +136,7 @@
|
|||
"juice": "11.0.1",
|
||||
"meilisearch": "0.51.0",
|
||||
"mfm-js": "0.25.0",
|
||||
"microformats-parser": "2.0.3",
|
||||
"microformats-parser": "2.0.4",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
|
@ -152,7 +152,7 @@
|
|||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.4.0",
|
||||
"parse5": "7.3.0",
|
||||
"pg": "8.16.0",
|
||||
"pg": "8.16.3",
|
||||
"pkce-challenge": "4.1.0",
|
||||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
|
@ -174,25 +174,25 @@
|
|||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.27.1",
|
||||
"systeminformation": "5.27.7",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.3",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typeorm": "0.3.24",
|
||||
"typescript": "5.8.3",
|
||||
"typeorm": "0.3.25",
|
||||
"typescript": "5.9.2",
|
||||
"ulid": "2.4.0",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "8.18.2",
|
||||
"ws": "8.18.3",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@nestjs/platform-express": "10.4.19",
|
||||
"@sentry/vue": "9.28.0",
|
||||
"@nestjs/platform-express": "10.4.20",
|
||||
"@sentry/vue": "9.45.0",
|
||||
"@simplewebauthn/types": "12.0.0",
|
||||
"@swc/jest": "0.2.38",
|
||||
"@swc/jest": "0.2.39",
|
||||
"@types/accepts": "1.3.7",
|
||||
"@types/archiver": "6.0.3",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
|
@ -209,12 +209,12 @@
|
|||
"@types/jsrsasign": "10.5.15",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/ms": "0.7.34",
|
||||
"@types/node": "22.15.31",
|
||||
"@types/node": "22.17.1",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/oauth": "0.9.6",
|
||||
"@types/oauth2orize": "1.11.5",
|
||||
"@types/oauth2orize-pkce": "0.1.2",
|
||||
"@types/pg": "8.15.4",
|
||||
"@types/pg": "8.15.5",
|
||||
"@types/pug": "2.0.10",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/random-seed": "0.3.5",
|
||||
|
@ -230,11 +230,11 @@
|
|||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.34.0",
|
||||
"@typescript-eslint/parser": "8.34.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.39.0",
|
||||
"@typescript-eslint/parser": "8.39.0",
|
||||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"execa": "8.0.1",
|
||||
"fkill": "9.0.0",
|
||||
"jest": "29.7.0",
|
||||
|
@ -242,6 +242,6 @@
|
|||
"nodemon": "3.1.10",
|
||||
"pid-port": "1.0.2",
|
||||
"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 { RegistryApiService } from './RegistryApiService.js';
|
||||
import { ReversiService } from './ReversiService.js';
|
||||
import { PageService } from './PageService.js';
|
||||
|
||||
import { ChartLoggerService } from './chart/ChartLoggerService.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 $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
||||
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
|
||||
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
|
||||
|
||||
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
||||
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
||||
|
@ -379,6 +381,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
PageService,
|
||||
|
||||
ChartLoggerService,
|
||||
FederationChart,
|
||||
|
@ -527,6 +530,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$PageService,
|
||||
|
||||
$ChartLoggerService,
|
||||
$FederationChart,
|
||||
|
@ -676,6 +680,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
PageService,
|
||||
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
|
@ -822,6 +827,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$PageService,
|
||||
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import * as net from 'node:net';
|
||||
import * as stream from 'node:stream';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import CacheableLookup from 'cacheable-lookup';
|
||||
import fetch from 'node-fetch';
|
||||
|
@ -26,12 +27,6 @@ export type HttpRequestSendOptions = {
|
|||
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 {
|
||||
constructor(
|
||||
private config: Config,
|
||||
|
@ -41,11 +36,11 @@ class HttpRequestServiceAgent extends http.Agent {
|
|||
}
|
||||
|
||||
@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)
|
||||
.on('connect', () => {
|
||||
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(address)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
|
@ -80,11 +75,11 @@ class HttpsRequestServiceAgent extends https.Agent {
|
|||
}
|
||||
|
||||
@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)
|
||||
.on('connect', () => {
|
||||
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(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) {
|
||||
this.systemQueue.upsertJobScheduler(def.name, {
|
||||
pattern: def.pattern,
|
||||
immediately: false,
|
||||
}, {
|
||||
name: def.name,
|
||||
opts: {
|
||||
|
|
|
@ -43,6 +43,7 @@ export type RolePolicies = {
|
|||
canManageCustomEmojis: boolean;
|
||||
canManageAvatarDecorations: boolean;
|
||||
canSearchNotes: boolean;
|
||||
canSearchUsers: boolean;
|
||||
canUseTranslator: boolean;
|
||||
canHideAds: boolean;
|
||||
driveCapacityMb: number;
|
||||
|
@ -82,6 +83,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
canManageCustomEmojis: false,
|
||||
canManageAvatarDecorations: false,
|
||||
canSearchNotes: false,
|
||||
canSearchUsers: true,
|
||||
canUseTranslator: true,
|
||||
canHideAds: false,
|
||||
driveCapacityMb: 100,
|
||||
|
@ -402,6 +404,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
|
||||
canManageAvatarDecorations: calc('canManageAvatarDecorations', 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)),
|
||||
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
|
||||
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
|
||||
|
|
|
@ -85,6 +85,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
|||
renoteCount: 10,
|
||||
repliesCount: 5,
|
||||
clippedCount: 0,
|
||||
pageCount: 0,
|
||||
reactions: {},
|
||||
visibility: 'public',
|
||||
uri: null,
|
||||
|
|
|
@ -114,6 +114,13 @@ export class MiNote {
|
|||
})
|
||||
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', {
|
||||
default: {},
|
||||
})
|
||||
|
|
|
@ -212,6 +212,10 @@ export const packedRolePoliciesSchema = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
canSearchUsers: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
canUseTranslator: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, IsNull, LessThan, QueryFailedError, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiMeta, MiNote, NotesRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
@ -24,18 +25,31 @@ export class CleanRemoteNotesProcessorService {
|
|||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private idService: IdService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
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
|
||||
public async process(job: Bull.Job<Record<string, unknown>>): Promise<{
|
||||
deletedCount: number;
|
||||
oldest: number | null;
|
||||
newest: number | null;
|
||||
skipped?: boolean;
|
||||
skipped: boolean;
|
||||
transientErrors: number;
|
||||
}> {
|
||||
if (!this.meta.enableRemoteNotesCleaning) {
|
||||
this.logger.info('Remote notes cleaning is disabled, skipping...');
|
||||
|
@ -44,6 +58,7 @@ export class CleanRemoteNotesProcessorService {
|
|||
oldest: null,
|
||||
newest: null,
|
||||
skipped: true,
|
||||
transientErrors: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -52,12 +67,10 @@ export class CleanRemoteNotesProcessorService {
|
|||
const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
|
||||
const startAt = Date.now();
|
||||
|
||||
const MAX_NOTE_COUNT_PER_QUERY = 50;
|
||||
|
||||
//#retion queries
|
||||
// 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
|
||||
//#region queries
|
||||
// The date limit for the newest note to be considered for deletion.
|
||||
// All notes newer than this limit will always be retained.
|
||||
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
|
||||
|
||||
// The condition for removing the notes.
|
||||
// The note must be:
|
||||
|
@ -66,56 +79,94 @@ export class CleanRemoteNotesProcessorService {
|
|||
// - not have clipped
|
||||
// - not have pinned on the user profile
|
||||
// - not has been favorite by any user
|
||||
const removeCondition = 'note.id < :newestLimit'
|
||||
+ ' AND note."clippedCount" = 0'
|
||||
+ ' AND note."userHost" IS NOT NULL'
|
||||
// using both userId and noteId instead of just noteId to use index on user_note_pining table.
|
||||
// This is safe because notes are only pinned by the user who created them.
|
||||
+ ' AND NOT EXISTS(SELECT 1 FROM "user_note_pining" WHERE "noteId" = note."id" AND "userId" = note."userId")'
|
||||
// We cannot use userId trick because users can favorite notes from other users.
|
||||
+ ' AND NOT EXISTS(SELECT 1 FROM "note_favorite" WHERE "noteId" = note."id")'
|
||||
;
|
||||
const removalCriteria = [
|
||||
'note."id" < :newestLimit',
|
||||
'note."clippedCount" = 0',
|
||||
'note."pageCount" = 0',
|
||||
'note."userHost" IS NOT NULL',
|
||||
'NOT EXISTS (SELECT 1 FROM user_note_pining WHERE "noteId" = note."id")',
|
||||
'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 initiatorQuery = this.notesRepository.createQueryBuilder('note')
|
||||
const minId = (await this.notesRepository.createQueryBuilder('note')
|
||||
.select('MIN(note.id)', 'minId')
|
||||
.where({
|
||||
id: LessThan(newestLimit),
|
||||
userHost: Not(IsNull()),
|
||||
replyId: IsNull(),
|
||||
renoteId: IsNull(),
|
||||
})
|
||||
.getRawOne<{ minId?: MiNote['id'] }>())?.minId;
|
||||
|
||||
if (!minId) {
|
||||
this.logger.info('No notes can possibly be deleted, skipping...');
|
||||
return {
|
||||
deletedCount: 0,
|
||||
oldest: null,
|
||||
newest: null,
|
||||
skipped: false,
|
||||
transientErrors: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// start with a conservative limit and adjust it based on the query duration
|
||||
const minimumLimit = 10;
|
||||
let currentLimit = 100;
|
||||
let cursorLeft = '0';
|
||||
|
||||
const candidateNotesCteName = 'candidate_notes';
|
||||
|
||||
// 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')
|
||||
.where(removeCondition)
|
||||
.andWhere('note.id > :cursor')
|
||||
.orderBy('note.id', 'ASC')
|
||||
.limit(MAX_NOTE_COUNT_PER_QUERY);
|
||||
.addSelect('note."replyId"', 'replyId')
|
||||
.addSelect('note."renoteId"', 'renoteId')
|
||||
.addSelect('parent."rootId"', 'rootId')
|
||||
.addSelect(removalCriteria, 'isRemovable')
|
||||
.addSelect('FALSE', 'isBase')
|
||||
.innerJoin(candidateNotesCteName, 'parent', 'parent."id" = note."replyId" OR parent."id" = note."renoteId"')
|
||||
.where('parent."isRemovable" = TRUE');
|
||||
|
||||
// The union query queries the related notes and replies related to the initiator query
|
||||
const unionQuery = `
|
||||
SELECT "note"."id", "note"."replyId", "note"."renoteId", rn."initiatorId"
|
||||
FROM "note" "note"
|
||||
INNER JOIN "related_notes" "rn"
|
||||
ON "note"."replyId" = rn.id
|
||||
OR "note"."renoteId" = rn.id
|
||||
OR "note"."id" = rn."replyId"
|
||||
OR "note"."id" = rn."renoteId"
|
||||
`;
|
||||
|
||||
const selectRelatedNotesFromInitiatorIdsQuery = `
|
||||
SELECT "note"."id" AS "id", "note"."replyId" AS "replyId", "note"."renoteId" AS "renoteId", "note"."id" AS "initiatorId"
|
||||
FROM "note" "note" WHERE "note"."id" IN (:...initiatorIds)
|
||||
`;
|
||||
|
||||
const recursiveQuery = `(${selectRelatedNotesFromInitiatorIdsQuery}) UNION (${unionQuery})`;
|
||||
|
||||
const removableInitiatorNotesQuery = this.notesRepository.createQueryBuilder('note')
|
||||
.select('rn."initiatorId"')
|
||||
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
|
||||
.groupBy('rn."initiatorId"')
|
||||
.having(`bool_and(${removeCondition})`);
|
||||
|
||||
const notesQuery = this.notesRepository.createQueryBuilder('note')
|
||||
.addCommonTableExpression(recursiveQuery, 'related_notes', { recursive: true })
|
||||
.select('note.id', 'id')
|
||||
.addSelect('rn."initiatorId"')
|
||||
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
|
||||
.where(`rn."initiatorId" IN (${removableInitiatorNotesQuery.getQuery()})`)
|
||||
.distinctOn(['note.id']);
|
||||
//#endregion
|
||||
// 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 stats = {
|
||||
deletedCount: 0,
|
||||
|
@ -123,51 +174,71 @@ export class CleanRemoteNotesProcessorService {
|
|||
newest: null as number | null,
|
||||
};
|
||||
|
||||
// The date limit for the newest note to be considered for deletion.
|
||||
// All notes newer than this limit will always be retained.
|
||||
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) {
|
||||
let lowThroughputWarned = false;
|
||||
let transientErrors = 0;
|
||||
for (;;) {
|
||||
//#region check time
|
||||
const batchBeginAt = Date.now();
|
||||
|
||||
const elapsed = batchBeginAt - startAt;
|
||||
|
||||
const progress = this.computeProgress(minId, newestLimit, cursorLeft > minId ? cursorLeft : minId);
|
||||
|
||||
if (elapsed >= maxDuration) {
|
||||
this.logger.info(`Reached maximum duration of ${maxDuration}ms, stopping...`);
|
||||
job.log('Reached maximum duration, stopping cleaning.');
|
||||
job.log(`Reached maximum duration of ${maxDuration}ms, stopping... (last cursor: ${cursorLeft}, final progress ${progress}%)`);
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
job.updateProgress((elapsed / maxDuration) * 100);
|
||||
const wallClockUsage = elapsed / maxDuration;
|
||||
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
|
||||
|
||||
// First, we fetch the initiator notes that are older than the newestLimit.
|
||||
const initiatorNotes: { id: MiNote['id'] }[] = await initiatorQuery.setParameters({ cursor, newestLimit }).getRawMany();
|
||||
const queryBegin = performance.now();
|
||||
let noteIds = null;
|
||||
|
||||
// update the cursor to the newest initiatorId found in the fetched notes.
|
||||
const newCursor = initiatorNotes.reduce((max, note) => note.id > max ? note.id : max, cursor);
|
||||
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 (initiatorNotes.length === 0 || cursor === newCursor || newCursor >= newestLimit) {
|
||||
// If no notes were found or the cursor did not change, we can stop.
|
||||
job.log('No more notes to clean. (no initiator notes found or cursor did not change.)');
|
||||
if (noteIds.length === 0) {
|
||||
job.log('No more notes to clean.');
|
||||
break;
|
||||
}
|
||||
|
||||
const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.setParameters({
|
||||
initiatorIds: initiatorNotes.map(note => note.id),
|
||||
newestLimit,
|
||||
}).getRawMany();
|
||||
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);
|
||||
|
||||
cursor = newCursor;
|
||||
const deletableNoteIds = noteIds.filter(result => result.isRemovable).map(result => result.id);
|
||||
if (deletableNoteIds.length > 0) {
|
||||
try {
|
||||
await this.notesRepository.delete(deletableNoteIds);
|
||||
|
||||
if (notes.length > 0) {
|
||||
await this.notesRepository.delete(notes.map(note => note.id));
|
||||
|
||||
for (const { id } of notes) {
|
||||
for (const id of deletableNoteIds) {
|
||||
const t = this.idService.parse(id).date.getTime();
|
||||
if (stats.oldest === null || t < stats.oldest) {
|
||||
stats.oldest = t;
|
||||
|
@ -177,20 +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} from ${initiatorNotes.length} initiators; ${Date.now() - batchBeginAt}ms`);
|
||||
cursorLeft = noteIds.filter(result => result.isBase).reduce((max, { id }) => id > max ? id : max, cursorLeft);
|
||||
|
||||
if (initiatorNotes.length < MAX_NOTE_COUNT_PER_QUERY) {
|
||||
// If we fetched less than the maximum, it means there are no more notes to process.
|
||||
job.log(`No more notes to clean. (fewer than MAX_NOTE_COUNT_PER_QUERY =${MAX_NOTE_COUNT_PER_QUERY}.)`);
|
||||
break;
|
||||
job.log(`Deleted ${noteIds.length} notes; ${Date.now() - batchBeginAt}ms`);
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
await setTimeout(Math.min(1000 * 5, queryDuration)); // Wait a moment to avoid overwhelming the db
|
||||
}
|
||||
};
|
||||
|
||||
await setTimeout(1000 * 5); // Wait a moment to avoid overwhelming the db
|
||||
if (transientErrors > 0) {
|
||||
const msg = `${transientErrors} transient errors occurred while cleaning remote notes. You may need a second pass to complete the cleaning.`;
|
||||
this.logger.warn(msg);
|
||||
job.log(msg);
|
||||
}
|
||||
|
||||
this.logger.succ('cleaning of remote notes completed.');
|
||||
|
||||
return {
|
||||
|
@ -198,6 +282,7 @@ export class CleanRemoteNotesProcessorService {
|
|||
oldest: stats.oldest,
|
||||
newest: stats.newest,
|
||||
skipped: false,
|
||||
transientErrors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MoreThan } from 'typeorm';
|
||||
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 { DriveService } from '@/core/DriveService.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 { bindThis } from '@/decorators.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { PageService } from '@/core/PageService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserDeleteJobData } from '../types.js';
|
||||
|
@ -35,7 +36,11 @@ export class DeleteAccountProcessorService {
|
|||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.pagesRepository)
|
||||
private pagesRepository: PagesRepository,
|
||||
|
||||
private driveService: DriveService,
|
||||
private pageService: PageService,
|
||||
private emailService: EmailService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
private searchService: SearchService,
|
||||
|
@ -112,6 +117,28 @@ export class DeleteAccountProcessorService {
|
|||
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
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
if (profile.email && profile.emailVerified) {
|
||||
|
|
|
@ -5,12 +5,13 @@
|
|||
|
||||
import ms from 'ms';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { MiPage, pageNameSchema } from '@/models/Page.js';
|
||||
import type { DriveFilesRepository, MiDriveFile, PagesRepository } from '@/models/_.js';
|
||||
import { pageNameSchema } from '@/models/Page.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { PageEntityService } from '@/core/entities/PageEntityService.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';
|
||||
|
||||
export const meta = {
|
||||
|
@ -77,11 +78,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private pageService: PageService,
|
||||
private pageEntityService: PageEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let eyeCatchingImage = null;
|
||||
let eyeCatchingImage: MiDriveFile | null = null;
|
||||
if (ps.eyeCatchingImageId != null) {
|
||||
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||
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({
|
||||
id: this.idService.gen(),
|
||||
updatedAt: new Date(),
|
||||
title: ps.title,
|
||||
name: ps.name,
|
||||
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,
|
||||
}));
|
||||
try {
|
||||
const page = await this.pageService.create(me, {
|
||||
...ps,
|
||||
eyeCatchingImage,
|
||||
summary: ps.summary ?? null,
|
||||
});
|
||||
|
||||
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 type { PagesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiDriveFile, PagesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { PageService } from '@/core/PageService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['pages'],
|
||||
|
@ -44,36 +46,17 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.pagesRepository)
|
||||
private pagesRepository: PagesRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private moderationLogService: ModerationLogService,
|
||||
private roleService: RoleService,
|
||||
private pageService: PageService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
|
||||
|
||||
if (page == null) {
|
||||
throw new ApiError(meta.errors.noSuchPage);
|
||||
try {
|
||||
await this.pageService.delete(me, ps.pageId);
|
||||
} catch (err) {
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -4,13 +4,14 @@
|
|||
*/
|
||||
|
||||
import ms from 'ms';
|
||||
import { Not } from 'typeorm';
|
||||
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 { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { pageNameSchema } from '@/models/Page.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { PageService } from '@/core/PageService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['pages'],
|
||||
|
@ -75,24 +76,17 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.pagesRepository)
|
||||
private pagesRepository: PagesRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private pageService: PageService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
|
||||
if (page == null) {
|
||||
throw new ApiError(meta.errors.noSuchPage);
|
||||
}
|
||||
if (page.userId !== me.id) {
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
if (ps.eyeCatchingImageId != null) {
|
||||
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||
id: ps.eyeCatchingImageId,
|
||||
try {
|
||||
let eyeCatchingImage: MiDriveFile | null | undefined | string = ps.eyeCatchingImageId;
|
||||
if (eyeCatchingImage != null) {
|
||||
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||
id: eyeCatchingImage,
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
|
@ -101,31 +95,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
}
|
||||
|
||||
if (ps.name != null) {
|
||||
await this.pagesRepository.findBy({
|
||||
id: Not(ps.pageId),
|
||||
userId: me.id,
|
||||
name: ps.name,
|
||||
}).then(result => {
|
||||
if (result.length > 0) {
|
||||
throw new ApiError(meta.errors.nameAlreadyExists);
|
||||
await this.pageService.update(me, ps.pageId, {
|
||||
...ps,
|
||||
eyeCatchingImage,
|
||||
});
|
||||
} catch (err) {
|
||||
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);
|
||||
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'],
|
||||
|
||||
requireCredential: false,
|
||||
requiredRolePolicy: 'canSearchUsers',
|
||||
|
||||
description: 'Search for users.',
|
||||
|
||||
|
|
|
@ -32,7 +32,6 @@ export default class Connection {
|
|||
public subscriber: StreamEventEmitter;
|
||||
private channels: Channel[] = [];
|
||||
private subscribingNotes: Partial<Record<string, number>> = {};
|
||||
private cachedNotes: Packed<'Note'>[] = [];
|
||||
public userProfile: MiUserProfile | null = null;
|
||||
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
||||
public followingChannels: Set<string> = new Set();
|
||||
|
@ -132,26 +131,6 @@ export default class Connection {
|
|||
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
|
||||
private onReadNotification(payload: JsonValue | undefined) {
|
||||
this.notificationService.readAllNotification(this.user!.id);
|
||||
|
|
|
@ -43,8 +43,6 @@ class AntennaChannel extends Channel {
|
|||
|
||||
if (this.isNoteMutedOrBlocked(note)) return;
|
||||
|
||||
this.connection.cacheNote(note);
|
||||
|
||||
this.send('note', note);
|
||||
} else {
|
||||
this.send(data.type, data.body);
|
||||
|
|
|
@ -49,8 +49,6 @@ class ChannelChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
this.connection.cacheNote(note);
|
||||
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
|
|
|
@ -65,8 +65,6 @@ class GlobalTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
this.connection.cacheNote(note);
|
||||
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
|
|
|
@ -53,8 +53,6 @@ class HashtagChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
this.connection.cacheNote(note);
|
||||
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
|
|
|
@ -86,8 +86,6 @@ class HomeTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
this.connection.cacheNote(note);
|
||||
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
|
|
|
@ -100,8 +100,6 @@ class HybridTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
this.connection.cacheNote(note);
|
||||
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
|
|
|
@ -75,8 +75,6 @@ class LocalTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
this.connection.cacheNote(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, {
|
||||
detail: true,
|
||||
});
|
||||
this.connection.cacheNote(note);
|
||||
data.body.note = note;
|
||||
}
|
||||
break;
|
||||
|
@ -52,7 +51,6 @@ class MainChannel extends Channel {
|
|||
const note = await this.noteEntityService.pack(data.body.id, this.user, {
|
||||
detail: true,
|
||||
});
|
||||
this.connection.cacheNote(note);
|
||||
data.body = note;
|
||||
}
|
||||
break;
|
||||
|
|
|
@ -118,8 +118,6 @@ class UserListChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
this.connection.cacheNote(note);
|
||||
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
|
|
|
@ -190,7 +190,8 @@ export async function uploadFile(
|
|||
path = '../../test/resources/192.jpg',
|
||||
): Promise<Misskey.entities.DriveFile> {
|
||||
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();
|
||||
body.append('i', user.i);
|
||||
|
|
|
@ -40,6 +40,7 @@ describe('NoteCreateService', () => {
|
|||
renoteCount: 0,
|
||||
repliesCount: 0,
|
||||
clippedCount: 0,
|
||||
pageCount: 0,
|
||||
reactions: {},
|
||||
visibility: 'public',
|
||||
uri: null,
|
||||
|
|
|
@ -23,6 +23,7 @@ const base: MiNote = {
|
|||
renoteCount: 0,
|
||||
repliesCount: 0,
|
||||
clippedCount: 0,
|
||||
pageCount: 0,
|
||||
reactions: {},
|
||||
visibility: 'public',
|
||||
uri: null,
|
||||
|
|
|
@ -158,6 +158,7 @@ describe('CleanRemoteNotesProcessorService', () => {
|
|||
oldest: null,
|
||||
newest: null,
|
||||
skipped: true,
|
||||
transientErrors: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -172,6 +173,7 @@ describe('CleanRemoteNotesProcessorService', () => {
|
|||
oldest: null,
|
||||
newest: null,
|
||||
skipped: false,
|
||||
transientErrors: 0,
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
|
@ -199,6 +201,7 @@ describe('CleanRemoteNotesProcessorService', () => {
|
|||
oldest: expect.any(Number),
|
||||
newest: expect.any(Number),
|
||||
skipped: false,
|
||||
transientErrors: 0,
|
||||
});
|
||||
|
||||
// Check side-by-side from all notes
|
||||
|
@ -278,6 +281,24 @@ describe('CleanRemoteNotesProcessorService', () => {
|
|||
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();
|
||||
|
|
|
@ -317,7 +317,7 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
|
|||
|
||||
const formData = new FormData();
|
||||
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');
|
||||
if (name) {
|
||||
formData.append('name', name);
|
||||
|
@ -608,8 +608,8 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
|
|||
username: config.db.user,
|
||||
password: config.db.pass,
|
||||
database: config.db.db,
|
||||
synchronize: true && !justBorrow,
|
||||
dropSchema: true && !justBorrow,
|
||||
synchronize: !justBorrow,
|
||||
dropSchema: !justBorrow,
|
||||
entities: initEntities ?? entities,
|
||||
});
|
||||
|
||||
|
@ -661,7 +661,9 @@ export async function captureWebhook<T = SystemWebhookPayload>(postAction: () =>
|
|||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
const result = await new Promise<string>(async (resolve, reject) => {
|
||||
fastify.all('/', async (req, res) => {
|
||||
timeoutHandle && clearTimeout(timeoutHandle);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
const body = JSON.stringify(req.body);
|
||||
res.status(200).send('ok');
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<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 style="margin-left: auto;" class="_buttons">
|
||||
<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>
|
||||
import { } from 'vue';
|
||||
import MkButton from './MkButton.vue';
|
||||
import type { useForm } from '@/composables/use-form.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
form: {
|
||||
modifiedCount: {
|
||||
value: number;
|
||||
};
|
||||
discard: () => void;
|
||||
save: () => void;
|
||||
};
|
||||
form: ReturnType<typeof useForm>;
|
||||
canSaving?: boolean;
|
||||
}>(), {
|
||||
canSaving: true,
|
||||
|
|
|
@ -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"/>
|
||||
</template>
|
||||
</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>
|
||||
<MkLoading v-else :inline="true"/>
|
||||
</button>
|
||||
|
|
|
@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
|
||||
</div>
|
||||
</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>
|
||||
<MkLoading v-else/>
|
||||
</button>
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import type { Directive } from 'vue';
|
||||
|
||||
export default {
|
||||
|
@ -10,12 +11,14 @@ export default {
|
|||
const fn = binding.value;
|
||||
if (fn == null) return;
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
const check = throttle(1000, (entries) => {
|
||||
if (entries.some(entry => entry.isIntersecting)) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(check);
|
||||
|
||||
observer.observe(src);
|
||||
|
||||
src._observer_ = observer;
|
||||
|
|
|
@ -111,6 +111,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<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>
|
||||
</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>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
|
@ -403,6 +406,7 @@ const patrons = [
|
|||
'東雲 琥珀',
|
||||
'ほとラズ',
|
||||
'スズカケン',
|
||||
'蒼井よみこ',
|
||||
];
|
||||
|
||||
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 === 'testcaptcha'" #suffix>testCaptcha</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"/>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -346,6 +346,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</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'])">
|
||||
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
|
||||
<template #suffix>
|
||||
|
|
|
@ -122,6 +122,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkSwitch>
|
||||
</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'])">
|
||||
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
|
||||
<template #suffix>{{ policies.canUseTranslator ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
|
|
|
@ -15,16 +15,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
|
||||
<div v-else-if="tab === 'user'" class="_spacer" style="--MI_SPACER-w: 800px;">
|
||||
<div v-if="usersSearchAvailable">
|
||||
<XUser v-bind="props"/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<MkInfo warn>{{ i18n.ts.usersSearchNotAvailable }}</MkInfo>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithHeader>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref, toRef } from 'vue';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.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';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
|
|
@ -78,7 +78,10 @@ export class Autocomplete {
|
|||
const caretPos = Number(this.textarea.selectionStart);
|
||||
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 emojiIndex = text.lastIndexOf(':');
|
||||
const mfmTagIndex = text.lastIndexOf('$');
|
||||
|
@ -97,7 +100,7 @@ export class Autocomplete {
|
|||
|
||||
const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop();
|
||||
|
||||
const isMention = mentionIndex !== -1;
|
||||
const maybeMention = mentionIndex !== -1;
|
||||
const isHashtag = hashtagIndex !== -1;
|
||||
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
|
||||
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
|
||||
|
@ -107,15 +110,21 @@ export class Autocomplete {
|
|||
|
||||
let opened = false;
|
||||
|
||||
if (isMention && this.onlyType.includes('user')) {
|
||||
if (maybeMention && this.onlyType.includes('user')) {
|
||||
// ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう
|
||||
// この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、
|
||||
// ホスト名を含むリモートのユーザ名を全て拾えるようにする
|
||||
const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1);
|
||||
const username = mentionIndexAlt === -1
|
||||
? text.substring(mentionIndex + 1)
|
||||
: text.substring(mentionIndexAlt + 1);
|
||||
if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) {
|
||||
const mentionIndexAlt = mentionCandidate.lastIndexOf('@', mentionIndex - 1);
|
||||
|
||||
// @が連続している場合、1つ目を無視する
|
||||
const mentionIndexLeft = (mentionIndexAlt !== -1 && mentionIndexAlt !== mentionIndex - 1) ? mentionIndexAlt : mentionIndex;
|
||||
|
||||
// メンションを構成する条件を満たしているか確認する
|
||||
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);
|
||||
opened = true;
|
||||
} else if (username === '') {
|
||||
|
@ -123,6 +132,7 @@ export class Autocomplete {
|
|||
opened = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isHashtag && !opened && this.onlyType.includes('hashtag')) {
|
||||
const hashtag = text.substring(hashtagIndex + 1);
|
||||
|
|
|
@ -17,3 +17,11 @@ export const notesSearchAvailable = (
|
|||
export const canSearchNonLocalNotes = (
|
||||
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
|
||||
);
|
||||
|
|
|
@ -7,15 +7,15 @@
|
|||
"generate": "tsx src/generator.ts && eslint ./built/**/*.ts --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@readme/openapi-parser": "5.0.0",
|
||||
"@types/node": "22.16.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.37.0",
|
||||
"@typescript-eslint/parser": "8.37.0",
|
||||
"@readme/openapi-parser": "5.0.1",
|
||||
"@types/node": "22.17.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.39.0",
|
||||
"@typescript-eslint/parser": "8.39.0",
|
||||
"openapi-types": "12.1.3",
|
||||
"openapi-typescript": "7.8.0",
|
||||
"ts-case-convert": "2.1.0",
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"files": [
|
||||
"built"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"type": "module",
|
||||
"name": "misskey-js",
|
||||
"version": "2025.8.0-alpha.7",
|
||||
"version": "2025.8.0-alpha.12",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
|
@ -35,18 +35,18 @@
|
|||
"directory": "packages/misskey-js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "7.52.8",
|
||||
"@types/node": "22.16.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.37.0",
|
||||
"@typescript-eslint/parser": "8.37.0",
|
||||
"@microsoft/api-extractor": "7.52.10",
|
||||
"@types/node": "22.17.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.39.0",
|
||||
"@typescript-eslint/parser": "8.39.0",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"esbuild": "0.25.6",
|
||||
"esbuild": "0.25.8",
|
||||
"execa": "9.6.0",
|
||||
"glob": "11.0.3",
|
||||
"ncp": "2.0.0",
|
||||
"nodemon": "3.1.10",
|
||||
"tsd": "0.32.0",
|
||||
"typescript": "5.8.3",
|
||||
"tsd": "0.33.0",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "3.2.4",
|
||||
"vitest-websocket-mock": "0.5.0"
|
||||
},
|
||||
|
|
|
@ -5211,6 +5211,7 @@ export type components = {
|
|||
canManageCustomEmojis: boolean;
|
||||
canManageAvatarDecorations: boolean;
|
||||
canSearchNotes: boolean;
|
||||
canSearchUsers: boolean;
|
||||
canUseTranslator: boolean;
|
||||
canHideAds: boolean;
|
||||
driveCapacityMb: number;
|
||||
|
|
3018
pnpm-lock.yaml
generated
3018
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue