mirror of
https://github.com/misskey-dev/misskey
synced 2025-08-16 00:52:51 +02:00
Compare commits
75 commits
2025.8.0-a
...
develop
Author | SHA1 | Date | |
---|---|---|---|
|
9ea7340da6 | ||
|
60f7278aff | ||
|
bae92a944d | ||
|
7d30768769 | ||
|
e444942c4e | ||
|
90b9609341 | ||
|
c25a922928 | ||
|
d26169ea32 | ||
|
8839d8d679 | ||
|
ad6af74eef | ||
|
7bb43329bb | ||
|
4c41930554 | ||
|
295f42b986 | ||
|
299f9e3115 | ||
|
1d8e183883 | ||
|
f242892382 | ||
|
ecc033f101 | ||
|
684dbfd626 | ||
|
aa5c42997f | ||
|
e7b666f567 | ||
|
0f7c0ed053 | ||
|
1e92bb4a0a | ||
|
b5b7914073 | ||
|
7595bff43b | ||
|
72864fcbd0 | ||
|
1b0de39f92 | ||
|
d8a137cb6c | ||
|
ddac2fb7a1 | ||
|
b1b335d55a | ||
|
0586dd98cb | ||
|
504f886065 | ||
|
2931eb0aad | ||
|
103d5a4b44 | ||
|
785b85ee46 | ||
|
8bd84a0ec4 | ||
|
9539995458 | ||
|
e67ff36e57 | ||
|
96a165d729 | ||
|
215725a3ac | ||
|
3da04fcae4 | ||
|
85e3e49688 | ||
|
076a83466e | ||
|
aaf3f343ea | ||
|
4a5751416a | ||
|
adb3ad6b7f | ||
|
8598f3912e | ||
|
f86239ab2f | ||
|
ee9dc94063 | ||
|
998beeae59 | ||
|
9931fff35b | ||
|
b4a0fdfaa1 | ||
|
d979cd2c07 | ||
|
bb56b3b4f1 | ||
|
2f13f923a8 | ||
|
93fefc58c7 | ||
|
bd7339c397 | ||
|
941625fd08 | ||
|
b6765edffe | ||
|
9273b21516 | ||
|
aa10e537a5 | ||
|
c79fe6dc33 | ||
|
fbf8db618c | ||
|
6f3cc2cdf7 | ||
|
7c1f4c9037 | ||
|
2da20bf3e8 | ||
|
6d54370f01 | ||
|
905d3c87f1 | ||
|
fc244067e0 | ||
|
8449354887 | ||
|
57e0f1b4ef | ||
|
a1e170e065 | ||
|
73de40b81e | ||
|
2c836e3c24 | ||
|
c2c5898221 | ||
|
99adf12355 |
250 changed files with 12207 additions and 5700 deletions
1
.github/workflows/check-spdx-license-id.yml
vendored
1
.github/workflows/check-spdx-license-id.yml
vendored
|
@ -50,6 +50,7 @@ jobs:
|
|||
"packages/backend/test"
|
||||
"packages/frontend-shared/@types"
|
||||
"packages/frontend-shared/js"
|
||||
"packages/frontend-builder"
|
||||
"packages/frontend/.storybook"
|
||||
"packages/frontend/@types"
|
||||
"packages/frontend/lib"
|
||||
|
|
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}"
|
||||
|
|
3
.github/workflows/lint.yml
vendored
3
.github/workflows/lint.yml
vendored
|
@ -9,6 +9,7 @@ on:
|
|||
- packages/backend/**
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/frontend-embed/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/sw/**
|
||||
|
@ -22,6 +23,7 @@ on:
|
|||
- packages/backend/**
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/frontend-embed/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/sw/**
|
||||
|
@ -56,6 +58,7 @@ jobs:
|
|||
- backend
|
||||
- frontend
|
||||
- frontend-shared
|
||||
- frontend-builder
|
||||
- frontend-embed
|
||||
- icons-subsetter
|
||||
- sw
|
||||
|
|
25
CHANGELOG.md
25
CHANGELOG.md
|
@ -13,21 +13,46 @@
|
|||
- 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。
|
||||
- サーバーの初期設定が完了するまでは連合がオンにならないようになりました
|
||||
- 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました
|
||||
- 実際の動作に即した名称になり、馴染みのない人でも理解しやすくなりました
|
||||
- 他サービスにおける「ダイレクトメッセージ」に相当するMisskeyの機能は「チャット」ですが、「ダイレクト投稿」という名称の機能が存在するとそちらがダイレクトメッセージ機能であるような誤解を生んでいました
|
||||
- mfm.jsをアップデートしました
|
||||
- Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応
|
||||
- Enhance: acctに `.` が入っているユーザーのメンションに対応
|
||||
- Fix: Unicode絵文字に隣接する異体字セレクタ(`U+FE0F`)が絵文字として認識される問題を修正
|
||||
- Enhance: ユーザー検索をロールポリシーで制限できるように
|
||||
|
||||
### Client
|
||||
- Feat: AiScriptが1.0に更新されました
|
||||
- プラグインは1.0に対応したものが必要です
|
||||
- Playはそのまま動作しますが、新規に作られるプリセットは1.0になります
|
||||
- 以前のバージョンから無効化されていた note_view_interruptor が有効になりました
|
||||
- Feat: セーフモード
|
||||
- プラグイン・テーマ・カスタムCSSの使用でクライアントの起動に問題が発生した際に、これらを無効にして起動できます
|
||||
- 以下の方法でセーフモードを起動できます
|
||||
- `g` キーを連打する
|
||||
- URLに`?safemode=true`を付ける
|
||||
- PWAのショートカットで Safemode を選択して起動する
|
||||
- Feat: ページのタブバーを下部に表示できるように
|
||||
- Enhance: 「自動でもっと見る」オプションが有効になり、安定性が向上しました
|
||||
- Enhance: コントロールパネルを検索できるように
|
||||
- Enhance: トルコ語 (tr-TR) に対応
|
||||
- Enhance: 不必要な翻訳データを読み込まなくなり、パフォーマンスが向上しました
|
||||
- Enhance: 画像エフェクトのパラメータ名の多言語対応
|
||||
- Enhance: 依存ソフトウェアの更新
|
||||
- Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正
|
||||
- Fix: 一部の設定検索結果が存在しないパスになる問題を修正
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171)
|
||||
- Fix: テーマエディタが動作しない問題を修正
|
||||
- Fix: チャンネルのハイライトページにノートが表示されない問題を修正
|
||||
- Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正
|
||||
- Fix: 複数のメンションを1行に記述した場合に、サジェストが正しく表示されない問題を修正
|
||||
- Fix: メンションとしての条件を満たしていても、特定の条件(`-`が含まれる場合など)で正しくサジェストされない問題を一部修正
|
||||
|
||||
### Server
|
||||
- Enhance: ノートの削除処理の効率化
|
||||
- Enhance: 全体的なパフォーマンスの向上
|
||||
- Enhance: 依存ソフトウェアの更新
|
||||
- Fix: SystemWebhook設定でsecretを空に出来ない問題を修正
|
||||
|
||||
|
||||
## 2025.7.0
|
||||
|
|
|
@ -618,3 +618,23 @@ color: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
|
|||
color: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
|
||||
```
|
||||
|
||||
## 考え方
|
||||
### DRYに囚われるな
|
||||
必要なのは一般化ではなく抽象化と考えます。
|
||||
盲信せず、誤った・不必要な共通化は避け、それが自然だと感じる場合は重複させる勇気を持ちましょう。
|
||||
|
||||
### Misskeyを複雑にしない実装
|
||||
それがいくら複雑であっても、Misskey固有のコンテキストと関心が分離されている(もしくは事実上分離されていると見做すことができる)実装であれば、それはMisskeyのコードベースに対する複雑性に影響を与えないと考えます。
|
||||
|
||||
例えるなら、VueやAiScriptといったMisskeyが使用しているライブラリの内部実装がいくら複雑だったとしても、「それを使用しているからMisskeyの実装は複雑である」ということにはならないのと同じです。
|
||||
|
||||
Misskeyのドメイン知識から関心が分離されているということは、Misskeyの実装について考える時にそれらの内部実装を考慮する必要が無く、認知負荷を増やさないからです。
|
||||
|
||||
また重要な点は、その実装が、Misskeyリポジトリの外部にあるか・内部にあるかということや、Misskeyがメンテナンスするものか・第三者がメンテナンスするものかといったことは複雑性を考える上ではほとんど無視できるという点です。
|
||||
|
||||
もちろんその実装がMisskeyリポジトリにあり、Misskeyがメンテナンスしなければならないものは、保守のコストはかかります。
|
||||
しかし、Misskeyの本質的な設計・実装という観点で見たときは、その実装は実質的に外部ライブラリのように振る舞います。
|
||||
換言すれば「たまたまMisskeyの開発者と同じ人たちがメンテナンスしているし、たまたまMisskeyのリポジトリ内に置いてあるだけの外部ライブラリ」です。
|
||||
|
||||
そのため、実装をなるべくMisskeyのドメイン知識から独立したものにすれば、Misskeyのコードベースの複雑性を上げることなく機能実装を行うことができ、お得であると言えます。
|
||||
もちろんそれにこだわって、些細な実装でもそのように分離してしまうとかえって認知負荷が増えたり、実装量が増えてメリットをデメリットが上回る場合もあるので、ケースバイケースではあります。
|
||||
|
|
|
@ -23,6 +23,7 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
|
|||
COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-shared/"]
|
||||
COPY --link ["packages/frontend/package.json", "./packages/frontend/"]
|
||||
COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"]
|
||||
COPY --link ["packages/frontend-builder/package.json", "./packages/frontend-builder/"]
|
||||
COPY --link ["packages/icons-subsetter/package.json", "./packages/icons-subsetter/"]
|
||||
COPY --link ["packages/sw/package.json", "./packages/sw/"]
|
||||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
|
|
|
@ -68,7 +68,7 @@ receiveFollowRequest: "تلقيت طلب متابعة"
|
|||
followRequestAccepted: "قُبل طلب المتابعة"
|
||||
mention: "أشر الى"
|
||||
mentions: "الإشارات"
|
||||
directNotes: "الملاحظات المباشرة"
|
||||
directNotes: "رسالة خاصة"
|
||||
importAndExport: "إستورد / صدر"
|
||||
import: "استيراد"
|
||||
export: "تصدير"
|
||||
|
@ -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"
|
||||
|
@ -1370,9 +1371,13 @@ defaultImageCompressionLevel: "Nivell de comprensió de la imatge per defecte"
|
|||
defaultImageCompressionLevel_description: "Baixa, conserva la qualitat de la imatge però la mida de l'arxiu és més gran. <br>Alta, redueix la mida de l'arxiu però també la qualitat de la imatge."
|
||||
inMinutes: "Minut(s)"
|
||||
inDays: "Di(a)(es)"
|
||||
safeModeEnabled: "Mode segur activat"
|
||||
pluginsAreDisabledBecauseSafeMode: "Els afegits no estan activats perquè el mode segur està activat."
|
||||
customCssIsDisabledBecauseSafeMode: "El CSS personalitzat no s'aplica perquè el mode segur es troba activat."
|
||||
themeIsDefaultBecauseSafeMode: "El tema predeterminat es farà servir mentre el mode segur estigui activat. Una vegada es desactivi el mode segur es restablirà el tema escollit."
|
||||
_order:
|
||||
newest: "Més recent"
|
||||
oldest: "Cronològic"
|
||||
oldest: "Antigues primer"
|
||||
_chat:
|
||||
noMessagesYet: "Encara no tens missatges "
|
||||
newMessage: "Missatge nou"
|
||||
|
@ -1461,6 +1466,7 @@ _settings:
|
|||
contentsUpdateFrequency_description2: "Quan s'activa el mode en temps real, el contingut s'actualitza en temps real, independentment d'aquesta configuració."
|
||||
showUrlPreview: "Mostrar vista prèvia d'URL"
|
||||
showAvailableReactionsFirstInNote: "Mostra les reacciones que pots fer servir al damunt"
|
||||
showPageTabBarBottom: "Mostrar les pestanyes de les línies de temps a la part inferior"
|
||||
_chat:
|
||||
showSenderName: "Mostrar el nom del remitent"
|
||||
sendOnEnter: "Introdueix per enviar"
|
||||
|
@ -1634,6 +1640,10 @@ _serverSettings:
|
|||
fanoutTimelineDbFallback: "Carregar de la base de dades"
|
||||
fanoutTimelineDbFallbackDescription: "Quan s'activa, la línia de temps fa servir la base de dades per consultes adicionals si la línia de temps no es troba a la memòria cau. Si és desactiva la càrrega del servidor és veure reduïda, però també és reduirà el nombre de línies de temps que és poden obtenir."
|
||||
reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat."
|
||||
remoteNotesCleaning: "Neteja automàtica de notes remotes"
|
||||
remoteNotesCleaning_description: "Quan activis aquesta opció, periòdicament es netejaran les notes remotes que no es consultin, això evitarà que la base de dades se"
|
||||
remoteNotesCleaningMaxProcessingDuration: "D'oració màxima del temps de funcionament del procés de neteja"
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: "Duració mínima de conservació de les notes"
|
||||
inquiryUrl: "URL de consulta "
|
||||
inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació."
|
||||
openRegistration: "Registres oberts"
|
||||
|
@ -1652,6 +1662,8 @@ _serverSettings:
|
|||
userGeneratedContentsVisibilityForVisitor: "L'abast de la publicació del contingut generat per l'usuari"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "Això ajuda a evitar problemes com que continguts remots inadequats que no hagin estat moderats correctament es publiquin a internet mitjançant el teu servidor."
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "La publicació incondicional de tots els continguts del servidor a internet, incloent-hi els continguts remots rebuts pel servidor, comporta riscos. Això és extremadament important per els espectadors que desconeixen el caràcter descentralitzat dels continguts, ja que poden percebre erroneament els continguts remots com contingut generat per el propi servidor."
|
||||
restartServerSetupWizardConfirm_title: "Vols tornar a executar l'assistent de configuració inicial del servidor?"
|
||||
restartServerSetupWizardConfirm_text: "Algunes configuracions actuals seran restablertes."
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "Tot obert al públic "
|
||||
localOnly: "Només es publiquen els continguts locals, el contingut remot es manté privat"
|
||||
|
@ -1988,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 "
|
||||
|
@ -3062,6 +3075,7 @@ _bootErrors:
|
|||
otherOption1: "Esborrar la configuració i la memòria cau del client"
|
||||
otherOption2: "Iniciar client senzill"
|
||||
otherOption3: "Iniciar l'eina de reparació "
|
||||
otherOption4: "Iniciar Misskey en mode segur"
|
||||
_search:
|
||||
searchScopeAll: "Tot"
|
||||
searchScopeLocal: "Local"
|
||||
|
@ -3098,6 +3112,8 @@ _serverSetupWizard:
|
|||
doYouConnectToFediverse_description1: "Quan es connecta amb una xarxa de servidors distribuïts (Fedivers), els continguts poden intercanviar-se amb altres servidors i entre ells."
|
||||
doYouConnectToFediverse_description2: "La connexió amb el Fedivers també es coneix com a \"federació\"."
|
||||
youCanConfigureMoreFederationSettingsLater: "Les configuracions avançades, com especificar els servidors amb els quals es pot federar, es poden fer més tard."
|
||||
remoteContentsCleaning: "Neteja automàtica del contingut rebut"
|
||||
remoteContentsCleaning_description: "Quan es comença a federar es rep un munt de contingut, quan s'activa la neteja automàtica el contingut antic que no es consulta serà eliminat del servidor, el que permet estalviar espai d'emmagatzematge."
|
||||
adminInfo: "Informació de l'administrador "
|
||||
adminInfo_description: "Estableix la informació de l'administrador que es farà servir per rebre consultes."
|
||||
adminInfo_mustBeFilled: "Aquesta informació ha de ser omplerta si el servidor té els registres oberts o la federació es troba activada."
|
||||
|
@ -3150,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"
|
||||
|
@ -3165,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"
|
||||
|
@ -3182,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"
|
||||
|
|
|
@ -2004,7 +2004,7 @@ _deck:
|
|||
list: "Seznamy"
|
||||
channel: "Kanály"
|
||||
mentions: "Zmínění"
|
||||
direct: "Přímý"
|
||||
direct: "Přímé poznámky"
|
||||
roleTimeline: "Časová osa role"
|
||||
_dialog:
|
||||
charactersExceeded: "Překročili jste maximální počet znaků! V současné době je na hodnotě {current} z {max}."
|
||||
|
@ -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"
|
||||
|
|
|
@ -353,6 +353,7 @@ _visibility:
|
|||
home: "Κεντρικό"
|
||||
homeDescription: "Δημοσίευση στο κεντρικό χρονολόγιο μόνο"
|
||||
followers: "Ακολουθούν"
|
||||
specified: "Απευθείας σημειώματα"
|
||||
_profile:
|
||||
name: "Όνομα"
|
||||
username: "Όνομα μέλους"
|
||||
|
@ -395,6 +396,7 @@ _deck:
|
|||
antenna: "Αντένες"
|
||||
list: "Λίστα"
|
||||
mentions: "Επισημάνσεις"
|
||||
direct: "Απευθείας σημειώματα"
|
||||
_webhookSettings:
|
||||
name: "Όνομα"
|
||||
_moderationLogTypes:
|
||||
|
|
|
@ -81,7 +81,7 @@ import: "Import"
|
|||
export: "Export"
|
||||
files: "Files"
|
||||
download: "Download"
|
||||
driveFileDeleteConfirm: "Do you want to remove the file \"{name}\"? Some content using this file will also be removed."
|
||||
driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted."
|
||||
unfollowConfirm: "Are you sure you want to unfollow {name}?"
|
||||
exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed."
|
||||
importRequested: "You've requested an import. This may take a while."
|
||||
|
@ -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"
|
||||
|
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "Default image compression level"
|
|||
defaultImageCompressionLevel_description: "Lower level preserves image quality but increases file size.<br>Higher level reduce file size, but reduce image quality."
|
||||
inMinutes: "Minute(s)"
|
||||
inDays: "Day(s)"
|
||||
safeModeEnabled: "Safe mode is enabled"
|
||||
pluginsAreDisabledBecauseSafeMode: "All plugins are disabled because safe mode is enabled."
|
||||
customCssIsDisabledBecauseSafeMode: "Custom CSS is not applied because safe mode is enabled."
|
||||
themeIsDefaultBecauseSafeMode: "While safe mode is active, the default theme is used. Disabling safe mode will revert these changes."
|
||||
_order:
|
||||
newest: "Newest First"
|
||||
oldest: "Oldest First"
|
||||
|
@ -1402,7 +1407,7 @@ _chat:
|
|||
muteThisRoom: "Mute room"
|
||||
deleteRoom: "Delete room"
|
||||
chatNotAvailableForThisAccountOrServer: "Chat is not enabled on this server or for this account."
|
||||
chatIsReadOnlyForThisAccountOrServer: "Chat is read-only on this instance or this account. You cannot write new messages or create/join chat rooms."
|
||||
chatIsReadOnlyForThisAccountOrServer: "Chat is read-only on this server or this account. You cannot write new messages or create/join chat rooms."
|
||||
chatNotAvailableInOtherAccount: "The chat function is disabled for the other user."
|
||||
cannotChatWithTheUser: "Cannot start a chat with this user"
|
||||
cannotChatWithTheUser_description: "Chat is either unavailable or the other party has not enabled chat."
|
||||
|
@ -1461,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"
|
||||
|
@ -1500,7 +1506,7 @@ _abuseUserReport:
|
|||
resolveTutorial: "If the report's content is legitimate, select \"Accept\" to mark it as resolved.\nIf the report's content is illegitimate, select \"Reject\" to ignore it."
|
||||
_delivery:
|
||||
status: "Delivery status"
|
||||
stop: "Suspended"
|
||||
stop: "Suspend"
|
||||
resume: "Delivery resume"
|
||||
_type:
|
||||
none: "Publishing"
|
||||
|
@ -1634,6 +1640,10 @@ _serverSettings:
|
|||
fanoutTimelineDbFallback: "Fallback to database"
|
||||
fanoutTimelineDbFallbackDescription: "When enabled, the timeline will fall back to the database for additional queries if the timeline is not cached. Disabling it further reduces the server load by eliminating the fallback process, but limits the range of timelines that can be retrieved."
|
||||
reactionsBufferingDescription: "When enabled, performance during reaction creation will be greatly improved, reducing the load on the database. However, Redis memory usage will increase."
|
||||
remoteNotesCleaning: "Automatic cleanup of remote notes"
|
||||
remoteNotesCleaning_description: "When enabled, unused and outdated remote notes will be periodically cleaned up to prevent database bloat."
|
||||
remoteNotesCleaningMaxProcessingDuration: "Maximum cleanup processing time"
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: "Minimum days to retain notes"
|
||||
inquiryUrl: "Inquiry URL"
|
||||
inquiryUrlDescription: "Specify a URL for the inquiry form to the server maintainer or a web page for the contact information."
|
||||
openRegistration: "Make the account creation open"
|
||||
|
@ -1652,6 +1662,8 @@ _serverSettings:
|
|||
userGeneratedContentsVisibilityForVisitor: "Visibility of user-generated content to guests"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "This is useful for preventing problems caused by inappropriate remote content that is not well moderated from being unintentionally published on the Internet via your own server."
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "Unconditionally publishing all content on the server to the Internet, including remote content received by the server is risky. This is especially important for guests who are unaware of the distributed nature of the content, as they may mistakenly believe that even remote content is content created by users on the server."
|
||||
restartServerSetupWizardConfirm_title: "Restart server setup wizard?"
|
||||
restartServerSetupWizardConfirm_text: "Some current settings will be reset."
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "Everything is public"
|
||||
localOnly: "Only local content is published, remote content is kept private"
|
||||
|
@ -1988,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"
|
||||
|
@ -2332,7 +2345,7 @@ _permissions:
|
|||
"read:admin:index-stats": "View database index stats"
|
||||
"read:admin:table-stats": "View database table stats"
|
||||
"read:admin:user-ips": "View user IP addresses"
|
||||
"read:admin:meta": "View instance metadata"
|
||||
"read:admin:meta": "View server metadata"
|
||||
"write:admin:reset-password": "Reset user password"
|
||||
"write:admin:resolve-abuse-user-report": "Resolve user report"
|
||||
"write:admin:send-email": "Send email"
|
||||
|
@ -2343,7 +2356,7 @@ _permissions:
|
|||
"write:admin:unset-user-avatar": "Remove user avatar"
|
||||
"write:admin:unset-user-banner": "Remove user banner"
|
||||
"write:admin:unsuspend-user": "Unsuspend user"
|
||||
"write:admin:meta": "Manage instance metadata"
|
||||
"write:admin:meta": "Manage server metadata"
|
||||
"write:admin:user-note": "Manage moderation note"
|
||||
"write:admin:roles": "Manage roles"
|
||||
"read:admin:roles": "View roles"
|
||||
|
@ -2775,7 +2788,7 @@ _moderationLogTypes:
|
|||
resetPassword: "Password reset"
|
||||
suspendRemoteInstance: "Remote instance suspended"
|
||||
unsuspendRemoteInstance: "Remote instance unsuspended"
|
||||
updateRemoteInstanceNote: "Moderation note updated for remote instance."
|
||||
updateRemoteInstanceNote: "Updated moderation note for remote servers"
|
||||
markSensitiveDriveFile: "File marked as sensitive"
|
||||
unmarkSensitiveDriveFile: "File unmarked as sensitive"
|
||||
resolveAbuseReport: "Report resolved"
|
||||
|
@ -3062,6 +3075,7 @@ _bootErrors:
|
|||
otherOption1: "Delete client settings and cache"
|
||||
otherOption2: "Start the simple client"
|
||||
otherOption3: "Launch the repair tool"
|
||||
otherOption4: "Launch Misskey in safe mode"
|
||||
_search:
|
||||
searchScopeAll: "All"
|
||||
searchScopeLocal: "Local"
|
||||
|
@ -3098,6 +3112,8 @@ _serverSetupWizard:
|
|||
doYouConnectToFediverse_description1: "When connected to a network of distributed servers (Fediverse) content can be exchanged with other servers."
|
||||
doYouConnectToFediverse_description2: "Connecting with the Fediverse is also called \"federation\""
|
||||
youCanConfigureMoreFederationSettingsLater: "Advanced settings such as specifying federated servers can be configured later."
|
||||
remoteContentsCleaning: "Automatic cleanup of received contents"
|
||||
remoteContentsCleaning_description: "Federation may result in a continuous inflow of content. Enabling automatic cleanup will remove outdated and unreferenced content from the server to save storage."
|
||||
adminInfo: "Administrator information"
|
||||
adminInfo_description: "Sets the administrator information used to receive inquiries."
|
||||
adminInfo_mustBeFilled: "Must be entered if public server or federation is on."
|
||||
|
@ -3150,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"
|
||||
|
@ -3165,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"
|
||||
|
@ -3182,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"
|
||||
|
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "Nivel de compresión de la imagen por defecto"
|
|||
defaultImageCompressionLevel_description: "Baja, conserva la calidad de la imagen pero la medida del archivo es más grande. <br>Alta, reduce la medida del archivo pero también la calidad de la imagen."
|
||||
inMinutes: "Minutos"
|
||||
inDays: "Días"
|
||||
safeModeEnabled: "El modo seguro está activado"
|
||||
pluginsAreDisabledBecauseSafeMode: "El modo seguro está activado, por lo que todos los plugins están desactivados."
|
||||
customCssIsDisabledBecauseSafeMode: "El modo seguro está activado, por lo que no se aplica el CSS personalizado."
|
||||
themeIsDefaultBecauseSafeMode: "Mientras el modo seguro esté activado, se utilizará el tema predeterminado. Cuando se desactive el modo seguro, se volverá al tema original."
|
||||
_order:
|
||||
newest: "Los más recientes primero"
|
||||
oldest: "Los más antiguos primero"
|
||||
|
@ -1461,6 +1466,7 @@ _settings:
|
|||
contentsUpdateFrequency_description2: "Cuando el modo en tiempo real está activado, el contenido se actualiza en tiempo real independientemente de esta configuración."
|
||||
showUrlPreview: "Mostrar la vista previa de la URL"
|
||||
showAvailableReactionsFirstInNote: "Mostrar las reacciones disponibles en la parte superior."
|
||||
showPageTabBarBottom: "Mostrar la barra de pestañas de la página en la parte inferior."
|
||||
_chat:
|
||||
showSenderName: "Mostrar el nombre del remitente"
|
||||
sendOnEnter: "Intro para enviar"
|
||||
|
@ -1634,6 +1640,10 @@ _serverSettings:
|
|||
fanoutTimelineDbFallback: "Cargar desde la base de datos"
|
||||
fanoutTimelineDbFallbackDescription: "Cuando esta opción está habilitada, la carga de peticiones adicionales de la línea de tiempo se hará desde la base de datos cuando éstas no se encuentren en la caché. Al deshabilitar esta opción se reduce la carga del servidor, pero limita el número de líneas de tiempo que pueden obtenerse."
|
||||
reactionsBufferingDescription: "Cuando se activa, el rendimiento durante la creación de reacciones mejorará considerablemente, reduciendo la carga de la base de datos. Sin embargo, aumentará el uso de memoria de Redis."
|
||||
remoteNotesCleaning: "Limpieza automática de notas (publicaciones) remotas"
|
||||
remoteNotesCleaning_description: "Al habilitar esta opción, se limpiarán periódicamente las entradas remotas antiguas que no se consultan, lo que evitará que la base de datos se sature."
|
||||
remoteNotesCleaningMaxProcessingDuration: "Tiempo máximo de funcionamiento continuo del proceso de limpieza"
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: "Días mínimos para conservar las notas"
|
||||
inquiryUrl: "URL de consulta "
|
||||
inquiryUrlDescription: "Especifica una URL para el formulario de consulta al responsable del servidor o una página web para la información de contacto."
|
||||
openRegistration: "Registros Abiertos"
|
||||
|
@ -1652,6 +1662,8 @@ _serverSettings:
|
|||
userGeneratedContentsVisibilityForVisitor: "Visibilidad de contenido generado por un usuario a invitados"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "Esto es útil para evitar problemas causados por contenidos remotos inapropiados que no estén bien moderados y que se publiquen involuntariamente en Internet a través de su propio servidor."
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "Publicar incondicionalmente todo el contenido del servidor en Internet, incluido el contenido remoto recibido por el servidor, es arriesgado. Esto es especialmente importante para los invitados que desconocen la naturaleza distribuida del contenido, ya que pueden creer erróneamente que incluso el contenido remoto es contenido creado por usuarios en el servidor."
|
||||
restartServerSetupWizardConfirm_title: "¿Reiniciar el asistente de configuración del servidor?"
|
||||
restartServerSetupWizardConfirm_text: "Algunas configuraciones actuales se restablecerán"
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "Todo es público."
|
||||
localOnly: "Sólo se publica el contenido local, el remoto se mantiene privado"
|
||||
|
@ -1988,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"
|
||||
|
@ -3062,6 +3075,7 @@ _bootErrors:
|
|||
otherOption1: "Borra la configuración y la memoria caché del cliente"
|
||||
otherOption2: "Iniciar el cliente simple"
|
||||
otherOption3: "Iniciar la herramienta de reparación"
|
||||
otherOption4: "Iniciar Misskey en modo seguro"
|
||||
_search:
|
||||
searchScopeAll: "Todo"
|
||||
searchScopeLocal: "Local"
|
||||
|
@ -3098,6 +3112,8 @@ _serverSetupWizard:
|
|||
doYouConnectToFediverse_description1: "Cuando se conecta a una red de servidores distribuidos (Fediverso), el contenido puede intercambiarse con otros servidores."
|
||||
doYouConnectToFediverse_description2: "Conectarse con el Fediverso también se conoce como \"federación\"."
|
||||
youCanConfigureMoreFederationSettingsLater: "Los ajustes avanzados, como la especificación de servidores federados, pueden configurarse más adelante."
|
||||
remoteContentsCleaning: "Limpieza automática de los contenidos recibidos"
|
||||
remoteContentsCleaning_description: "La federación puede dar lugar a un flujo continuo de contenido. Al habilitar la limpieza automática, se eliminará del servidor el contenido obsoleto y sin referencias para ahorrar espacio de almacenamiento."
|
||||
adminInfo: "Información del administrador"
|
||||
adminInfo_description: "Establece la información del administrador para recibir consultas."
|
||||
adminInfo_mustBeFilled: "Esta información debe ser introducida en el caso de registros abiertos o la federación esté activada."
|
||||
|
@ -3150,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"
|
||||
|
@ -3165,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"
|
||||
|
@ -3182,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"
|
||||
|
|
|
@ -73,7 +73,7 @@ export default function generateDTS() {
|
|||
ts.NodeFlags.Const,
|
||||
),
|
||||
),
|
||||
ts.factory.createInterfaceDeclaration(
|
||||
ts.factory.createTypeAliasDeclaration(
|
||||
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
|
||||
ts.factory.createIdentifier('ParameterizedString'),
|
||||
[
|
||||
|
@ -84,20 +84,22 @@ export default function generateDTS() {
|
|||
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
[
|
||||
ts.factory.createPropertySignature(
|
||||
undefined,
|
||||
ts.factory.createComputedPropertyName(
|
||||
ts.factory.createIdentifier('kParameters'),
|
||||
),
|
||||
undefined,
|
||||
ts.factory.createTypeReferenceNode(
|
||||
ts.factory.createIdentifier('T'),
|
||||
ts.factory.createIntersectionTypeNode([
|
||||
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
|
||||
ts.factory.createTypeLiteralNode([
|
||||
ts.factory.createPropertySignature(
|
||||
undefined,
|
||||
ts.factory.createComputedPropertyName(
|
||||
ts.factory.createIdentifier('kParameters'),
|
||||
),
|
||||
undefined,
|
||||
ts.factory.createTypeReferenceNode(
|
||||
ts.factory.createIdentifier('T'),
|
||||
undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
])
|
||||
]),
|
||||
),
|
||||
ts.factory.createInterfaceDeclaration(
|
||||
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
|
||||
|
|
|
@ -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"
|
||||
|
|
154
locales/index.d.ts
vendored
154
locales/index.d.ts
vendored
|
@ -2,9 +2,9 @@
|
|||
// This file is generated by locales/generateDTS.js
|
||||
// Do not edit this file directly.
|
||||
declare const kParameters: unique symbol;
|
||||
export interface ParameterizedString<T extends string = string> {
|
||||
export type ParameterizedString<T extends string = string> = string & {
|
||||
[kParameters]: T;
|
||||
}
|
||||
};
|
||||
export interface ILocale {
|
||||
[_: string]: string | ParameterizedString | ILocale;
|
||||
}
|
||||
|
@ -4386,6 +4386,10 @@ export interface Locale extends ILocale {
|
|||
* ノート検索は利用できません。
|
||||
*/
|
||||
"notesSearchNotAvailable": string;
|
||||
/**
|
||||
* ユーザー検索は利用できません。
|
||||
*/
|
||||
"usersSearchNotAvailable": string;
|
||||
/**
|
||||
* ライセンス
|
||||
*/
|
||||
|
@ -5871,6 +5875,10 @@ export interface Locale extends ILocale {
|
|||
* 利用できるリアクションを先頭に表示
|
||||
*/
|
||||
"showAvailableReactionsFirstInNote": string;
|
||||
/**
|
||||
* ページのタブバーを下部に表示
|
||||
*/
|
||||
"showPageTabBarBottom": string;
|
||||
"_chat": {
|
||||
/**
|
||||
* 送信者の名前を表示
|
||||
|
@ -7795,6 +7803,10 @@ export interface Locale extends ILocale {
|
|||
* ノート検索の利用
|
||||
*/
|
||||
"canSearchNotes": string;
|
||||
/**
|
||||
* ユーザー検索の利用
|
||||
*/
|
||||
"canSearchUsers": string;
|
||||
/**
|
||||
* 翻訳機能の利用
|
||||
*/
|
||||
|
@ -12199,6 +12211,10 @@ export interface Locale extends ILocale {
|
|||
* 高度
|
||||
*/
|
||||
"advanced": string;
|
||||
/**
|
||||
* 角度
|
||||
*/
|
||||
"angle": string;
|
||||
/**
|
||||
* ストライプ
|
||||
*/
|
||||
|
@ -12211,10 +12227,6 @@ export interface Locale extends ILocale {
|
|||
* ラインの数
|
||||
*/
|
||||
"stripeFrequency": string;
|
||||
/**
|
||||
* 角度
|
||||
*/
|
||||
"angle": string;
|
||||
/**
|
||||
* ポルカドット
|
||||
*/
|
||||
|
@ -12257,6 +12269,10 @@ export interface Locale extends ILocale {
|
|||
* 変更を破棄して終了しますか?
|
||||
*/
|
||||
"discardChangesConfirm": string;
|
||||
/**
|
||||
* 設定項目はありません
|
||||
*/
|
||||
"nothingToConfigure": string;
|
||||
"_fxs": {
|
||||
/**
|
||||
* 色収差
|
||||
|
@ -12323,6 +12339,132 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"tearing": string;
|
||||
};
|
||||
"_fxProps": {
|
||||
/**
|
||||
* 角度
|
||||
*/
|
||||
"angle": string;
|
||||
/**
|
||||
* サイズ
|
||||
*/
|
||||
"scale": string;
|
||||
/**
|
||||
* サイズ
|
||||
*/
|
||||
"size": string;
|
||||
/**
|
||||
* 色
|
||||
*/
|
||||
"color": string;
|
||||
/**
|
||||
* 不透明度
|
||||
*/
|
||||
"opacity": string;
|
||||
/**
|
||||
* 正規化
|
||||
*/
|
||||
"normalize": string;
|
||||
/**
|
||||
* 量
|
||||
*/
|
||||
"amount": string;
|
||||
/**
|
||||
* 明るさ
|
||||
*/
|
||||
"lightness": string;
|
||||
/**
|
||||
* コントラスト
|
||||
*/
|
||||
"contrast": string;
|
||||
/**
|
||||
* 色相
|
||||
*/
|
||||
"hue": string;
|
||||
/**
|
||||
* 輝度
|
||||
*/
|
||||
"brightness": string;
|
||||
/**
|
||||
* 彩度
|
||||
*/
|
||||
"saturation": string;
|
||||
/**
|
||||
* 最大値
|
||||
*/
|
||||
"max": string;
|
||||
/**
|
||||
* 最小値
|
||||
*/
|
||||
"min": string;
|
||||
/**
|
||||
* 方向
|
||||
*/
|
||||
"direction": string;
|
||||
/**
|
||||
* 位相
|
||||
*/
|
||||
"phase": string;
|
||||
/**
|
||||
* 頻度
|
||||
*/
|
||||
"frequency": string;
|
||||
/**
|
||||
* 強さ
|
||||
*/
|
||||
"strength": string;
|
||||
/**
|
||||
* ズレ
|
||||
*/
|
||||
"glitchChannelShift": string;
|
||||
/**
|
||||
* シード値
|
||||
*/
|
||||
"seed": string;
|
||||
/**
|
||||
* 赤色成分
|
||||
*/
|
||||
"redComponent": string;
|
||||
/**
|
||||
* 緑色成分
|
||||
*/
|
||||
"greenComponent": string;
|
||||
/**
|
||||
* 青色成分
|
||||
*/
|
||||
"blueComponent": string;
|
||||
/**
|
||||
* しきい値
|
||||
*/
|
||||
"threshold": string;
|
||||
/**
|
||||
* 中心X
|
||||
*/
|
||||
"centerX": string;
|
||||
/**
|
||||
* 中心Y
|
||||
*/
|
||||
"centerY": string;
|
||||
/**
|
||||
* スムージング
|
||||
*/
|
||||
"zoomLinesSmoothing": string;
|
||||
/**
|
||||
* スムージングと集中線の幅の設定は併用できません。
|
||||
*/
|
||||
"zoomLinesSmoothingDescription": string;
|
||||
/**
|
||||
* 集中線の幅
|
||||
*/
|
||||
"zoomLinesThreshold": string;
|
||||
/**
|
||||
* 中心径
|
||||
*/
|
||||
"zoomLinesMaskSize": string;
|
||||
/**
|
||||
* 黒色にする
|
||||
*/
|
||||
"zoomLinesBlack": string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* 下書き
|
||||
|
|
|
@ -36,6 +36,7 @@ const languages = [
|
|||
'ru-RU',
|
||||
'sk-SK',
|
||||
'th-TH',
|
||||
'tr-TR',
|
||||
'ug-CN',
|
||||
'uk-UA',
|
||||
'vi-VN',
|
||||
|
|
|
@ -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"
|
||||
|
@ -1198,7 +1199,7 @@ replies: "Risposte"
|
|||
renotes: "Rinota"
|
||||
loadReplies: "Leggi le risposte"
|
||||
loadConversation: "Leggi la conversazione"
|
||||
pinnedList: "Elenco in primo piano"
|
||||
pinnedList: "Lista in primo piano"
|
||||
keepScreenOn: "Mantenere lo schermo acceso"
|
||||
verifiedLink: "Abbiamo confermato la validità di questo collegamento"
|
||||
notifyNotes: "Notifica nuove Note"
|
||||
|
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "Livello predefinito di compressione immagini"
|
|||
defaultImageCompressionLevel_description: "La compressione diminuisce la qualità dell'immagine, poca compressione mantiene alta qualità delle immagini. Aumentandola, si riducono le dimensioni del file, a discapito della qualità dell'immagine."
|
||||
inMinutes: "min"
|
||||
inDays: "giorni"
|
||||
safeModeEnabled: "La modalità sicura è attiva"
|
||||
pluginsAreDisabledBecauseSafeMode: "Tutti i plugin sono disattivati, poiché la modalità sicura è attiva."
|
||||
customCssIsDisabledBecauseSafeMode: "Il CSS personalizzato non è stato applicato, poiché la modalità sicura è attiva."
|
||||
themeIsDefaultBecauseSafeMode: "Quando la modalità sicura è attiva, viene utilizzato il tema predefinito. Quando la modalità sicura viene disattivata, il tema torna a essere quello precedente."
|
||||
_order:
|
||||
newest: "Prima i più recenti"
|
||||
oldest: "Meno recenti prima"
|
||||
|
@ -1461,6 +1466,7 @@ _settings:
|
|||
contentsUpdateFrequency_description2: "Quando la modalità è in tempo reale, arriveranno a prescindere."
|
||||
showUrlPreview: "Mostra anteprima dell'URL"
|
||||
showAvailableReactionsFirstInNote: "Mostra le reazioni disponibili in alto"
|
||||
showPageTabBarBottom: "Visualizza le schede della pagina nella parte inferiore"
|
||||
_chat:
|
||||
showSenderName: "Mostra il nome del mittente"
|
||||
sendOnEnter: "Invio spedisce"
|
||||
|
@ -1634,6 +1640,10 @@ _serverSettings:
|
|||
fanoutTimelineDbFallback: "Elaborazione dati alternativa"
|
||||
fanoutTimelineDbFallbackDescription: "Attivando l'elaborazione alternativa, verrà interrogato ulteriormente il database se la timeline non è nella cache. \nDisattivando, si può ridurre ulteriormente il carico del server, evitando l'elaborazione alternativa, ma limitando l'intervallo recuperabile delle timeline."
|
||||
reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
|
||||
remoteNotesCleaning: "Pulizia automatica dei contenuti remoti"
|
||||
remoteNotesCleaning_description: "Se abilitata, verranno periodicamente rimosse le vecchie Note remote senza relazioni, per ridurre il sovraccarico del sistema."
|
||||
remoteNotesCleaningMaxProcessingDuration: "Durata massima del processo di pulizia"
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: "Periodo minimo di conservazione delle note"
|
||||
inquiryUrl: "URL di contatto"
|
||||
inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
|
||||
openRegistration: "Registrazioni aperte"
|
||||
|
@ -1652,6 +1662,8 @@ _serverSettings:
|
|||
userGeneratedContentsVisibilityForVisitor: "Visibilità dei contenuti generati dagli utenti ai non utenti"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "Questa funzionalità è utile per impedire che contenuti remoti inappropriati e difficili da moderare vengano inavvertitamente resi pubblici su Internet tramite il proprio server."
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "Esistono dei rischi nell'esporre incondizionatamente su internet tutto il contenuto del tuo server, incluso il contenuto remoto ricevuto da altri server. In particolare, occorre prestare attenzione, perché le persone non consapevoli della federazione potrebbero erroneamente credere che il contenuto remoto sia stato invece creato all'interno del proprio server."
|
||||
restartServerSetupWizardConfirm_title: "Vuoi ripetere la procedura guidata di configurazione iniziale del server?"
|
||||
restartServerSetupWizardConfirm_text: "Verranno ripristinate alcune tue impostazioni personalizzate."
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "Tutto pubblico"
|
||||
localOnly: "Pubblica solo contenuti locali, mantieni privati i contenuti remoti"
|
||||
|
@ -1988,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più."
|
||||
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"
|
||||
|
@ -3062,6 +3075,7 @@ _bootErrors:
|
|||
otherOption1: "Nelle impostazioni, cancellare le impostazioni del client e svuotare la cache"
|
||||
otherOption2: "Avviare il client predefinito"
|
||||
otherOption3: "Avviare lo strumento di riparazione"
|
||||
otherOption4: "Avvia Misskey in modalità sicura"
|
||||
_search:
|
||||
searchScopeAll: "Tutte"
|
||||
searchScopeLocal: "Locale"
|
||||
|
@ -3098,6 +3112,8 @@ _serverSetupWizard:
|
|||
doYouConnectToFediverse_description1: "Collegandosi a una rete di server distribuiti, denominata Fediverso, potrai scambiare contenuti con altri server, tramite il protocollo di comunicazione ActivityPub."
|
||||
doYouConnectToFediverse_description2: "Connettersi al Fediverso è anche detto \"federazione\"."
|
||||
youCanConfigureMoreFederationSettingsLater: "Puoi svolgere la configurazione avanzata anche dopo. Ad esempio specificando quali server possono federarsi."
|
||||
remoteContentsCleaning: "Pulizia automatica dei contenuti in arrivo"
|
||||
remoteContentsCleaning_description: "Con la federazione funzionante, riceverai sempre più contenuti. Abilitando la pulizia automatica, i contenuti non referenziati e obsoleti verranno rimossi automaticamente dai tuoi server, risparmiando spazio di archiviazione."
|
||||
adminInfo: "Informazioni sull'amministratore"
|
||||
adminInfo_description: "Imposta le informazioni dell'amministratore utilizzate per accettare le richieste."
|
||||
adminInfo_mustBeFilled: "Questa operazione è necessaria su un server aperto o se è attiva la federazione."
|
||||
|
@ -3150,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"
|
||||
|
@ -3165,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"
|
||||
|
@ -3182,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: "自分のクリップ"
|
||||
|
@ -1469,6 +1470,7 @@ _settings:
|
|||
contentsUpdateFrequency_description2: "リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。"
|
||||
showUrlPreview: "URLプレビューを表示する"
|
||||
showAvailableReactionsFirstInNote: "利用できるリアクションを先頭に表示"
|
||||
showPageTabBarBottom: "ページのタブバーを下部に表示"
|
||||
|
||||
_chat:
|
||||
showSenderName: "送信者の名前を表示"
|
||||
|
@ -2019,6 +2021,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。"
|
||||
canHideAds: "広告の非表示"
|
||||
canSearchNotes: "ノート検索の利用"
|
||||
canSearchUsers: "ユーザー検索の利用"
|
||||
canUseTranslator: "翻訳機能の利用"
|
||||
avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
|
||||
canImportAntennas: "アンテナのインポートを許可"
|
||||
|
@ -3266,10 +3269,10 @@ _watermarkEditor:
|
|||
type: "タイプ"
|
||||
image: "画像"
|
||||
advanced: "高度"
|
||||
angle: "角度"
|
||||
stripe: "ストライプ"
|
||||
stripeWidth: "ラインの幅"
|
||||
stripeFrequency: "ラインの数"
|
||||
angle: "角度"
|
||||
polkadot: "ポルカドット"
|
||||
checker: "チェッカー"
|
||||
polkadotMainDotOpacity: "メインドットの不透明度"
|
||||
|
@ -3282,6 +3285,7 @@ _imageEffector:
|
|||
title: "エフェクト"
|
||||
addEffect: "エフェクトを追加"
|
||||
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||
nothingToConfigure: "設定項目はありません"
|
||||
|
||||
_fxs:
|
||||
chromaticAberration: "色収差"
|
||||
|
@ -3301,6 +3305,39 @@ _imageEffector:
|
|||
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: "下書きを選択"
|
||||
|
|
|
@ -1333,6 +1333,10 @@ hideAllTips: "「ヒントとコツ」は全部表示せんでええ"
|
|||
defaultImageCompressionLevel_description: "低くすると画質は保てるんやけど、ファイルサイズが増えるで。<br>高くするとファイルサイズは減らせるんやけど、画質が落ちるで。"
|
||||
inMinutes: "分"
|
||||
inDays: "日"
|
||||
safeModeEnabled: "セーフモードがオンになってるで"
|
||||
pluginsAreDisabledBecauseSafeMode: "セーフモードがオンやから、プラグインは全部無効化されてるで。"
|
||||
customCssIsDisabledBecauseSafeMode: "セーフモードがオンやから、カスタムCSSは適用されてへんで。"
|
||||
themeIsDefaultBecauseSafeMode: "セーフモードがオンの間はデフォルトのテーマを使うで。セーフモードをオフにれば元に戻るで。"
|
||||
_chat:
|
||||
noMessagesYet: "まだメッセージはあらへんで"
|
||||
individualChat_description: "特定のユーザーと一対一でチャットができるで。"
|
||||
|
@ -1345,8 +1349,59 @@ _chat:
|
|||
members: "メンバーはん"
|
||||
home: "ホーム"
|
||||
send: "送信"
|
||||
deleteRoom: "ルームをほかす"
|
||||
chatNotAvailableForThisAccountOrServer: "このサーバー、もしくはこのアカウントでチャットが有効にされてへんで。"
|
||||
chatIsReadOnlyForThisAccountOrServer: "このサーバー、もしくはこのアカウントでチャットが読み取り専用になっとるわ。新しく書き込んだり、チャットルームを作ったり参加したりはできへんで。"
|
||||
chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えんくなっとるみたいやわ。"
|
||||
cannotChatWithTheUser: "このユーザーとのチャットを開始できへんみたいやわ"
|
||||
cannotChatWithTheUser_description: "チャットが使えん状態になっとるか、相手がチャットを開放してへんみたいやわ。"
|
||||
youAreNotAMemberOfThisRoomButInvited: "あんたはこのルームの参加者ちゃうけど、招待が届いとるで。参加するんやったら、招待を承認してな。"
|
||||
doYouAcceptInvitation: "招待を承認してもええんか?"
|
||||
chatWithThisUser: "チャットしよか"
|
||||
thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのチャットしか受け付けとらんみたいやわ。"
|
||||
thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしとるユーザーからのチャットしか受け付けとらんみたいやわ。"
|
||||
thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのチャットしか受け付けとらんみたいやわ。"
|
||||
thisUserNotAllowedChatAnyone: "このユーザーは誰からのチャットも受け付けとらんみたいやわ。"
|
||||
chatAllowedUsers: "チャットしてもええ相手"
|
||||
chatAllowedUsers_note: "自分からチャットメッセージを送った相手やったらこの設定に関わらずチャットできるで。"
|
||||
_chatAllowedUsers:
|
||||
followers: "自分のフォロワーだけ"
|
||||
following: "自分がフォローしとるユーザーだけ"
|
||||
mutual: "相互フォローのユーザーだけ"
|
||||
none: "誰もかもあかん"
|
||||
_emojiPalette:
|
||||
enableSyncBetweenDevicesForPalettes: "パレットのデバイス間同期をつけとく"
|
||||
paletteForMain: "メインで使うパレット"
|
||||
paletteForReaction: "リアクションで使うパレット"
|
||||
_settings:
|
||||
driveBanner: "ドライブの管理と設定、使用量の確認、ファイルをアップロードするときの設定ができるで。"
|
||||
pluginBanner: "プラグインを使うとクライアントの機能を拡張できるねん。プラグインのインストール、個別の設定と管理ができるで。"
|
||||
notificationsBanner: "サーバーから受け取る通知の種類とか範囲、プッシュ通知の設定ができるで。"
|
||||
webhook: "Webhook"
|
||||
serviceConnectionBanner: "外部のアプリ・サービスと連携するのに使うとるアクセストークンとかWebhookの管理と設定ができるで。"
|
||||
accountDataBanner: "アカウントデータのアーカイブをエクスポート/インポートして管理できるで。"
|
||||
muteAndBlockBanner: "見せんでええコンテンツの設定とか、特定のユーザーからのアクションを制限する設定と管理ができるで。"
|
||||
accessibilityBanner: "クライアントの視覚や動作に関わるパーソナライズをして、よりええ感じに使えるように設定できるで。"
|
||||
privacyBanner: "コンテンツの公開範囲、見つけやすさ、フォローの承認制とかアカウントのプライバシーに関わる設定ができるで。"
|
||||
securityBanner: "パスワード、ログイン方法、認証アプリ、パスキーとかアカウントのセキュリティに関わる設定ができるで。"
|
||||
preferencesBanner: "好みに応じた、クライアントの全体的な動作の設定ができるで。"
|
||||
appearanceBanner: "好みに応じた、クライアントの見た目・表示方法に関わる設定ができるで。"
|
||||
soundsBanner: "クライアントで流すサウンドの設定ができるで。"
|
||||
makeEveryTextElementsSelectable: "全部のテキスト要素を選択できるようにする"
|
||||
makeEveryTextElementsSelectable_description: "これをつけると、一部のシチュエーションでユーザビリティが低下するかもしれん。"
|
||||
enablePullToRefresh_description: "マウスやったら、ホイールを押し込みながらドラッグしてな。"
|
||||
realtimeMode_description: "サーバーと接続を確立して、リアルタイムでコンテンツを更新するで。通信量とバッテリーの消費が多くなるかもしれへん。"
|
||||
contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されるんやけど、そのぶんパフォーマンスが低くなるし、通信量とバッテリーの消費も増えるねん。"
|
||||
contentsUpdateFrequency_description2: "リアルタイムモードをつけてるんやったら、この設定がどうであれリアルタイムでコンテンツが更新されるで。"
|
||||
_preferencesProfile:
|
||||
profileNameDescription: "このデバイスはなんて呼んだらええんや?"
|
||||
_preferencesBackup:
|
||||
noBackupsFoundTitle: "バックアップが見つからへんね"
|
||||
noBackupsFoundDescription: "自動で作られたバックアップは見つからんかったけど、バックアップファイルを手動で保存してるんやったら、それをインポートして復元できるで。"
|
||||
selectBackupToRestore: "復元するバックアップを選んでや"
|
||||
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効するんやったらプロファイル名の設定が必要やな。"
|
||||
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になってへんで。"
|
||||
backupFound: "設定のバックアップがあるみたいやわ"
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "ログインしてもらってからコンテンツ見てもらう"
|
||||
requireSigninToViewContentsDescription1: "あなたが作成した全部のノートとかのコンテンツを見れるようにするのにログインがいるようにするで。クローラーにいろいろ収集されるんを防げるかもしれん。"
|
||||
|
@ -1357,6 +1412,7 @@ _accountSettings:
|
|||
makeNotesHiddenBefore: "昔のノートを見れんようにする"
|
||||
makeNotesHiddenBeforeDescription: "この機能が有効になってる間は、設定された日時より前、それか設定された時間が経ったノートがフォロワーのみ見れるようになるで。無効に戻すと、ノートの公開状態も戻るで。"
|
||||
mayNotEffectForFederatedNotes: "リモートサーバーに連合されたノートには効果が及ばんかもしれん。"
|
||||
mayNotEffectSomeSituations: "これらの制限は簡易的なものやで。リモートサーバーでの閲覧とかモデレーション時とか、一部のシチュエーションでは適用されへんかもしれん。"
|
||||
notesHavePassedSpecifiedPeriod: "決めた時間が経ったノート"
|
||||
notesOlderThanSpecifiedDateAndTime: "決めた日時より前のノート"
|
||||
_abuseUserReport:
|
||||
|
@ -1375,6 +1431,7 @@ _delivery:
|
|||
manuallySuspended: "手動停止中"
|
||||
goneSuspended: "サーバー削除のため停止中"
|
||||
autoSuspendedForNotResponding: "サーバー応答せえへんから停止中"
|
||||
softwareSuspended: "配信停止中のソフトウェアやから停止中"
|
||||
_bubbleGame:
|
||||
howToPlay: "遊び方"
|
||||
hold: "ホールド"
|
||||
|
@ -1501,11 +1558,21 @@ _serverSettings:
|
|||
fanoutTimelineDbFallback: "データベースにフォールバックする"
|
||||
fanoutTimelineDbFallbackDescription: "有効にしたら、タイムラインがキャッシュん中に入ってないときにDBにもっかい問い合わせるフォールバック処理ってのをやっとくで。切ったらフォールバック処理をやらんからサーバーはもっと軽くなんねんけど、タイムラインの取得範囲がちょっと減るで。"
|
||||
reactionsBufferingDescription: "有効にしたら、リアクション作るときのパフォーマンスがすっごい上がって、データベースへの負荷が減るで。代わりに、Redisのメモリ使用は増えるで。"
|
||||
remoteNotesCleaning_description: "つけると、参照されてへん古いリモートの投稿を定期的にクリーンアップしてデータベースの肥大化を抑えてくれるで。"
|
||||
inquiryUrl: "問い合わせ先URL"
|
||||
inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定するで。"
|
||||
openRegistration: "アカウントの作成をオープンにする"
|
||||
openRegistrationWarning: "登録を解放するのはリスクが伴うで。サーバーをいっつも監視して、なんか起きたらすぐに対応できるんやったら、オンにしてもええと思う。"
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターがおらんかったら、スパムを防ぐためにこの設定は勝手に切られるで。"
|
||||
deliverSuspendedSoftwareDescription: "脆弱性とかの理由で、サーバーのソフトウェアの名前とバージョンの範囲を決めて配信を止められるで。このバージョン情報はサーバーが提供したものやから、信頼性は保証されへん。バージョン指定には semver の範囲指定が使えるねんけど、>= 2024.3.1と指定すると 2024.3.1-custom.0 みたいなカスタムバージョンが含まれへんから、>= 2024.3.1-0 みたいに prerelease を指定するとええかもしれへんな。"
|
||||
singleUserMode_description: "このサーバーを使うとるんが自分だけなんやったら、このモードを有効にすると動作がええ感じになるで。"
|
||||
signToActivityPubGet_description: "通常はつけといてな。連合の通信に関わる問題があるんやったら、無効にすると改善するかもしれへんけど、逆にサーバーによっては通信ができんくなることがあるで。"
|
||||
proxyRemoteFiles_description: "つけると、リモートのファイルをプロキシして提供するで。画像のサムネイル生成とかユーザーのプライバシー保護にええな。"
|
||||
allowExternalApRedirect_description: "つけると、他のサーバーがうちのサーバーを通して第三者のコンテンツを照会できるようになるんやけど、コンテンツのなりすましが発生するかもしれへん。"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "モデレーションが行き届きにくい不適切なリモートコンテンツとかが、うちのサーバー経由で図らずもインターネットに公開されてまうことによるトラブルを防止できたりするで。"
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受け取ったリモートのコンテンツを含め、サーバー内の全部のコンテンツを何でもかんでもインターネットに公開するのはリスクを伴うねん。特に、分散型の特性を知らん閲覧者にとっては、リモートのコンテンツやったとしてもサーバー内で作られたコンテンツやと誤認してまうかもしれへんから、注意が必要やな。"
|
||||
restartServerSetupWizardConfirm_title: "サーバーの初期設定ウィザードをやり直すん?"
|
||||
restartServerSetupWizardConfirm_text: "現在の一部の設定はリセットされるで。"
|
||||
_accountMigration:
|
||||
moveFrom: "別のアカウントからこのアカウントに引っ越す"
|
||||
moveFromSub: "別のアカウントへエイリアスを作る"
|
||||
|
@ -1802,6 +1869,7 @@ _role:
|
|||
descriptionOfIsExplorable: "オンにしたらロールの面子一覧が「みつける」で公開されるし、ロールのタイムラインが使えるようになるで。"
|
||||
displayOrder: "表示順"
|
||||
descriptionOfDisplayOrder: "数がでかいほど、UI上で先に表示されるで。"
|
||||
preserveAssignmentOnMoveAccount_description: "つけると、このロールがのっかったアカウントが引っ越したときに、引っ越し先アカウントにもこのロールがのっかるようになるで。"
|
||||
canEditMembersByModerator: "モデレーターがメンバーいじるのを許す"
|
||||
descriptionOfCanEditMembersByModerator: "オンにすると、管理者だけやなくてモデレーターもこのロールにユーザーを入れたり抜いたりできるで。オフにすると管理者だけしかやれへんくなるで。"
|
||||
priority: "優先度"
|
||||
|
@ -1842,6 +1910,8 @@ _role:
|
|||
canImportFollowing: "フォローのインポートを許す"
|
||||
canImportMuting: "ミュートのインポートを許す"
|
||||
canImportUserLists: "リストのインポートを許す"
|
||||
uploadableFileTypes_caption: "MIMEタイプを指定してや。改行で区切って複数指定もできるし、アスタリスク(*)でワイルドカード指定もできるで。(例: image/*)"
|
||||
uploadableFileTypes_caption2: "ファイルによっては種別がわからんこともあるで。そないなファイルを許可するんやったら {x} を指定に追加してな。"
|
||||
_condition:
|
||||
roleAssignedTo: "マニュアルロールにアサイン済み"
|
||||
isLocal: "ローカルユーザー"
|
||||
|
@ -2041,7 +2111,7 @@ _theme:
|
|||
navIndicator: "サイドバーのインジケーター"
|
||||
link: "リンク"
|
||||
hashtag: "ハッシュタグ"
|
||||
mention: "メンション"
|
||||
mention: "あんた宛て"
|
||||
mentionMe: "うち宛てのメンション"
|
||||
renote: "Renote"
|
||||
modalBg: "モーダルの背景"
|
||||
|
@ -2310,6 +2380,8 @@ _visibility:
|
|||
disableFederation: "連合なし"
|
||||
disableFederationDescription: "他サーバーへは送らんとくわ"
|
||||
_postForm:
|
||||
quitInspiteOfThereAreUnuploadedFilesConfirm: "アップロードされてへんファイルがあるんやけど、ほかしてフォームを閉じてもええんか?"
|
||||
uploaderTip: "ファイルはまだアップロードされてへんで。ファイルのメニューから、リネームとか画像のクロップ、ウォーターマークをのっける、圧縮するかどうかなんかを設定できるで。ファイルはノートを投稿するときに自動でアップロードされるで。"
|
||||
replyPlaceholder: "このノートに返信..."
|
||||
quotePlaceholder: "このノートを引用..."
|
||||
channelPlaceholder: "チャンネルに投稿..."
|
||||
|
@ -2461,6 +2533,7 @@ _notification:
|
|||
newNote: "さらの投稿"
|
||||
unreadAntennaNote: "アンテナ {name}"
|
||||
roleAssigned: "ロールが付与されたで"
|
||||
chatRoomInvitationReceived: "チャットルームへ招待されたで"
|
||||
emptyPushNotificationMessage: "プッシュ通知の更新をしといたで"
|
||||
achievementEarned: "実績を獲得しとるで"
|
||||
testNotification: "通知テスト"
|
||||
|
@ -2480,7 +2553,7 @@ _notification:
|
|||
all: "すべて"
|
||||
note: "あんたらの新規投稿"
|
||||
follow: "フォロー"
|
||||
mention: "メンション"
|
||||
mention: "あんた宛て"
|
||||
reply: "リプライ"
|
||||
renote: "リノート"
|
||||
quote: "引用"
|
||||
|
@ -2680,6 +2753,10 @@ _dataSaver:
|
|||
_avatar:
|
||||
title: "アイコンの絵"
|
||||
description: "アイコン画像のアニメが止まるで。普通の画像よりもデータ量がでかいから、もっと通信量を節約できるねん。"
|
||||
_urlPreviewThumbnail:
|
||||
description: "URLプレビューのサムネイル画像が読み込まれへんくなるで。"
|
||||
_disableUrlPreview:
|
||||
description: "URLプレビュー機能を切るで。サムネイル画像だけと違って、リンク先の情報の読み込み自体を削減できるで。"
|
||||
_code:
|
||||
title: "コードハイライトは表示せんでええ"
|
||||
description: "MFMとかでコードハイライト記法が使われてるとき、タップするまで読み込まれへんくなるで。コードハイライトではハイライトする言語ごとにその決めてるファイルを読む必要はあんねんな。けどな、それは自動で読み込まれなくなるから、通信量を少なくできることができるねん。"
|
||||
|
@ -2737,6 +2814,7 @@ _offlineScreen:
|
|||
_urlPreviewSetting:
|
||||
title: "URLプレビューの設定"
|
||||
enable: "URLプレビューを有効にする"
|
||||
allowRedirectDescription: "入力されたURLがリダイレクトされるとき、そのリダイレクト先をたどってプレビューを表示するかどうかを設定できるで。無効にするとサーバーリソースを節約できるんやけど、リダイレクト先の内容は表示されへんくなるで。"
|
||||
timeout: "プレビュー取得時のタイムアウト(ms)"
|
||||
timeoutDescription: "プレビュー取得の所要時間がこの値を超えた場合、プレビューは生成されへんで。"
|
||||
maximumContentLength: "Content-Lengthの最大値(byte)"
|
||||
|
@ -2881,8 +2959,57 @@ _search:
|
|||
searchScopeAll: "みんな"
|
||||
searchScopeLocal: "ローカル"
|
||||
searchScopeUser: "ユーザー指定"
|
||||
pleaseEnterServerHost: "サーバーのホストはどないするん?"
|
||||
pleaseSelectUser: "ユーザーを選んでや"
|
||||
_serverSetupWizard:
|
||||
installCompleted: "Misskeyのインストールが終わったで!"
|
||||
firstCreateAccount: "最初は、管理者アカウントを作成しよか。"
|
||||
accountCreated: "管理者アカウントができたで!"
|
||||
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "このウィザードで簡単にええ感じのサーバーの設定ができるで。"
|
||||
settingsYouMakeHereCanBeChangedLater: "ここでの設定は、あとからでも変えられるで。"
|
||||
howWillYouUseMisskey: "Misskeyをどんな感じに使うん?"
|
||||
_use:
|
||||
single_youCanCreateMultipleAccounts: "お一人様サーバーとして運用するとしても、アカウントは必要に応じて複数作れるで。"
|
||||
openServerAdvice: "不特定多数の利用者を受け入れるには相応のリスクがあるで。トラブルに対処できるよう、ちゃんとしたモデレーション体制で運営しいや。"
|
||||
openServerAntiSpamAdvice: "うちのサーバーがスパムの踏み台にならへんように、reCAPTCHAとかのアンチボット機能を使う、みたいなセキュリティ対策もしっかり考えてな。"
|
||||
howManyUsersDoYouExpect: "どれくらいの人数を考えとるん?"
|
||||
largeScaleServerAdvice: "大規模なサーバーやったら、ロードバランシングとかデータベースのレプリケーションみたいな、高度なインフラストラクチャーの知識が必要になるかもしれへんわ。"
|
||||
doYouConnectToFediverse: "Fediverseと接続するんやっけ?"
|
||||
doYouConnectToFediverse_description1: "分散型サーバーでできたネットワーク(Fediverse)に繋げると、他のサーバーと相互にコンテンツのやり取りができるようになるで。"
|
||||
doYouConnectToFediverse_description2: "Fediverseと接続することは「連合」とも呼ばれるな。"
|
||||
youCanConfigureMoreFederationSettingsLater: "連合してもええサーバーの指定とか、高度な設定も後でできるで。"
|
||||
remoteContentsCleaning_description: "連合すると、ぎょうさんコンテンツを受け取り続けることになるねん。自動クリーニングをつけると、参照されてない古いコンテンツを自動でサーバーからほかして、ストレージを節約できるで。"
|
||||
adminInfo_description: "問い合わせを受け付けるのに使う管理者情報を設定しよか。"
|
||||
adminInfo_mustBeFilled: "オープンサーバー、もしくは連合を入れとるんやったら必ず入力せなあかんで。"
|
||||
followingSettingsAreRecommended: "こういう設定がええかもな"
|
||||
settingsCompleted: "設定が終わったで!"
|
||||
settingsCompleted_description: "お疲れさん。準備ができたから、さっそくサーバーを使い始められるで。"
|
||||
settingsCompleted_description2: "細かいサーバー設定は、「コントロールパネル」を見てみてな。"
|
||||
_donationRequest:
|
||||
text1: "Misskeyは有志で開発されとる無料のソフトウェアやで。"
|
||||
text2: "今後も開発を続けられるように、よかったらぜひカンパをお願いするわ。"
|
||||
text3: "支援者向け特典もあるで!"
|
||||
_uploader:
|
||||
abortConfirm: "アップロードされてへんファイルがあるんやけど、やめてもええんか?"
|
||||
doneConfirm: "アップロードされてへんファイルがあるんやけど、完了してもええんか?"
|
||||
maxFileSizeIsX: "アップロードできるファイルサイズは{x}までやで。"
|
||||
tip: "ファイルはまだアップロードされてへんで。このダイアログで、アップロードする前に確認・リネーム・圧縮・クロッピングとかをできるで。準備が出来たら、「アップロード」ボタンを押してアップロードしてな。"
|
||||
_clientPerformanceIssueTip:
|
||||
makeSureDisabledAdBlocker: "アドブロッカーを切ってみてや"
|
||||
makeSureDisabledAdBlocker_description: "アドブロッカーはパフォーマンスに影響があるかもしれへん。OSの機能とかブラウザの機能・アドオンとかでアドブロッカーが有効になってないか確認してや。"
|
||||
makeSureDisabledCustomCss: "カスタムCSSを無効にしてみてや"
|
||||
makeSureDisabledCustomCss_description: "スタイルを上書きするとパフォーマンスに影響があるかもしれへん。カスタムCSSとか、スタイルを上書きする拡張機能が有効になってないか確認してや。"
|
||||
makeSureDisabledAddons: "拡張機能を無効にしてみてや"
|
||||
makeSureDisabledAddons_description: "なんかの拡張機能がクライアントの動作にちょっかいをかけてパフォーマンスに影響を与えてるかもしれへん。ブラウザの拡張機能を無効にして良くなるか確認してや。"
|
||||
_clip:
|
||||
tip: "クリップは、ノートをまとめられる機能やで。"
|
||||
_userLists:
|
||||
tip: "好きなユーザーを含むリストを作れるねん。作ったリストはタイムラインとして表示できるで。"
|
||||
_watermarkEditor:
|
||||
tip: "画像にクレジット情報とかのウォーターマークをのっけられるで。"
|
||||
quitWithoutSaveConfirm: "保存せずに終わってもええんか?"
|
||||
driveFileTypeWarn: "このファイルは対応しとらへん"
|
||||
driveFileTypeWarnDescription: "画像ファイルを選んでや"
|
||||
opacity: "不透明度"
|
||||
scale: "大きさ"
|
||||
text: "テキスト"
|
||||
|
@ -2893,6 +3020,16 @@ _watermarkEditor:
|
|||
angle: "角度"
|
||||
_imageEffector:
|
||||
discardChangesConfirm: "変更をせんで終わるか?"
|
||||
_fxProps:
|
||||
angle: "角度"
|
||||
scale: "大きさ"
|
||||
size: "大きさ"
|
||||
color: "色"
|
||||
opacity: "不透明度"
|
||||
lightness: "明るさ"
|
||||
_drafts:
|
||||
cannotCreateDraftAnymore: "下書きはこれ以上は作れへんな。"
|
||||
cannotCreateDraft: "この内容で下書きは作れへんな。"
|
||||
delete: "下書きをほかす"
|
||||
deleteAreYouSure: "下書きをほかしてもええか?"
|
||||
noDrafts: "下書きはあらへん"
|
||||
|
|
|
@ -44,6 +44,7 @@ showMore: "ಇನ್ನಷ್ಟು ನೋಡು"
|
|||
youGotNewFollower: "ಹಿಂಬಾಲಿಸಿದರು"
|
||||
receiveFollowRequest: "ಹಿಂಬಾಲನೆ ವಿನಂತಿ ಬಂದಿದೆ"
|
||||
followRequestAccepted: "ಹಿಂಬಾಲನೆ ವಿನಂತಿ ಸ್ವೀಕರಿಸಲಾಯಿತು"
|
||||
mention: "ಹೆಸರಿಸಿದ"
|
||||
mentions: "ಹೆಸರಿಸಿದ"
|
||||
directNotes: "ನೇರ ಟಿಪ್ಪಣಿಗಳು"
|
||||
importAndExport: "ಆಮದು/ರಫ್ತು"
|
||||
|
@ -65,6 +66,9 @@ replies: "ಉತ್ತರಿಸು"
|
|||
_email:
|
||||
_follow:
|
||||
title: "ಹಿಂಬಾಲಿಸಿದರು"
|
||||
_theme:
|
||||
keys:
|
||||
mention: "ಹೆಸರಿಸಿದ"
|
||||
_sfx:
|
||||
notification: "ಅಧಿಸೂಚನೆಗಳು"
|
||||
_widgets:
|
||||
|
@ -73,11 +77,14 @@ _widgets:
|
|||
timeline: "ಸಮಯಸಾಲು"
|
||||
_cw:
|
||||
show: "ಇನ್ನಷ್ಟು ನೋಡು"
|
||||
_visibility:
|
||||
specified: "ನೇರ ಟಿಪ್ಪಣಿಗಳು"
|
||||
_profile:
|
||||
username: "ಬಳಕೆಹೆಸರು"
|
||||
_notification:
|
||||
youWereFollowed: "ಹಿಂಬಾಲಿಸಿದರು"
|
||||
_types:
|
||||
mention: "ಹೆಸರಿಸಿದ"
|
||||
login: "ಪ್ರವೇಶ"
|
||||
_actions:
|
||||
reply: "ಉತ್ತರಿಸು"
|
||||
|
@ -86,3 +93,4 @@ _deck:
|
|||
notifications: "ಅಧಿಸೂಚನೆಗಳು"
|
||||
tl: "ಸಮಯಸಾಲು"
|
||||
mentions: "ಹೆಸರಿಸಿದ"
|
||||
direct: "ನೇರ ಟಿಪ್ಪಣಿಗಳು"
|
||||
|
|
|
@ -745,7 +745,7 @@ _menuDisplay:
|
|||
_theme:
|
||||
description: "설멩"
|
||||
keys:
|
||||
mention: "멘션"
|
||||
mention: "받언 멘션"
|
||||
renote: "리노트"
|
||||
_sfx:
|
||||
note: "새 노트"
|
||||
|
@ -775,6 +775,7 @@ _cw:
|
|||
_visibility:
|
||||
home: "덜머리"
|
||||
followers: "팔로워"
|
||||
specified: "쪽지 서기"
|
||||
_postForm:
|
||||
_placeholders:
|
||||
e: "옇다 서 주이소"
|
||||
|
@ -809,7 +810,7 @@ _notification:
|
|||
newNote: "새 걸"
|
||||
_types:
|
||||
follow: "팔로잉"
|
||||
mention: "멘션"
|
||||
mention: "받언 멘션"
|
||||
renote: "리노트"
|
||||
quote: "따오기"
|
||||
reaction: "반엉"
|
||||
|
@ -824,6 +825,7 @@ _deck:
|
|||
antenna: "안테나"
|
||||
list: "리스트"
|
||||
mentions: "받언 멘션"
|
||||
direct: "쪽지 서기"
|
||||
_webhookSettings:
|
||||
name: "이럼"
|
||||
_abuseReport:
|
||||
|
|
|
@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며,
|
|||
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: "오래된 순"
|
||||
|
@ -1461,6 +1466,7 @@ _settings:
|
|||
contentsUpdateFrequency_description2: "실시간 모드가 켜져 있을 때는 이 설정과 상관없이 실시간으로 콘텐츠가 업데이트됩니다."
|
||||
showUrlPreview: "URL 미리보기 표시"
|
||||
showAvailableReactionsFirstInNote: "이용 가능한 리액션을 선두로 표시"
|
||||
showPageTabBarBottom: "페이지의 탭 바를 아래쪽에 표시"
|
||||
_chat:
|
||||
showSenderName: "발신자 이름 표시"
|
||||
sendOnEnter: "엔터로 보내기"
|
||||
|
@ -1634,6 +1640,10 @@ _serverSettings:
|
|||
fanoutTimelineDbFallback: "데이터베이스를 예비로 사용하기"
|
||||
fanoutTimelineDbFallbackDescription: "활성화하면 타임라인의 캐시되어 있지 않은 부분에 대해 DB에 질의하여 정보를 가져옵니다. 비활성화하면 이를 실행하지 않음으로써 서버의 부하를 줄일 수 있지만, 타임라인에서 가져올 수 있는 게시물 범위가 한정됩니다."
|
||||
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
|
||||
remoteNotesCleaning: "리모트 서버 노트 자동 정리 "
|
||||
remoteNotesCleaning_description: "더 이상 사용되지 않는 오래된 리모트 노트를 정기적으로 정리하여, 데이터 베이스의 사용량을 절약할 수 있습니다."
|
||||
remoteNotesCleaningMaxProcessingDuration: "리모트 노트 자동 정리 최대 실행 시간"
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: "리모트 노트 저장 최소 일수"
|
||||
inquiryUrl: "문의처 URL"
|
||||
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
|
||||
openRegistration: "회원 가입을 활성화 하기"
|
||||
|
@ -1652,6 +1662,8 @@ _serverSettings:
|
|||
userGeneratedContentsVisibilityForVisitor: "비이용자에 대한 유저 작성 콘텐츠의 공개 범위"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "조정을 하기 힘든 부적절한 리모트 콘텐츠 등이 자신의 서버 경유로 의도치 않게 인터넷에 공개되는 문제의 방지 등에 도움을 줍니다."
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "서버에서 받은 리모트 콘텐츠를 포함해 서버 내의 모든 콘텐츠를 무조건 인터넷에 공개하는 것에는 위험이 따릅니다. 특히, 분산형 특성에 대해 모르는 열람자에게는 리모트 콘텐츠여도 서버 내에서 작성된 콘텐츠라고 잘못 인식할 수 있기에 주의가 필요합니다."
|
||||
restartServerSetupWizardConfirm_title: "서버의 초기 설정 위자드를 재시도하시겠습니까?"
|
||||
restartServerSetupWizardConfirm_text: "현재 일부 설정은 리셋됩니다."
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "모두 공개"
|
||||
localOnly: "로컬 콘텐츠만 공개하고 리모트 콘텐츠는 비공개"
|
||||
|
@ -1988,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다."
|
||||
canHideAds: "광고 숨기기"
|
||||
canSearchNotes: "노트 검색 이용 가능 여부"
|
||||
canSearchUsers: "유저 검색 이용"
|
||||
canUseTranslator: "번역 기능의 사용"
|
||||
avatarDecorationLimit: "아바타 장식의 최대 붙임 개수"
|
||||
canImportAntennas: "안테나 가져오기 허용"
|
||||
|
@ -3062,6 +3075,7 @@ _bootErrors:
|
|||
otherOption1: "클라이언트 설정 및 캐시 삭제"
|
||||
otherOption2: "간편 클라이언트 실행"
|
||||
otherOption3: "복구 툴 실행"
|
||||
otherOption4: "Misskey를 세이프 모드로 열기"
|
||||
_search:
|
||||
searchScopeAll: "전체"
|
||||
searchScopeLocal: "로컬"
|
||||
|
@ -3098,6 +3112,8 @@ _serverSetupWizard:
|
|||
doYouConnectToFediverse_description1: "분산형 서버로 구성된 네트워크(Fediverse)에 접속하면 다른 서버와 서로 콘텐츠의 주고받기를 할 수 있습니다."
|
||||
doYouConnectToFediverse_description2: "Fediverse에 접속하는 것을 '연합'이라고도 부릅니다."
|
||||
youCanConfigureMoreFederationSettingsLater: "나중에 연합 가능한 서버의 지정 등 고급 설정을 할 수 있습니다."
|
||||
remoteContentsCleaning: "리모트 콘텐츠 자동 정리"
|
||||
remoteContentsCleaning_description: "연합 중인 서버가 있는 경우, 리모트 서버에서 대단히 많은 콘텐츠를 받아오게 됩니다. 자동 정리 기능을 활성화하면, 오래되고 서버에서 더 이상 조회되지 않는 콘텐츠를 자동으로 서버에서 삭제하여, 스토리지를 절약할 수 있습니다."
|
||||
adminInfo: "관리자 정보"
|
||||
adminInfo_description: "문의 접수를 위해 사용되는 관리자 정보를 설정합니다."
|
||||
adminInfo_mustBeFilled: "오픈 서버 혹은 연합이 켜져 있는 경우 반드시 입력해야 합니다."
|
||||
|
@ -3150,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "종류"
|
||||
image: "이미지"
|
||||
advanced: "고급"
|
||||
angle: "각도"
|
||||
stripe: "줄무늬"
|
||||
stripeWidth: "라인의 폭"
|
||||
stripeFrequency: "라인의 수"
|
||||
angle: "각도"
|
||||
polkadot: "물방울 무늬"
|
||||
checker: "체크 무늬"
|
||||
polkadotMainDotOpacity: "주요 물방울의 불투명도"
|
||||
|
@ -3165,6 +3181,7 @@ _imageEffector:
|
|||
title: "이펙트"
|
||||
addEffect: "이펙트를 추가"
|
||||
discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?"
|
||||
nothingToConfigure: "설정 항목이 없습니다."
|
||||
_fxs:
|
||||
chromaticAberration: "색수차"
|
||||
glitch: "글리치"
|
||||
|
@ -3182,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: "초안 선택"
|
||||
|
|
|
@ -433,6 +433,7 @@ _cw:
|
|||
_visibility:
|
||||
home: "ໜ້າຫຼັກ"
|
||||
followers: "ຜູ້ຕິດຕາມ"
|
||||
specified: "ໂພສ Direct note"
|
||||
_profile:
|
||||
name: "ຊື່"
|
||||
username: "ຊື່ຜູ້ໃຊ້"
|
||||
|
@ -470,6 +471,7 @@ _deck:
|
|||
list: "ລາຍການ"
|
||||
channel: "ຊ່ອງ"
|
||||
mentions: "ກ່າວເຖິງເຈົ້າ"
|
||||
direct: "ໂພສ Direct note"
|
||||
_webhookSettings:
|
||||
name: "ຊື່"
|
||||
_abuseReport:
|
||||
|
|
|
@ -1019,6 +1019,7 @@ _cw:
|
|||
_visibility:
|
||||
home: "Startpagina"
|
||||
followers: "Volgers"
|
||||
specified: "Directe notities"
|
||||
_profile:
|
||||
name: "Naam"
|
||||
username: "Gebruikersnaam"
|
||||
|
@ -1061,6 +1062,7 @@ _deck:
|
|||
list: "Lijsten"
|
||||
channel: "Kanalen"
|
||||
mentions: "Vermeldingen"
|
||||
direct: "Directe notities"
|
||||
_webhookSettings:
|
||||
name: "Naam"
|
||||
active: "Ingeschakeld"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1302,6 +1302,7 @@ _cw:
|
|||
_visibility:
|
||||
home: "Acasă"
|
||||
followers: "Urmăritori"
|
||||
specified: "Note directe"
|
||||
_postForm:
|
||||
replyPlaceholder: "Răspunde la această notă..."
|
||||
quotePlaceholder: "Citează aceasta nota..."
|
||||
|
@ -1356,6 +1357,7 @@ _deck:
|
|||
list: "Liste"
|
||||
channel: "Canale"
|
||||
mentions: "Mențiuni"
|
||||
direct: "Note directe"
|
||||
roleTimeline: "Cronologia rolului"
|
||||
_webhookSettings:
|
||||
name: "Nume"
|
||||
|
@ -1398,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ť"
|
||||
|
|
|
@ -646,6 +646,7 @@ _poll:
|
|||
_visibility:
|
||||
home: "Hem"
|
||||
followers: "Följare"
|
||||
specified: "Direktnoter"
|
||||
_profile:
|
||||
name: "Namn"
|
||||
username: "Användarnamn"
|
||||
|
@ -692,6 +693,7 @@ _deck:
|
|||
list: "Listor"
|
||||
channel: "kanal"
|
||||
mentions: "Omnämningar"
|
||||
direct: "Direktnoter"
|
||||
_webhookSettings:
|
||||
name: "Namn"
|
||||
active: "Aktiverad"
|
||||
|
@ -714,3 +716,8 @@ _search:
|
|||
_watermarkEditor:
|
||||
scale: "Storlek"
|
||||
image: "Bilder"
|
||||
_imageEffector:
|
||||
_fxProps:
|
||||
scale: "Storlek"
|
||||
size: "Storlek"
|
||||
color: "Färg"
|
||||
|
|
|
@ -776,7 +776,7 @@ highlightSensitiveMedia: "ไฮไลท์สื่อที่มีเนื
|
|||
verificationEmailSent: "ได้ส่งอีเมลยืนยันแล้ว กรุณาเข้าลิงก์ที่ระบุในอีเมลเพื่อทำการตั้งค่าให้เสร็จสิ้น"
|
||||
notSet: "ไม่ได้ตั้งค่า"
|
||||
emailVerified: "อีเมลได้รับการยืนยันแล้ว"
|
||||
noteFavoritesCount: "จำนวนโน้ตที่ชื่นชอบ"
|
||||
noteFavoritesCount: "จำนวนโน้ตโปรด"
|
||||
pageLikesCount: "จำนวนเพจที่ถูกใจ"
|
||||
pageLikedCount: "จำนวนการกดถูกใจเพจที่ได้รับแล้ว"
|
||||
contact: "ติดต่อ"
|
||||
|
@ -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: "เรียงจากเก่าไปใหม่"
|
||||
|
@ -1433,7 +1438,7 @@ _settings:
|
|||
api: "API"
|
||||
webhook: "Webhook"
|
||||
serviceConnection: "การเชื่อมต่อกับบริการ"
|
||||
serviceConnectionBanner: "สามารถจัดการและตั้งค่า Access Token และ Webhook เพื่อเชื่อมต่อกับแอปหรือบริการภายนอกได้"
|
||||
serviceConnectionBanner: "สามารถจัดการและตั้งค่าโทเค็นการเข้าถึงและ Webhook เพื่อเชื่อมต่อกับแอปหรือบริการภายนอกได้"
|
||||
accountData: "ข้อมูลบัญชี"
|
||||
accountDataBanner: "สามารถจัดการข้อมูลบัญชีได้โดยส่งออกหรือนำเข้าไฟล์เก็บถาวร"
|
||||
muteAndBlockBanner: "สามารถตั้งค่าการซ่อนเนื้อหา และจำกัดการกระทำจากผู้ใช้เฉพาะรายได้"
|
||||
|
@ -1461,6 +1466,7 @@ _settings:
|
|||
contentsUpdateFrequency_description2: "เมื่อโหมดเรียลไทม์เปิดอยู่ เนื้อหาจะอัปเดตแบบเรียลไทม์โดยไม่ขึ้นกับการตั้งค่านี้"
|
||||
showUrlPreview: "แสดงตัวอย่าง URL"
|
||||
showAvailableReactionsFirstInNote: "แสดงรีแอคชั่นที่ใช้ได้ไว้หน้าสุด"
|
||||
showPageTabBarBottom: "แสดงแท็บบาร์ของเพจที่ด้านล่าง"
|
||||
_chat:
|
||||
showSenderName: "แสดงชื่อผู้ส่ง"
|
||||
sendOnEnter: "กด Enter เพื่อส่ง"
|
||||
|
@ -1634,6 +1640,10 @@ _serverSettings:
|
|||
fanoutTimelineDbFallback: "ฟอลแบ๊กกลับฐานข้อมูล"
|
||||
fanoutTimelineDbFallbackDescription: "เมื่อเปิดใช้งาน หากไม่ได้แคชไทม์ไลน์ ไทม์ไลน์จะฟอลแบ๊กไปยังฐานข้อมูลสำหรับการ query เพิ่มเติม การปิดใช้งานจะช่วยลดภาระของเซิร์ฟเวอร์ด้วยการกำจัดกระบวนฟอลแบ๊ก แต่มันก็จะจำกัดช่วงเวลาไทม์ไลน์ที่สามารถดึงข้อมูลได้"
|
||||
reactionsBufferingDescription: "เมื่อเปิดใช้งานฟังก์ชันนี้ก็จะช่วยลด latency ในการสร้างปฏิกิริยา แต่อาจจะส่งผลให้ memory footprint ของ Redis เพิ่มขึ้นนะ"
|
||||
remoteNotesCleaning: "การล้างข้อมูลโพสต์จากระยะไกลโดยอัตโนมัติ"
|
||||
remoteNotesCleaning_description: "เมื่อเปิดใช้งาน จะทำการล้างโพสต์จากระยะไกลเก่าที่ไม่ถูกอ้างอิง เป็นระยะ เพื่อลดการขยายตัวของฐานข้อมูล"
|
||||
remoteNotesCleaningMaxProcessingDuration: "ระยะเวลาสูงสุดของการประมวลผลการล้างข้อมูล"
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: "จำนวนวันที่ต้องเก็บโน้ตไว้อย่างน้อย"
|
||||
inquiryUrl: "URL สำหรับการติดต่อสอบถาม"
|
||||
inquiryUrlDescription: "ระบุ URL ของหน้าเว็บที่มีแบบฟอร์มสำหรับติดต่อผู้ดูแลเซิร์ฟเวอร์ หรือข้อมูลการติดต่อของผู้ดูแลเซิร์ฟเวอร์"
|
||||
openRegistration: "เปิดให้สร้างบัญชีได้"
|
||||
|
@ -1652,6 +1662,8 @@ _serverSettings:
|
|||
userGeneratedContentsVisibilityForVisitor: "ขอบเขตการเปิดเผยเนื้อหาที่ผู้ใช้สร้างต่อบุคคลที่ไม่ได้เข้าร่วม (แขก)"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "ช่วยป้องกันปัญหาที่อาจเกิดขึ้นจากเนื้อหาระยะไกลที่ไม่เหมาะสม ซึ่งอาจถูกเผยแพร่ออกสู่อินเทอร์เน็ตโดยไม่ตั้งใจผ่านเซิร์ฟเวอร์ของตนเอง โดยเฉพาะในกรณีที่การดูแลควบคุมไม่ทั่วถึง"
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "การเปิดเผยเนื้อหาทั้งหมดในเซิร์ฟเวอร์รวมทั้งเนื้อหาที่รับมาจากระยะไกลสู่สาธารณะบนอินเทอร์เน็ตโดยไม่มีข้อจำกัดใดๆ มีความเสี่ยงโดยเฉพาะอย่างยิ่งสำหรับผู้ชมที่ไม่เข้าใจลักษณะของระบบแบบกระจาย อาจทำให้เกิดความเข้าใจผิดคิดว่าเนื้อหาที่มาจากระยะไกลนั้นเป็นเนื้อหาที่สร้างขึ้นภายในเซิร์ฟเวอร์นี้ จึงควรใช้ความระมัดระวังอย่างมาก"
|
||||
restartServerSetupWizardConfirm_title: "ต้องการเริ่มวิซาร์ดการตั้งค่าเซิร์ฟเวอร์ใหม่หรือไม่?"
|
||||
restartServerSetupWizardConfirm_text: "การตั้งค่าบางส่วนในปัจจุบันจะถูกรีเซ็ต"
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "ทั้งหมดสาธารณะ"
|
||||
localOnly: "เผยแพร่เป็นสาธารณะเฉพาะเนื้อหาท้องถิ่น เนื้อหาระยะไกลให้เป็นส่วนตัว"
|
||||
|
@ -1988,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "ยิ่งตัวเลขน้อยก็ยิ่งจำกัดน้อย ยิ่งมากก็ยิ่งเข้มงวดมากขึ้น"
|
||||
canHideAds: "ซ่อนโฆษณา"
|
||||
canSearchNotes: "การใช้การค้นหาโน้ต"
|
||||
canSearchUsers: "ค้นหาผู้ใช้"
|
||||
canUseTranslator: "การใช้งานแปล"
|
||||
avatarDecorationLimit: "จำนวนของตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
|
||||
canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ"
|
||||
|
@ -3062,6 +3075,7 @@ _bootErrors:
|
|||
otherOption1: "ลบการตั้งค่าและแคชของไคลเอนต์"
|
||||
otherOption2: "เริ่มใช้งานไคลเอนต์แบบง่าย"
|
||||
otherOption3: "เปิดเครื่องมือซ่อมแซม"
|
||||
otherOption4: "เริ่มทำงาน Misskey ในโหมดปลอดภัย"
|
||||
_search:
|
||||
searchScopeAll: "ทั้งหมด"
|
||||
searchScopeLocal: "ท้องถิ่น"
|
||||
|
@ -3098,6 +3112,8 @@ _serverSetupWizard:
|
|||
doYouConnectToFediverse_description1: "หากเชื่อมต่อกับเครือข่ายที่ประกอบด้วยเซิร์ฟเวอร์แบบกระจาย (Fediverse) จะสามารถแลกเปลี่ยนเนื้อหากับเซิร์ฟเวอร์อื่นๆ ได้"
|
||||
doYouConnectToFediverse_description2: "การเชื่อมต่อกับ Fediverse เรียกว่า “สหพันธ์”"
|
||||
youCanConfigureMoreFederationSettingsLater: "หลังจากนี้ยังสามารถตั้งค่าแบบขั้นสูง เช่น การกำหนดเซิร์ฟเวอร์ที่อนุญาตให้สหพันธ์ต่อกันได้เพิ่มเติม"
|
||||
remoteContentsCleaning: "การล้างข้อมูลเนื้อหาที่ได้รับโดยอัตโนมัติ"
|
||||
remoteContentsCleaning_description: "เมื่อมีการเชื่อมโยงสหพันธ์ จะได้รับเนื้อหาเป็นจำนวนมากอย่างต่อเนื่อง เมื่อเปิดใช้งานการล้างข้อมูลอัตโนมัติ จะทำการลบเนื้อหาเก่าที่ไม่ถูกอ้างอิง ไปจากเซิร์ฟเวอร์โดยอัตโนมัติ เพื่อประหยัดพื้นที่จัดเก็บข้อมูล"
|
||||
adminInfo: "ข้อมูลผู้ดูแลระบ"
|
||||
adminInfo_description: "ตั้งค่าข้อมูลผู้ดูแลระบบที่จะใช้รับคำถามและติดต่อ"
|
||||
adminInfo_mustBeFilled: "หากเปิดใช้เซิร์ฟเวอร์สาธารณะ หรือเปิดใช้งานสหพันธ์ จะต้องกรอกข้อมูลนี้"
|
||||
|
@ -3150,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "รูปแบบ"
|
||||
image: "รูปภาพ"
|
||||
advanced: "ขั้นสูง"
|
||||
angle: "แองเกิล"
|
||||
stripe: "ริ้ว"
|
||||
stripeWidth: "ความกว้างเส้น"
|
||||
stripeFrequency: "จำนวนเส้น"
|
||||
angle: "แองเกิล"
|
||||
polkadot: "ลายจุด"
|
||||
checker: "ช่องตาราง"
|
||||
polkadotMainDotOpacity: "ความทึบของจุดหลัก"
|
||||
|
@ -3165,6 +3181,7 @@ _imageEffector:
|
|||
title: "เอฟเฟกต์"
|
||||
addEffect: "เพิ่มเอฟเฟกต์"
|
||||
discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?"
|
||||
nothingToConfigure: "ไม่มีอะไรให้ตั้งค่า"
|
||||
_fxs:
|
||||
chromaticAberration: "ความคลาดสี"
|
||||
glitch: "กลิตช์"
|
||||
|
@ -3182,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: "เลือกฉบับร่าง"
|
||||
|
|
3302
locales/tr-TR.yml
3302
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: "Яскравість"
|
||||
|
|
|
@ -903,7 +903,7 @@ _theme:
|
|||
header: "Sarlavha"
|
||||
navBg: "Yon panel foni"
|
||||
navFg: "Yon panel matni"
|
||||
mention: "Murojat"
|
||||
mention: "Eslatib o'tish"
|
||||
renote: "Qayta qayd etish"
|
||||
divider: "Ajratrmoq"
|
||||
fgHighlighted: "Belgilangan matn"
|
||||
|
@ -1045,7 +1045,7 @@ _notification:
|
|||
_types:
|
||||
all: "Barchasi"
|
||||
follow: "Obuna bo‘lish"
|
||||
mention: "Murojat"
|
||||
mention: "Eslatib o'tish"
|
||||
renote: "Qayta qayd etish"
|
||||
quote: "Iqtibos keltirish"
|
||||
reaction: "Reaktsiyalar"
|
||||
|
@ -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: "我的便签"
|
||||
|
@ -1143,7 +1144,7 @@ channelArchiveConfirmTitle: "要将 {name} 归档吗?"
|
|||
channelArchiveConfirmDescription: "归档后,在频道列表与搜索结果中不会显示,也无法发布新的贴文。"
|
||||
thisChannelArchived: "该频道已被归档。"
|
||||
displayOfNote: "显示帖子"
|
||||
initialAccountSetting: "初始设置"
|
||||
initialAccountSetting: "初始设定"
|
||||
youFollowing: "正在关注"
|
||||
preventAiLearning: "拒绝接受生成式 AI 的学习"
|
||||
preventAiLearningDescription: "要求文章生成 AI 或图像生成 AI 不能够以发布的帖子和图像等内容作为学习对象。这是通过在 HTML 响应中包含 noai 标志来实现的,这不能完全阻止 AI 学习你的发布内容,并不是所有 AI 都会遵守这类请求。"
|
||||
|
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "默认图像压缩等级"
|
|||
defaultImageCompressionLevel_description: "较低的等级可以保持画质,但会增加文件大小。<br>较高的等级可以减少文件大小,但相对应的画质将会降低。"
|
||||
inMinutes: "分"
|
||||
inDays: "日"
|
||||
safeModeEnabled: "已启用安全模式"
|
||||
pluginsAreDisabledBecauseSafeMode: "因启用了安全模式,所有插件均已被禁用。"
|
||||
customCssIsDisabledBecauseSafeMode: "因启用了安全模式,无法应用自定义 CSS。"
|
||||
themeIsDefaultBecauseSafeMode: "启用安全模式时将使用默认主题。关闭安全模式后将还原。"
|
||||
_order:
|
||||
newest: "从新到旧"
|
||||
oldest: "从旧到新"
|
||||
|
@ -1461,6 +1466,7 @@ _settings:
|
|||
contentsUpdateFrequency_description2: "当实时模式开启时,无论此设置如何,内容都会实时更新。"
|
||||
showUrlPreview: "显示 URL 预览"
|
||||
showAvailableReactionsFirstInNote: "在顶部显示可用的回应"
|
||||
showPageTabBarBottom: "在下方显示页面标签栏"
|
||||
_chat:
|
||||
showSenderName: "显示发送者的名字"
|
||||
sendOnEnter: "回车键发送"
|
||||
|
@ -1538,7 +1544,7 @@ _announcement:
|
|||
silenceDescription: "开启后,此条公告将不会发送通知,也不强制用户阅读。"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "账户创建完成了!"
|
||||
letsStartAccountSetup: "来进行帐户的初始设置吧。"
|
||||
letsStartAccountSetup: "马上来进行账户的初始设定吧。"
|
||||
letsFillYourProfile: "首先,来设定你的个人档案吧!"
|
||||
profileSetting: "个人资料设置"
|
||||
privacySetting: "隐私设置"
|
||||
|
@ -1550,7 +1556,7 @@ _initialAccountSetting:
|
|||
haveFun: "希望 {name} 在这里玩得开心!"
|
||||
youCanContinueTutorial: "您可以继续了解 {name}(Misskey) 的使用教程,也可以在此停止教程并立即开始使用它。\n"
|
||||
startTutorial: "开始教学"
|
||||
skipAreYouSure: "要跳过初始设置吗?"
|
||||
skipAreYouSure: "要跳过初始设定吗?"
|
||||
laterAreYouSure: "要稍后再进行初始设定吗?"
|
||||
_initialTutorial:
|
||||
launchTutorial: "观看教学"
|
||||
|
@ -1634,6 +1640,10 @@ _serverSettings:
|
|||
fanoutTimelineDbFallback: "回退到数据库"
|
||||
fanoutTimelineDbFallbackDescription: "当启用时,若时间线未被缓存,则将额外查询数据库。禁用该功能可通过不执行回退处理进一步减少服务器负载,但会限制可检索的时间线范围。"
|
||||
reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。"
|
||||
remoteNotesCleaning: "自动清理远程投稿"
|
||||
remoteNotesCleaning_description: "启用后,将自动清理已无法找到的旧的远程投稿,可减缓数据库的增长。"
|
||||
remoteNotesCleaningMaxProcessingDuration: "最长清理持续时间"
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: "最短帖子保留期限"
|
||||
inquiryUrl: "联络地址"
|
||||
inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
|
||||
openRegistration: "开放注册"
|
||||
|
@ -1652,6 +1662,8 @@ _serverSettings:
|
|||
userGeneratedContentsVisibilityForVisitor: "用户生成内容对非用户的可见性"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "对于防止难以审核的不适当的远程内容等,通过自己的服务器无意中在互联网上公开等问题很有用。"
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "包含服务器接收到的远程内容在内,无条件将服务器上的所有内容公开在互联网上存在风险。特别是对去中心化的特性不是很了解的访问者有可能将远程服务器上的内容误认为是在此服务器内生成的,需要特别留意。"
|
||||
restartServerSetupWizardConfirm_title: "要重新开始服务器初始设定向导吗?"
|
||||
restartServerSetupWizardConfirm_text: "现有的部分设定将重置。"
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "全部公开"
|
||||
localOnly: "仅公开本地内容,隐藏远程内容"
|
||||
|
@ -1988,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
|
||||
canHideAds: "可以隐藏广告"
|
||||
canSearchNotes: "是否可以搜索帖子"
|
||||
canSearchUsers: "使用用户检索"
|
||||
canUseTranslator: "使用翻译功能"
|
||||
avatarDecorationLimit: "可添加头像挂件的最大个数"
|
||||
canImportAntennas: "允许导入天线"
|
||||
|
@ -3062,6 +3075,7 @@ _bootErrors:
|
|||
otherOption1: "清除客户端设定与缓存"
|
||||
otherOption2: "使用简易客户端"
|
||||
otherOption3: "启动修复工具"
|
||||
otherOption4: "以安全模式启动 Misskey"
|
||||
_search:
|
||||
searchScopeAll: "全部"
|
||||
searchScopeLocal: "本地"
|
||||
|
@ -3098,6 +3112,8 @@ _serverSetupWizard:
|
|||
doYouConnectToFediverse_description1: "若加入由分散性服务器所构成的网络(Fediverse),将能与其它服务器交换内容。"
|
||||
doYouConnectToFediverse_description2: "加入 Fediverse 在这里被称为「联合」。"
|
||||
youCanConfigureMoreFederationSettingsLater: "可在之后进行如哪些服务器可以进行联合等高级设置。"
|
||||
remoteContentsCleaning: "自动清理传入内容"
|
||||
remoteContentsCleaning_description: "加入联合后,服务器将持续接收大量内容。打开自动清理后,将自动删除无法找到的旧内容,可节省存储空间。"
|
||||
adminInfo: "管理员信息"
|
||||
adminInfo_description: "设置用于接受询问的管理员信息。"
|
||||
adminInfo_mustBeFilled: "开放服务器或开启了联合的情况下必须输入。"
|
||||
|
@ -3150,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "类型"
|
||||
image: "图片"
|
||||
advanced: "高级"
|
||||
angle: "角度"
|
||||
stripe: "条纹"
|
||||
stripeWidth: "线条宽度"
|
||||
stripeFrequency: "线条数量"
|
||||
angle: "角度"
|
||||
polkadot: "波点"
|
||||
checker: "检查"
|
||||
polkadotMainDotOpacity: "主波点的不透明度"
|
||||
|
@ -3165,6 +3181,7 @@ _imageEffector:
|
|||
title: "效果"
|
||||
addEffect: "添加效果"
|
||||
discardChangesConfirm: "丢弃当前设置并退出?"
|
||||
nothingToConfigure: "还没有设置"
|
||||
_fxs:
|
||||
chromaticAberration: "色差"
|
||||
glitch: "故障"
|
||||
|
@ -3182,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: "我的摘錄"
|
||||
|
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "預設的影像壓縮程度"
|
|||
defaultImageCompressionLevel_description: "低的話可以保留畫質,但是會增加檔案的大小。<br>高的話可以減少檔案大小,但是會降低畫質。"
|
||||
inMinutes: "分鐘"
|
||||
inDays: "日"
|
||||
safeModeEnabled: "啟用安全模式"
|
||||
pluginsAreDisabledBecauseSafeMode: "由於啟用安全模式,所有的外掛都被停用。"
|
||||
customCssIsDisabledBecauseSafeMode: "由於啟用安全模式,所有的客製 CSS 都被停用。"
|
||||
themeIsDefaultBecauseSafeMode: "在安全模式啟用期間將使用預設主題。關閉安全模式後會恢復原本的設定。"
|
||||
_order:
|
||||
newest: "最新的在前"
|
||||
oldest: "最舊的在前"
|
||||
|
@ -1461,6 +1466,7 @@ _settings:
|
|||
contentsUpdateFrequency_description2: "當即時模式開啟時,不論此設定為何,內容都會即時更新。"
|
||||
showUrlPreview: "顯示網址預覽"
|
||||
showAvailableReactionsFirstInNote: "將可用的反應顯示在頂部"
|
||||
showPageTabBarBottom: "在底部顯示頁面的標籤列"
|
||||
_chat:
|
||||
showSenderName: "顯示發送者的名稱"
|
||||
sendOnEnter: "按下 Enter 發送訊息"
|
||||
|
@ -1545,7 +1551,7 @@ _initialAccountSetting:
|
|||
theseSettingsCanEditLater: "這裡的設定可以在之後變更。"
|
||||
youCanEditMoreSettingsInSettingsPageLater: "除此之外,還可以在「設定」頁面進行各種設定。之後請確認看看。"
|
||||
followUsers: "為了構築時間軸,試著追隨您感興趣的使用者吧。"
|
||||
pushNotificationDescription: "啟用推送通知後,就可以在裝置上接收來自{name}的通知了。"
|
||||
pushNotificationDescription: "啟用推送通知後,就可以在裝置上接收來自 {name} 的通知了。"
|
||||
initialAccountSettingCompleted: "初始設定完成了!"
|
||||
haveFun: "盡情享受{name}吧!"
|
||||
youCanContinueTutorial: "您可以繼續學習如何使用{name}(Misskey),也可以就此打住,立即開始使用。"
|
||||
|
@ -1634,6 +1640,10 @@ _serverSettings:
|
|||
fanoutTimelineDbFallback: "資料庫的回退"
|
||||
fanoutTimelineDbFallbackDescription: "若啟用,在時間軸沒有快取的情況下將執行回退處理以額外查詢資料庫。若停用,可以透過不執行回退處理來進一步減少伺服器的負荷,但會限制可取得的時間軸範圍。"
|
||||
reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。"
|
||||
remoteNotesCleaning: "自動清除遠端發佈內容"
|
||||
remoteNotesCleaning_description: "啟用後,系統會定期清理未被參照的舊遠端貼文,以抑制資料庫的膨脹。"
|
||||
remoteNotesCleaningMaxProcessingDuration: "清理作業的最長持續時間"
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: "貼文最短保留天數"
|
||||
inquiryUrl: "聯絡表單網址"
|
||||
inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。"
|
||||
openRegistration: "允許建立帳戶"
|
||||
|
@ -1652,6 +1662,8 @@ _serverSettings:
|
|||
userGeneratedContentsVisibilityForVisitor: "使用者建立的內容對訪客的公開範圍"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "這有助於防止一些問題的發生,例如未經適當審核的不適當遠端內容無意中透過您自己的伺服器發佈到網際網路上。"
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "包括伺服器接收到的遠端內容在內,無條件地將伺服器內所有內容公開到網際網路上是具有風險的。特別是對於不了解分散式架構特性的瀏覽者來說,他們可能會誤以為這些遠端內容是由該伺服器所創建的,因此需要特別留意。"
|
||||
restartServerSetupWizardConfirm_title: "要重新執行伺服器的初始設定精靈嗎?"
|
||||
restartServerSetupWizardConfirm_text: "當前的部分設定將會被重設。"
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "全部公開\n"
|
||||
localOnly: "僅公開本地內容,遠端內容則不公開\n"
|
||||
|
@ -1988,6 +2000,7 @@ _role:
|
|||
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
|
||||
canHideAds: "不顯示廣告"
|
||||
canSearchNotes: "可否搜尋貼文"
|
||||
canSearchUsers: "可使用使用者搜尋功能"
|
||||
canUseTranslator: "使用翻譯功能"
|
||||
avatarDecorationLimit: "頭像可掛上的最大裝飾數量"
|
||||
canImportAntennas: "允許匯入天線"
|
||||
|
@ -3062,6 +3075,7 @@ _bootErrors:
|
|||
otherOption1: "刪除用戶端設定和快取"
|
||||
otherOption2: "啟動簡易用戶端"
|
||||
otherOption3: "啟動修復工具"
|
||||
otherOption4: "以安全模式啟動 Misskey"
|
||||
_search:
|
||||
searchScopeAll: "全部"
|
||||
searchScopeLocal: "本地"
|
||||
|
@ -3098,6 +3112,8 @@ _serverSetupWizard:
|
|||
doYouConnectToFediverse_description1: "連接到由分散型伺服器構成的網絡(聯邦宇宙)後,您可以與其他伺服器進行內容的互相交流。\n"
|
||||
doYouConnectToFediverse_description2: "連接到聯邦宇宙被稱為「聯邦」。\n"
|
||||
youCanConfigureMoreFederationSettingsLater: "您可以在稍後進行更高級的設定,例如指定可以聯繫的伺服器等。\n"
|
||||
remoteContentsCleaning: "自動清理接收的內容"
|
||||
remoteContentsCleaning_description: "進行聯邦後,會持續接收大量內容。啟用自動清理功能後,系統會自動從伺服器中刪除未被參照的過時內容,以節省儲存空間。"
|
||||
adminInfo: "管理員資訊"
|
||||
adminInfo_description: "設定用於接收查詢的管理者資訊。\n"
|
||||
adminInfo_mustBeFilled: "當設置為開放伺服器或啟用聯邦時,必須填寫此資訊。\n"
|
||||
|
@ -3150,10 +3166,10 @@ _watermarkEditor:
|
|||
type: "類型"
|
||||
image: "圖片"
|
||||
advanced: "進階"
|
||||
angle: "角度"
|
||||
stripe: "條紋"
|
||||
stripeWidth: "線條寬度"
|
||||
stripeFrequency: "線條數量"
|
||||
angle: "角度"
|
||||
polkadot: "波卡圓點"
|
||||
checker: "棋盤格"
|
||||
polkadotMainDotOpacity: "主圓點的不透明度"
|
||||
|
@ -3165,6 +3181,7 @@ _imageEffector:
|
|||
title: "特效"
|
||||
addEffect: "新增特效"
|
||||
discardChangesConfirm: "捨棄更改並退出嗎?"
|
||||
nothingToConfigure: "無可設定的項目"
|
||||
_fxs:
|
||||
chromaticAberration: "色差"
|
||||
glitch: "異常雜訊效果"
|
||||
|
@ -3182,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: "選擇草槁"
|
||||
|
|
22
package.json
22
package.json
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.8.0-alpha.2",
|
||||
"version": "2025.8.0-alpha.12",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"packageManager": "pnpm@10.14.0",
|
||||
"workspaces": [
|
||||
"packages/frontend-shared",
|
||||
"packages/frontend",
|
||||
|
@ -53,7 +53,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"cssnano": "7.1.0",
|
||||
"esbuild": "0.25.6",
|
||||
"esbuild": "0.25.8",
|
||||
"execa": "9.6.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"glob": "11.0.3",
|
||||
|
@ -62,20 +62,20 @@
|
|||
"postcss": "8.5.6",
|
||||
"tar": "7.4.3",
|
||||
"terser": "5.43.1",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "2.1.0",
|
||||
"@types/node": "22.16.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.37.0",
|
||||
"@typescript-eslint/parser": "8.37.0",
|
||||
"@types/node": "22.17.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.39.0",
|
||||
"@typescript-eslint/parser": "8.39.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "14.5.2",
|
||||
"eslint": "9.31.0",
|
||||
"cypress": "14.5.4",
|
||||
"eslint": "9.33.0",
|
||||
"globals": "16.3.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "10.13.1",
|
||||
"start-server-and-test": "2.0.12"
|
||||
"pnpm": "10.14.0",
|
||||
"start-server-and-test": "2.0.13"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs-core": "4.22.0"
|
||||
|
|
|
@ -7,7 +7,7 @@ export class RemoteNotesCleaning1753863104203 {
|
|||
name = 'RemoteNotesCleaning1753863104203'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableRemoteNotesCleaning" boolean NOT NULL DEFAULT true`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableRemoteNotesCleaning" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query('ALTER TABLE "meta" ADD "remoteNotesCleaningMaxProcessingDurationInMinutes" integer NOT NULL DEFAULT \'60\'');
|
||||
await queryRunner.query('ALTER TABLE "meta" ADD "remoteNotesCleaningExpiryDaysForEachNotes" integer NOT NULL DEFAULT \'90\'');
|
||||
}
|
||||
|
|
|
@ -8,11 +8,9 @@ export class TweakDefaultFederationSettings1754019326356 {
|
|||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "federation" SET DEFAULT 'none'`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "enableRemoteNotesCleaning" SET DEFAULT false`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "enableRemoteNotesCleaning" SET DEFAULT true`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "federation" SET DEFAULT 'all'`);
|
||||
}
|
||||
}
|
||||
|
|
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,9 +68,9 @@
|
|||
"utf-8-validate": "6.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.826.0",
|
||||
"@aws-sdk/lib-storage": "3.826.0",
|
||||
"@discordapp/twemoji": "15.1.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",
|
||||
"@fastify/cors": "10.1.0",
|
||||
|
@ -80,20 +80,20 @@
|
|||
"@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",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@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",
|
||||
"ajv": "8.17.1",
|
||||
|
@ -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",
|
||||
|
@ -135,8 +135,8 @@
|
|||
"jsrsasign": "11.1.0",
|
||||
"juice": "11.0.1",
|
||||
"meilisearch": "0.51.0",
|
||||
"mfm-js": "0.24.0",
|
||||
"microformats-parser": "2.0.3",
|
||||
"mfm-js": "0.25.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ async function killProc() {
|
|||
'./node_modules/nodemon/bin/nodemon.js',
|
||||
[
|
||||
'-w', 'src',
|
||||
'-e', 'ts,js,mjs,cjs,json',
|
||||
'-e', 'ts,js,mjs,cjs,json,pug',
|
||||
'--exec', 'pnpm', 'run', 'build',
|
||||
],
|
||||
{
|
||||
|
|
|
@ -184,9 +184,9 @@ export type Config = {
|
|||
authUrl: string;
|
||||
driveUrl: string;
|
||||
userAgent: string;
|
||||
frontendEntry: string;
|
||||
frontendEntry: { file: string | null };
|
||||
frontendManifestExists: boolean;
|
||||
frontendEmbedEntry: string;
|
||||
frontendEmbedEntry: { file: string | null };
|
||||
frontendEmbedManifestExists: boolean;
|
||||
mediaProxy: string;
|
||||
externalMediaProxyEnabled: boolean;
|
||||
|
@ -235,10 +235,10 @@ export function loadConfig(): Config {
|
|||
const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json');
|
||||
const frontendManifest = frontendManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8'))
|
||||
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
|
||||
: { 'src/_boot_.ts': { file: null } };
|
||||
const frontendEmbedManifest = frontendEmbedManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
|
||||
: { 'src/boot.ts': { file: 'src/boot.ts' } };
|
||||
: { 'src/boot.ts': { file: null } };
|
||||
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
|
||||
const address = socket.remoteAddress;
|
||||
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', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
|
||||
const address = socket.remoteAddress;
|
||||
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)),
|
||||
|
|
|
@ -227,9 +227,9 @@ export class SearchService {
|
|||
|
||||
if (opts.host) {
|
||||
if (opts.host === '.') {
|
||||
query.andWhere('user.host IS NULL');
|
||||
query.andWhere('note.userHost IS NULL');
|
||||
} else {
|
||||
query.andWhere('user.host = :host', { host: opts.host });
|
||||
query.andWhere('note.userHost = :host', { host: opts.host });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -85,6 +85,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
|||
renoteCount: 10,
|
||||
repliesCount: 5,
|
||||
clippedCount: 0,
|
||||
pageCount: 0,
|
||||
reactions: {},
|
||||
visibility: 'public',
|
||||
uri: null,
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -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,9 +5,9 @@
|
|||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { And, In, IsNull, LessThan, MoreThan, Not } from 'typeorm';
|
||||
import { DataSource, IsNull, LessThan, QueryFailedError, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiMeta, MiNote, NoteFavoritesRepository, NotesRepository, UserNotePiningsRepository } from '@/models/_.js';
|
||||
import type { MiMeta, MiNote, NotesRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
@ -25,11 +25,8 @@ export class CleanRemoteNotesProcessorService {
|
|||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.noteFavoritesRepository)
|
||||
private noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
@Inject(DI.userNotePiningsRepository)
|
||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private idService: IdService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
|
@ -37,12 +34,22 @@ export class CleanRemoteNotesProcessorService {
|
|||
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...');
|
||||
|
@ -51,6 +58,7 @@ export class CleanRemoteNotesProcessorService {
|
|||
oldest: null,
|
||||
newest: null,
|
||||
skipped: true,
|
||||
transientErrors: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -59,7 +67,106 @@ 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;
|
||||
//#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:
|
||||
// - old enough (older than the newestLimit)
|
||||
// - a remote note (userHost is not null).
|
||||
// - not have clipped
|
||||
// - not have pinned on the user profile
|
||||
// - not has been favorite by any user
|
||||
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 ');
|
||||
|
||||
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')
|
||||
.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');
|
||||
|
||||
// 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,
|
||||
|
@ -67,101 +174,107 @@ export class CleanRemoteNotesProcessorService {
|
|||
newest: null as number | null,
|
||||
};
|
||||
|
||||
let cursor: MiNote['id'] = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
|
||||
|
||||
while (true) {
|
||||
let lowThroughputWarned = false;
|
||||
let transientErrors = 0;
|
||||
for (;;) {
|
||||
//#region check time
|
||||
const batchBeginAt = Date.now();
|
||||
|
||||
let notes: Pick<MiNote, 'id'>[] = await this.notesRepository.find({
|
||||
where: {
|
||||
id: LessThan(cursor),
|
||||
userHost: Not(IsNull()),
|
||||
clippedCount: 0,
|
||||
renoteCount: 0,
|
||||
},
|
||||
take: MAX_NOTE_COUNT_PER_QUERY,
|
||||
order: {
|
||||
// 新しい順
|
||||
// https://github.com/misskey-dev/misskey/pull/16292#issuecomment-3139376314
|
||||
id: -1,
|
||||
},
|
||||
select: ['id'],
|
||||
});
|
||||
const elapsed = batchBeginAt - startAt;
|
||||
|
||||
const fetchedCount = notes.length;
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.id < cursor) {
|
||||
cursor = note.id;
|
||||
}
|
||||
}
|
||||
|
||||
const pinings = notes.length === 0 ? [] : await this.userNotePiningsRepository.find({
|
||||
where: {
|
||||
noteId: In(notes.map(note => note.id)),
|
||||
},
|
||||
select: ['noteId'],
|
||||
});
|
||||
|
||||
notes = notes.filter(note => {
|
||||
return !pinings.some(pining => pining.noteId === note.id);
|
||||
});
|
||||
|
||||
const favorites = notes.length === 0 ? [] : await this.noteFavoritesRepository.find({
|
||||
where: {
|
||||
noteId: In(notes.map(note => note.id)),
|
||||
},
|
||||
select: ['noteId'],
|
||||
});
|
||||
|
||||
notes = notes.filter(note => {
|
||||
return !favorites.some(favorite => favorite.noteId === note.id);
|
||||
});
|
||||
|
||||
const replies = notes.length === 0 ? [] : await this.notesRepository.find({
|
||||
where: {
|
||||
replyId: In(notes.map(note => note.id)),
|
||||
userHost: IsNull(),
|
||||
},
|
||||
select: ['replyId'],
|
||||
});
|
||||
|
||||
notes = notes.filter(note => {
|
||||
return !replies.some(reply => reply.replyId === note.id);
|
||||
});
|
||||
|
||||
if (notes.length > 0) {
|
||||
await this.notesRepository.delete(notes.map(note => note.id));
|
||||
|
||||
for (const note of notes) {
|
||||
const t = this.idService.parse(note.id).date.getTime();
|
||||
if (stats.oldest === null || t < stats.oldest) {
|
||||
stats.oldest = t;
|
||||
}
|
||||
if (stats.newest === null || t > stats.newest) {
|
||||
stats.newest = t;
|
||||
}
|
||||
}
|
||||
|
||||
stats.deletedCount += notes.length;
|
||||
}
|
||||
|
||||
job.log(`Deleted ${notes.length} of ${fetchedCount}; ${Date.now() - batchBeginAt}ms`);
|
||||
|
||||
const elapsed = Date.now() - 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
|
||||
|
||||
await setTimeout(1000 * 5); // Wait a moment to avoid overwhelming the db
|
||||
const queryBegin = performance.now();
|
||||
let noteIds = null;
|
||||
|
||||
try {
|
||||
noteIds = await candidateNotesQuery.setParameters(
|
||||
{ newestLimit, cursorLeft },
|
||||
).getRawMany<{ id: MiNote['id'], isRemovable: boolean, isBase: boolean }>();
|
||||
} catch (e) {
|
||||
if (currentLimit > minimumLimit && e instanceof QueryFailedError && e.driverError?.code === '57014') {
|
||||
// Statement timeout (maybe suddenly hit a large note tree), reduce the limit and try again
|
||||
// continuous failures will eventually converge to currentLimit == minimumLimit and then throw
|
||||
currentLimit = Math.max(minimumLimit, Math.floor(currentLimit * 0.25));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
job.log('No more notes to clean.');
|
||||
break;
|
||||
}
|
||||
|
||||
const queryDuration = performance.now() - queryBegin;
|
||||
// try to adjust such that each query takes about 1~5 seconds and reasonable NodeJS heap so the task stays responsive
|
||||
// this should not oscillate..
|
||||
if (queryDuration > 5000 || noteIds.length > 5000) {
|
||||
currentLimit = Math.floor(currentLimit * 0.5);
|
||||
} else if (queryDuration < 1000 && noteIds.length < 1000) {
|
||||
currentLimit = Math.floor(currentLimit * 1.5);
|
||||
}
|
||||
// clamp to a sane range
|
||||
currentLimit = Math.min(Math.max(currentLimit, minimumLimit), 5000);
|
||||
|
||||
const deletableNoteIds = noteIds.filter(result => result.isRemovable).map(result => result.id);
|
||||
if (deletableNoteIds.length > 0) {
|
||||
try {
|
||||
await this.notesRepository.delete(deletableNoteIds);
|
||||
|
||||
for (const id of deletableNoteIds) {
|
||||
const t = this.idService.parse(id).date.getTime();
|
||||
if (stats.oldest === null || t < stats.oldest) {
|
||||
stats.oldest = t;
|
||||
}
|
||||
if (stats.newest === null || t > stats.newest) {
|
||||
stats.newest = t;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cursorLeft = noteIds.filter(result => result.isBase).reduce((max, { id }) => id > max ? id : max, cursorLeft);
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
|
@ -169,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) {
|
||||
|
|
|
@ -48,8 +48,8 @@ export const paramDef = {
|
|||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 1024,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
|
@ -57,7 +57,6 @@ export const paramDef = {
|
|||
'name',
|
||||
'on',
|
||||
'url',
|
||||
'secret',
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -52,8 +52,8 @@ export const paramDef = {
|
|||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 1024,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
|
@ -62,7 +62,6 @@ export const paramDef = {
|
|||
'name',
|
||||
'on',
|
||||
'url',
|
||||
'secret',
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -32,6 +32,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private chatService: ChatService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
await this.chatService.checkChatAvailability(me.id, 'read');
|
||||
|
||||
await this.chatService.readAllChatMessages(me.id);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { QueryService } from '@/core/QueryService.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
|
||||
import { ChatService } from '@/core/ChatService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -60,14 +61,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.chatMessagesRepository)
|
||||
private chatMessagesRepository: ChatMessagesRepository,
|
||||
|
||||
private chatService: ChatService,
|
||||
private chatEntityService: ChatEntityService,
|
||||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const isModerator = await this.roleService.isModerator(me);
|
||||
|
||||
if (!isModerator) {
|
||||
await this.chatService.checkChatAvailability(me.id, 'read');
|
||||
}
|
||||
|
||||
const file = await this.driveFilesRepository.findOneBy({
|
||||
id: ps.fileId,
|
||||
userId: await this.roleService.isModerator(me) ? undefined : me.id,
|
||||
userId: isModerator ? undefined : me.id,
|
||||
});
|
||||
|
||||
if (file == null) {
|
||||
|
|
|
@ -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);
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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);
|
||||
}
|
||||
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,57 +76,37 @@ 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);
|
||||
}
|
||||
try {
|
||||
let eyeCatchingImage: MiDriveFile | null | undefined | string = ps.eyeCatchingImageId;
|
||||
if (eyeCatchingImage != null) {
|
||||
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||
id: eyeCatchingImage,
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
if (ps.eyeCatchingImageId != null) {
|
||||
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
|
||||
id: ps.eyeCatchingImageId,
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
if (eyeCatchingImage == null) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (eyeCatchingImage == null) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -20,17 +20,6 @@ import type { Config } from '@/config.js';
|
|||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import type {
|
||||
DbQueue,
|
||||
DeliverQueue,
|
||||
EndedPollNotificationQueue,
|
||||
InboxQueue,
|
||||
ObjectStorageQueue,
|
||||
RelationshipQueue,
|
||||
SystemQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
} from '@/core/QueueModule.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||
|
@ -129,16 +118,6 @@ export class ClientServerService {
|
|||
private feedService: FeedService,
|
||||
private roleService: RoleService,
|
||||
private clientLoggerService: ClientLoggerService,
|
||||
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
|
|
|
@ -32,61 +32,30 @@
|
|||
}
|
||||
|
||||
//#region Detect language & fetch translations
|
||||
if (!localStorage.hasOwnProperty('locale')) {
|
||||
const supportedLangs = LANGS;
|
||||
let lang = localStorage.getItem('lang');
|
||||
if (lang == null || !supportedLangs.includes(lang)) {
|
||||
if (supportedLangs.includes(navigator.language)) {
|
||||
lang = navigator.language;
|
||||
} else {
|
||||
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
|
||||
|
||||
// Fallback
|
||||
if (lang == null) lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
const metaRes = await window.fetch('/api/meta', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
credentials: 'omit',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (metaRes.status !== 200) {
|
||||
renderError('META_FETCH');
|
||||
return;
|
||||
}
|
||||
const meta = await metaRes.json();
|
||||
const v = meta.version;
|
||||
if (v == null) {
|
||||
renderError('META_FETCH_V');
|
||||
return;
|
||||
}
|
||||
|
||||
// for https://github.com/misskey-dev/misskey/issues/10202
|
||||
if (lang == null || lang.toString == null || lang.toString() === 'null') {
|
||||
console.error('invalid lang value detected!!!', typeof lang, lang);
|
||||
lang = 'en-US';
|
||||
}
|
||||
|
||||
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
|
||||
if (localRes.status === 200) {
|
||||
localStorage.setItem('lang', lang);
|
||||
localStorage.setItem('locale', await localRes.text());
|
||||
localStorage.setItem('localeVersion', v);
|
||||
const supportedLangs = LANGS;
|
||||
/** @type { string } */
|
||||
let lang = localStorage.getItem('lang');
|
||||
if (lang == null || !supportedLangs.includes(lang)) {
|
||||
if (supportedLangs.includes(navigator.language)) {
|
||||
lang = navigator.language;
|
||||
} else {
|
||||
renderError('LOCALE_FETCH');
|
||||
return;
|
||||
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
|
||||
|
||||
// Fallback
|
||||
if (lang == null) lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
// for https://github.com/misskey-dev/misskey/issues/10202
|
||||
if (lang == null || lang.toString == null || lang.toString() === 'null') {
|
||||
console.error('invalid lang value detected!!!', typeof lang, lang);
|
||||
lang = 'en-US';
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Script
|
||||
async function importAppScript() {
|
||||
await import(`/embed_vite/${CLIENT_ENTRY}`)
|
||||
await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/_boot_.ts')
|
||||
.catch(async e => {
|
||||
console.error(e);
|
||||
renderError('APP_IMPORT');
|
||||
|
@ -115,10 +84,26 @@
|
|||
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
|
||||
}
|
||||
|
||||
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
|
||||
let messages = null;
|
||||
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
|
||||
if (bootloaderLocales) {
|
||||
messages = JSON.parse(bootloaderLocales);
|
||||
}
|
||||
if (!messages) {
|
||||
// older version of misskey does not store bootloaderLocales, stores locale as a whole
|
||||
const legacyLocale = localStorage.getItem('locale');
|
||||
if (legacyLocale) {
|
||||
const parsed = JSON.parse(legacyLocale);
|
||||
messages = {
|
||||
...(parsed._bootErrors ?? {}),
|
||||
reload: parsed.reload,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!messages) messages = {};
|
||||
|
||||
const title = locale?._bootErrors?.title || 'Failed to initialize Misskey';
|
||||
const reload = locale?.reload || 'Reload';
|
||||
const title = messages?.title || 'Failed to initialize Misskey';
|
||||
const reload = messages?.reload || 'Reload';
|
||||
|
||||
document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
|
||||
<div class="message">${title}</div>
|
||||
|
|
|
@ -22,62 +22,31 @@
|
|||
return;
|
||||
}
|
||||
|
||||
//#region Detect language & fetch translations
|
||||
if (!localStorage.hasOwnProperty('locale')) {
|
||||
const supportedLangs = LANGS;
|
||||
let lang = localStorage.getItem('lang');
|
||||
if (lang == null || !supportedLangs.includes(lang)) {
|
||||
if (supportedLangs.includes(navigator.language)) {
|
||||
lang = navigator.language;
|
||||
} else {
|
||||
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
|
||||
|
||||
// Fallback
|
||||
if (lang == null) lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
const metaRes = await window.fetch('/api/meta', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
credentials: 'omit',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (metaRes.status !== 200) {
|
||||
renderError('META_FETCH');
|
||||
return;
|
||||
}
|
||||
const meta = await metaRes.json();
|
||||
const v = meta.version;
|
||||
if (v == null) {
|
||||
renderError('META_FETCH_V');
|
||||
return;
|
||||
}
|
||||
|
||||
// for https://github.com/misskey-dev/misskey/issues/10202
|
||||
if (lang == null || lang.toString == null || lang.toString() === 'null') {
|
||||
console.error('invalid lang value detected!!!', typeof lang, lang);
|
||||
lang = 'en-US';
|
||||
}
|
||||
|
||||
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
|
||||
if (localRes.status === 200) {
|
||||
localStorage.setItem('lang', lang);
|
||||
localStorage.setItem('locale', await localRes.text());
|
||||
localStorage.setItem('localeVersion', v);
|
||||
//#region Detect language
|
||||
const supportedLangs = LANGS;
|
||||
/** @type { string } */
|
||||
let lang = localStorage.getItem('lang');
|
||||
if (lang == null || !supportedLangs.includes(lang)) {
|
||||
if (supportedLangs.includes(navigator.language)) {
|
||||
lang = navigator.language;
|
||||
} else {
|
||||
renderError('LOCALE_FETCH');
|
||||
return;
|
||||
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
|
||||
|
||||
// Fallback
|
||||
if (lang == null) lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
// for https://github.com/misskey-dev/misskey/issues/10202
|
||||
if (lang == null || lang.toString == null || lang.toString() === 'null') {
|
||||
console.error('invalid lang value detected!!!', typeof lang, lang);
|
||||
lang = 'en-US';
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Script
|
||||
async function importAppScript() {
|
||||
await import(`/vite/${CLIENT_ENTRY}`)
|
||||
await import(CLIENT_ENTRY ? `/vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/vite/src/_boot_.ts')
|
||||
.catch(async e => {
|
||||
console.error(e);
|
||||
renderError('APP_IMPORT', e);
|
||||
|
@ -162,9 +131,25 @@
|
|||
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
|
||||
}
|
||||
|
||||
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
|
||||
let messages = null;
|
||||
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
|
||||
if (bootloaderLocales) {
|
||||
messages = JSON.parse(bootloaderLocales);
|
||||
}
|
||||
if (!messages) {
|
||||
// older version of misskey does not store bootloaderLocales, stores locale as a whole
|
||||
const legacyLocale = localStorage.getItem('locale');
|
||||
if (legacyLocale) {
|
||||
const parsed = JSON.parse(legacyLocale);
|
||||
messages = {
|
||||
...(parsed._bootErrors ?? {}),
|
||||
reload: parsed.reload,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!messages) messages = {};
|
||||
|
||||
const messages = Object.assign({
|
||||
messages = Object.assign({
|
||||
title: 'Failed to initialize Misskey',
|
||||
solution: 'The following actions may solve the problem.',
|
||||
solution1: 'Update your os and browser',
|
||||
|
@ -176,8 +161,8 @@
|
|||
otherOption2: 'Start the simple client',
|
||||
otherOption3: 'Start the repair tool',
|
||||
otherOption4: 'Start Misskey in safe mode',
|
||||
}, locale?._bootErrors || {});
|
||||
const reload = locale?.reload || 'Reload';
|
||||
reload: 'Reload',
|
||||
}, messages);
|
||||
|
||||
const safeModeUrl = new URL(window.location.href);
|
||||
safeModeUrl.searchParams.set('safemode', 'true');
|
||||
|
@ -193,7 +178,7 @@
|
|||
</svg>
|
||||
<h1>${messages.title}</h1>
|
||||
<button class="button-big" onclick="location.reload(true);">
|
||||
<span class="button-label-big">${reload}</span>
|
||||
<span class="button-label-big">${messages?.reload}</span>
|
||||
</button>
|
||||
<p><b>${messages.solution}</b></p>
|
||||
<p>${messages.solution1}</p>
|
||||
|
|
|
@ -19,7 +19,6 @@ html(class='embed')
|
|||
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
|
||||
link(rel='icon' href= icon || '/favicon.ico')
|
||||
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
|
||||
link(rel='modulepreload' href=`/embed_vite/${entry.file}`)
|
||||
|
||||
if !config.frontendEmbedManifestExists
|
||||
script(type="module" src="/embed_vite/@vite/client")
|
||||
|
@ -40,7 +39,7 @@ html(class='embed')
|
|||
|
||||
script.
|
||||
var VERSION = "#{version}";
|
||||
var CLIENT_ENTRY = "#{entry.file}";
|
||||
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
|
||||
|
||||
script(type='application/json' id='misskey_meta' data-generated-at=now)
|
||||
!= metaJson
|
||||
|
|
|
@ -37,7 +37,6 @@ html
|
|||
link(rel='prefetch' href=serverErrorImageUrl)
|
||||
link(rel='prefetch' href=infoImageUrl)
|
||||
link(rel='prefetch' href=notFoundImageUrl)
|
||||
link(rel='modulepreload' href=`/vite/${entry.file}`)
|
||||
|
||||
if !config.frontendManifestExists
|
||||
script(type="module" src="/vite/@vite/client")
|
||||
|
@ -69,7 +68,7 @@ html
|
|||
|
||||
script.
|
||||
var VERSION = "#{version}";
|
||||
var CLIENT_ENTRY = "#{entry.file}";
|
||||
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
|
||||
|
||||
script(type='application/json' id='misskey_meta' data-generated-at=now)
|
||||
!= metaJson
|
||||
|
|
|
@ -79,6 +79,9 @@ async function createAdmin(host: Host): Promise<Misskey.entities.SignupResponse
|
|||
rateLimitFactor: 0 as never,
|
||||
},
|
||||
}, res.token);
|
||||
await client.request('admin/update-meta', {
|
||||
federation: 'all',
|
||||
}, res.token);
|
||||
return res;
|
||||
}).catch(err => {
|
||||
if (err.info.e.message === 'access denied') return undefined;
|
||||
|
@ -187,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);
|
||||
|
|
|
@ -24,6 +24,7 @@ describe('Endpoints', () => {
|
|||
bob = await signup({ username: 'bob' });
|
||||
carol = await signup({ username: 'carol' });
|
||||
dave = await signup({ username: 'dave' });
|
||||
await api('admin/update-meta', { federation: 'all' }, alice as misskey.entities.SignupResponse);
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
describe('signup', () => {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { channel, clip, galleryPost, page, play, post, signup, simpleGet, uploadFile } from '../utils.js';
|
||||
import { api, channel, clip, galleryPost, page, play, post, signup, simpleGet, uploadFile } from '../utils.js';
|
||||
import type { SimpleGetResponse } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
|
@ -78,6 +78,7 @@ describe('Webリソース', () => {
|
|||
|
||||
beforeAll(async () => {
|
||||
alice = await signup({ username: 'alice' });
|
||||
await api('admin/update-meta', { federation: 'all' }, alice as misskey.entities.SignupResponse);
|
||||
aliceUploadedFile = (await uploadFile(alice)).body;
|
||||
alicesPost = await post(alice, {
|
||||
text: 'test',
|
||||
|
|
|
@ -16,6 +16,7 @@ describe('FF visibility', () => {
|
|||
beforeAll(async () => {
|
||||
alice = await signup({ username: 'alice' });
|
||||
bob = await signup({ username: 'bob' });
|
||||
await api('admin/update-meta', { federation: 'all' }, alice as misskey.entities.SignupResponse);
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
test('followingVisibility, followersVisibility がともに public なユーザーのフォロー/フォロワーを誰でも見れる', async () => {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { host, origin, relativeFetch, signup } from '../utils.js';
|
||||
import { api, host, origin, relativeFetch, signup } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('.well-known', () => {
|
||||
|
@ -14,6 +14,7 @@ describe('.well-known', () => {
|
|||
|
||||
beforeAll(async () => {
|
||||
alice = await signup({ username: 'alice' });
|
||||
await api('admin/update-meta', { federation: 'all' }, alice as misskey.entities.SignupResponse);
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
test('nodeinfo', async () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,652 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import ms from 'ms';
|
||||
import {
|
||||
type MiNote,
|
||||
type MiUser,
|
||||
type NotesRepository,
|
||||
type NoteFavoritesRepository,
|
||||
type UserNotePiningsRepository,
|
||||
type UsersRepository,
|
||||
type UserProfilesRepository,
|
||||
MiMeta,
|
||||
} from '@/models/_.js';
|
||||
import { CleanRemoteNotesProcessorService } from '@/queue/processors/CleanRemoteNotesProcessorService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
|
||||
describe('CleanRemoteNotesProcessorService', () => {
|
||||
let app: TestingModule;
|
||||
let service: CleanRemoteNotesProcessorService;
|
||||
let idService: IdService;
|
||||
let notesRepository: NotesRepository;
|
||||
let noteFavoritesRepository: NoteFavoritesRepository;
|
||||
let userNotePiningsRepository: UserNotePiningsRepository;
|
||||
let usersRepository: UsersRepository;
|
||||
let userProfilesRepository: UserProfilesRepository;
|
||||
|
||||
// Local user
|
||||
let alice: MiUser;
|
||||
// Remote user 1
|
||||
let bob: MiUser;
|
||||
// Remote user 2
|
||||
let carol: MiUser;
|
||||
|
||||
const meta = new MiMeta();
|
||||
|
||||
// Mock job object
|
||||
const createMockJob = () => ({
|
||||
log: jest.fn(),
|
||||
updateProgress: jest.fn(),
|
||||
});
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}) {
|
||||
const id = idService.gen();
|
||||
const un = data.username || secureRndstr(16);
|
||||
const user = await usersRepository
|
||||
.insert({
|
||||
id,
|
||||
username: un,
|
||||
usernameLower: un.toLowerCase(),
|
||||
...data,
|
||||
})
|
||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await userProfilesRepository.save({
|
||||
userId: id,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createNote(data: Partial<MiNote>, user: MiUser, time?: number): Promise<MiNote> {
|
||||
const id = idService.gen(time);
|
||||
const note = await notesRepository
|
||||
.insert({
|
||||
id: id,
|
||||
text: `note_${id}`,
|
||||
userId: user.id,
|
||||
userHost: user.host,
|
||||
visibility: 'public',
|
||||
...data,
|
||||
})
|
||||
.then(x => notesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
return note;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await Test
|
||||
.createTestingModule({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
],
|
||||
providers: [
|
||||
CleanRemoteNotesProcessorService,
|
||||
IdService,
|
||||
{
|
||||
provide: QueueLoggerService,
|
||||
useFactory: () => ({
|
||||
logger: {
|
||||
createSubLogger: () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
succ: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideProvider(DI.meta).useFactory({ factory: () => meta })
|
||||
.compile();
|
||||
|
||||
service = app.get(CleanRemoteNotesProcessorService);
|
||||
idService = app.get(IdService);
|
||||
notesRepository = app.get(DI.notesRepository);
|
||||
noteFavoritesRepository = app.get(DI.noteFavoritesRepository);
|
||||
userNotePiningsRepository = app.get(DI.userNotePiningsRepository);
|
||||
usersRepository = app.get(DI.usersRepository);
|
||||
userProfilesRepository = app.get(DI.userProfilesRepository);
|
||||
|
||||
alice = await createUser({ username: 'alice', host: null });
|
||||
bob = await createUser({ username: 'bob', host: 'remote1.example.com' });
|
||||
carol = await createUser({ username: 'carol', host: 'remote2.example.com' });
|
||||
|
||||
app.enableShutdownHooks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set default meta values
|
||||
meta.enableRemoteNotesCleaning = true;
|
||||
meta.remoteNotesCleaningMaxProcessingDurationInMinutes = 0.3;
|
||||
meta.remoteNotesCleaningExpiryDaysForEachNotes = 30;
|
||||
}, 60 * 1000);
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await Promise.all([
|
||||
notesRepository.createQueryBuilder().delete().execute(),
|
||||
userNotePiningsRepository.createQueryBuilder().delete().execute(),
|
||||
noteFavoritesRepository.createQueryBuilder().delete().execute(),
|
||||
]);
|
||||
}, 60 * 1000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('basic', () => {
|
||||
test('should skip cleaning when enableRemoteNotesCleaning is false', async () => {
|
||||
meta.enableRemoteNotesCleaning = false;
|
||||
const job = createMockJob();
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedCount: 0,
|
||||
oldest: null,
|
||||
newest: null,
|
||||
skipped: true,
|
||||
transientErrors: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('should return success result when enableRemoteNotesCleaning is true and no notes to clean', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
await createNote({}, alice);
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedCount: 0,
|
||||
oldest: null,
|
||||
newest: null,
|
||||
skipped: false,
|
||||
transientErrors: 0,
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
test('should clean remote notes and return stats', async () => {
|
||||
// Remote notes
|
||||
const remoteNotes = await Promise.all([
|
||||
createNote({}, bob),
|
||||
createNote({}, carol),
|
||||
createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000),
|
||||
createNote({}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000), // Note older than expiry
|
||||
]);
|
||||
|
||||
// Local notes
|
||||
const localNotes = await Promise.all([
|
||||
createNote({}, alice),
|
||||
createNote({}, alice, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000),
|
||||
]);
|
||||
|
||||
const job = createMockJob();
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedCount: 2,
|
||||
oldest: expect.any(Number),
|
||||
newest: expect.any(Number),
|
||||
skipped: false,
|
||||
transientErrors: 0,
|
||||
});
|
||||
|
||||
// Check side-by-side from all notes
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.length).toBe(4);
|
||||
expect(remainingNotes.some(n => n.id === remoteNotes[0].id)).toBe(true);
|
||||
expect(remainingNotes.some(n => n.id === remoteNotes[1].id)).toBe(true);
|
||||
expect(remainingNotes.some(n => n.id === remoteNotes[2].id)).toBe(false);
|
||||
expect(remainingNotes.some(n => n.id === remoteNotes[3].id)).toBe(false);
|
||||
expect(remainingNotes.some(n => n.id === localNotes[0].id)).toBe(true);
|
||||
expect(remainingNotes.some(n => n.id === localNotes[1].id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advanced', () => {
|
||||
// お気に入り
|
||||
test('should not delete note that is favorited by any user', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote note that should be deleted
|
||||
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
|
||||
// Favorite the note
|
||||
await noteFavoritesRepository.save({
|
||||
id: idService.gen(),
|
||||
userId: alice.id,
|
||||
noteId: olderRemoteNote.id,
|
||||
});
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0);
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNote = await notesRepository.findOneBy({ id: olderRemoteNote.id });
|
||||
expect(remainingNote).not.toBeNull();
|
||||
});
|
||||
|
||||
// ピン留め
|
||||
test('should not delete note that is pinned by the user', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote note that should be deleted
|
||||
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
|
||||
// Pin the note by the user who created it
|
||||
await userNotePiningsRepository.save({
|
||||
id: idService.gen(),
|
||||
userId: bob.id, // Same user as the note creator
|
||||
noteId: olderRemoteNote.id,
|
||||
});
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0);
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNote = await notesRepository.findOneBy({ id: olderRemoteNote.id });
|
||||
expect(remainingNote).not.toBeNull();
|
||||
});
|
||||
|
||||
// クリップ
|
||||
test('should not delete note that is clipped', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote note that is clipped
|
||||
const clippedNote = await createNote({
|
||||
clippedCount: 1, // Clipped
|
||||
}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0);
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNote = await notesRepository.findOneBy({ id: clippedNote.id });
|
||||
expect(remainingNote).not.toBeNull();
|
||||
});
|
||||
|
||||
// ページ
|
||||
test('should not delete note that is embedded in a page', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote note that is embedded in a page
|
||||
const clippedNote = await createNote({
|
||||
pageCount: 1, // Embedded in a page
|
||||
}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0);
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNote = await notesRepository.findOneBy({ id: clippedNote.id });
|
||||
expect(remainingNote).not.toBeNull();
|
||||
});
|
||||
|
||||
// 古いreply, renoteが含まれている時の挙動
|
||||
test('should handle reply/renote relationships correctly', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote notes with reply/renote relationships
|
||||
const originalNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
const replyNote = await createNote({
|
||||
replyId: originalNote.id,
|
||||
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||
const renoteNote = await createNote({
|
||||
renoteId: originalNote.id,
|
||||
}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 3000);
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
// Should delete all three notes as they are all old and remote
|
||||
expect(result.deletedCount).toBe(3);
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.some(n => n.id === originalNote.id)).toBe(false);
|
||||
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(false);
|
||||
expect(remainingNotes.some(n => n.id === renoteNote.id)).toBe(false);
|
||||
});
|
||||
|
||||
// 古いリモートノートに新しいリプライがある時、どちらも削除されない
|
||||
test('should not delete both old remote note with new reply', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote note that should be deleted
|
||||
const oldNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
|
||||
// Create a reply note that is newer than the expiry period
|
||||
const recentReplyNote = await createNote({
|
||||
replyId: oldNote.id,
|
||||
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) + 1000);
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0); // Only the old note should be deleted
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.some(n => n.id === oldNote.id)).toBe(true);
|
||||
expect(remainingNotes.some(n => n.id === recentReplyNote.id)).toBe(true); // Recent reply note should remain
|
||||
});
|
||||
|
||||
// 古いリモートノートに新しいリプライと古いリプライがある時、全て残る
|
||||
test('should not delete old remote note with new reply and old reply', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote note that should be deleted
|
||||
const oldNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
|
||||
// Create a reply note that is newer than the expiry period
|
||||
const recentReplyNote = await createNote({
|
||||
replyId: oldNote.id,
|
||||
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) + 1000);
|
||||
|
||||
// Create an old reply note that should be deleted
|
||||
const oldReplyNote = await createNote({
|
||||
replyId: oldNote.id,
|
||||
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0);
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.some(n => n.id === oldNote.id)).toBe(true);
|
||||
expect(remainingNotes.some(n => n.id === recentReplyNote.id)).toBe(true); // Recent reply note should remain
|
||||
expect(remainingNotes.some(n => n.id === oldReplyNote.id)).toBe(true); // Old reply note should be deleted
|
||||
});
|
||||
|
||||
// リプライがお気に入りされているとき、どちらも削除されない
|
||||
test('should not delete reply note that is favorited', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote note that should be deleted
|
||||
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
|
||||
// Create a reply note that is newer than the expiry period
|
||||
const replyNote = await createNote({
|
||||
replyId: olderRemoteNote.id,
|
||||
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||
|
||||
// Favorite the reply note
|
||||
await noteFavoritesRepository.save({
|
||||
id: idService.gen(),
|
||||
userId: alice.id,
|
||||
noteId: replyNote.id,
|
||||
});
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0); // Only the old note should be deleted
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.some(n => n.id === olderRemoteNote.id)).toBe(true);
|
||||
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(true); // Recent reply note should remain
|
||||
});
|
||||
|
||||
// リプライがピン留めされているとき、どちらも削除されない
|
||||
test('should not delete reply note that is pinned', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote note that should be deleted
|
||||
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
|
||||
// Create a reply note that is newer than the expiry period
|
||||
const replyNote = await createNote({
|
||||
replyId: olderRemoteNote.id,
|
||||
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||
|
||||
// Pin the reply note
|
||||
await userNotePiningsRepository.save({
|
||||
id: idService.gen(),
|
||||
userId: carol.id,
|
||||
noteId: replyNote.id,
|
||||
});
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0); // Only the old note should be deleted
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.some(n => n.id === olderRemoteNote.id)).toBe(true);
|
||||
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(true); // Reply note should remain
|
||||
});
|
||||
|
||||
// リプライがクリップされているとき、どちらも削除されない
|
||||
test('should not delete reply note that is clipped', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create old remote note that should be deleted
|
||||
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
|
||||
|
||||
// Create a reply note that is old but clipped
|
||||
const replyNote = await createNote({
|
||||
replyId: olderRemoteNote.id,
|
||||
clippedCount: 1, // Clipped
|
||||
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0); // Both notes should be kept because reply is clipped
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.some(n => n.id === olderRemoteNote.id)).toBe(true);
|
||||
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle mixed scenarios with multiple conditions', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create various types of notes
|
||||
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||
|
||||
// Should be deleted: old remote note with no special conditions
|
||||
const deletableNote = await createNote({}, bob, oldTime);
|
||||
|
||||
// Should NOT be deleted: old remote note but favorited
|
||||
const favoritedNote = await createNote({}, carol, oldTime);
|
||||
await noteFavoritesRepository.save({
|
||||
id: idService.gen(),
|
||||
userId: alice.id,
|
||||
noteId: favoritedNote.id,
|
||||
});
|
||||
|
||||
// Should NOT be deleted: old remote note but pinned
|
||||
const pinnedNote = await createNote({}, bob, oldTime);
|
||||
await userNotePiningsRepository.save({
|
||||
id: idService.gen(),
|
||||
userId: bob.id,
|
||||
noteId: pinnedNote.id,
|
||||
});
|
||||
|
||||
// Should NOT be deleted: old remote note but clipped
|
||||
const clippedNote = await createNote({
|
||||
clippedCount: 2,
|
||||
}, carol, oldTime);
|
||||
|
||||
// Should NOT be deleted: old local note
|
||||
const localNote = await createNote({}, alice, oldTime);
|
||||
|
||||
// Should NOT be deleted: new remote note
|
||||
const newerRemoteNote = await createNote({}, bob);
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(1); // Only deletableNote should be deleted
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.length).toBe(5);
|
||||
expect(remainingNotes.some(n => n.id === deletableNote.id)).toBe(false); // Deleted
|
||||
expect(remainingNotes.some(n => n.id === favoritedNote.id)).toBe(true); // Kept
|
||||
expect(remainingNotes.some(n => n.id === pinnedNote.id)).toBe(true); // Kept
|
||||
expect(remainingNotes.some(n => n.id === clippedNote.id)).toBe(true); // Kept
|
||||
expect(remainingNotes.some(n => n.id === localNote.id)).toBe(true); // Kept
|
||||
expect(remainingNotes.some(n => n.id === newerRemoteNote.id)).toBe(true); // Kept
|
||||
});
|
||||
|
||||
// 大量のノート
|
||||
test('should handle large number of notes correctly', async () => {
|
||||
const AMOUNT = 130;
|
||||
const job = createMockJob();
|
||||
|
||||
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||
const noteIds = [];
|
||||
for (let i = 0; i < AMOUNT; i++) {
|
||||
const note = await createNote({}, bob, oldTime - i);
|
||||
noteIds.push(note.id);
|
||||
}
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
// Should delete all notes, but may require multiple batches
|
||||
expect(result.deletedCount).toBe(AMOUNT);
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.length).toBe(0);
|
||||
});
|
||||
|
||||
// 大量のノート + リプライ or リノート
|
||||
test('should handle large number of notes with replies correctly', async () => {
|
||||
const AMOUNT = 130;
|
||||
const job = createMockJob();
|
||||
|
||||
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||
const noteIds = [];
|
||||
for (let i = 0; i < AMOUNT; i++) {
|
||||
const note = await createNote({}, bob, oldTime - i - AMOUNT);
|
||||
noteIds.push(note.id);
|
||||
if (i % 2 === 0) {
|
||||
// Create a reply for every second note
|
||||
await createNote({ replyId: note.id }, carol, oldTime - i);
|
||||
} else {
|
||||
// Create a renote for every second note
|
||||
await createNote({ renoteId: note.id }, bob, oldTime - i);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await service.process(job as any);
|
||||
// Should delete all notes, but may require multiple batches
|
||||
expect(result.deletedCount).toBe(AMOUNT * 2);
|
||||
expect(result.skipped).toBe(false);
|
||||
});
|
||||
|
||||
// 大量の古いノート + 新しいリプライ or リノート
|
||||
test('should handle large number of old notes with new replies correctly', async () => {
|
||||
const AMOUNT = 130;
|
||||
const job = createMockJob();
|
||||
|
||||
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||
const newTime = Date.now();
|
||||
const noteIds = [];
|
||||
for (let i = 0; i < AMOUNT; i++) {
|
||||
const note = await createNote({}, bob, oldTime - i);
|
||||
noteIds.push(note.id);
|
||||
if (i % 2 === 0) {
|
||||
// Create a reply for every second note
|
||||
await createNote({ replyId: note.id }, carol, newTime + i);
|
||||
} else {
|
||||
// Create a renote for every second note
|
||||
await createNote({ renoteId: note.id }, bob, newTime + i);
|
||||
}
|
||||
}
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(0);
|
||||
expect(result.skipped).toBe(false);
|
||||
});
|
||||
|
||||
// 大量の残す対象(clippedCount: 1)と大量の削除対象
|
||||
test('should handle large number of notes, mixed conditions with clippedCount', async () => {
|
||||
const AMOUNT_BASE = 70;
|
||||
const job = createMockJob();
|
||||
|
||||
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||
const noteIds = [];
|
||||
for (let i = 0; i < AMOUNT_BASE; i++) {
|
||||
const note = await createNote({ clippedCount: 1 }, bob, oldTime - i - AMOUNT_BASE);
|
||||
noteIds.push(note.id);
|
||||
}
|
||||
for (let i = 0; i < AMOUNT_BASE; i++) {
|
||||
const note = await createNote({}, carol, oldTime - i);
|
||||
noteIds.push(note.id);
|
||||
}
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(AMOUNT_BASE); // Assuming half are deletable
|
||||
expect(result.skipped).toBe(false);
|
||||
});
|
||||
|
||||
// 大量の残す対象(リプライ)と大量の削除対象
|
||||
test('should handle large number of notes, mixed conditions with replies', async () => {
|
||||
const AMOUNT_BASE = 70;
|
||||
const job = createMockJob();
|
||||
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
|
||||
const newTime = Date.now();
|
||||
for (let i = 0; i < AMOUNT_BASE; i++) {
|
||||
// should remain
|
||||
const note = await createNote({}, carol, oldTime - AMOUNT_BASE - i);
|
||||
// should remain
|
||||
await createNote({ replyId: note.id }, bob, newTime + i);
|
||||
}
|
||||
|
||||
const noteIdsExpectedToBeDeleted = [];
|
||||
for (let i = 0; i < AMOUNT_BASE; i++) {
|
||||
// should be deleted
|
||||
const note = await createNote({}, bob, oldTime - i);
|
||||
noteIdsExpectedToBeDeleted.push(note.id);
|
||||
}
|
||||
|
||||
const result = await service.process(job as any);
|
||||
expect(result.deletedCount).toBe(AMOUNT_BASE); // Assuming all replies are deletable
|
||||
expect(result.skipped).toBe(false);
|
||||
|
||||
const remainingNotes = await notesRepository.find();
|
||||
expect(remainingNotes.length).toBe(AMOUNT_BASE * 2); // Only replies should remain
|
||||
noteIdsExpectedToBeDeleted.forEach(id => {
|
||||
expect(remainingNotes.some(n => n.id === id)).toBe(false); // All original notes should be deleted
|
||||
});
|
||||
});
|
||||
|
||||
test('should update cursor correctly during batch processing', async () => {
|
||||
const job = createMockJob();
|
||||
|
||||
// Create notes with specific timing to test cursor behavior
|
||||
const baseTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 10000;
|
||||
|
||||
const note1 = await createNote({}, bob, baseTime);
|
||||
const note2 = await createNote({}, carol, baseTime - 1000);
|
||||
const note3 = await createNote({}, bob, baseTime - 2000);
|
||||
|
||||
const result = await service.process(job as any);
|
||||
|
||||
expect(result.deletedCount).toBe(3);
|
||||
expect(result.newest).toBe(idService.parse(note1.id).date.getTime());
|
||||
expect(result.oldest).toBe(idService.parse(note3.id).date.getTime());
|
||||
expect(result.skipped).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -317,7 +317,7 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
|
|||
|
||||
const formData = new FormData();
|
||||
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');
|
||||
|
|
1
packages/frontend-builder/README.txt
Normal file
1
packages/frontend-builder/README.txt
Normal file
|
@ -0,0 +1 @@
|
|||
This package contains the common scripts that are used to build the frontend and frontend-embed packages.
|
52
packages/frontend-builder/eslint.config.js
Normal file
52
packages/frontend-builder/eslint.config.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import globals from 'globals';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../shared/eslint.config.js';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
files: [
|
||||
'**/*.ts',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
|
||||
...globals.browser,
|
||||
|
||||
// Node.js
|
||||
module: false,
|
||||
require: false,
|
||||
__dirname: false,
|
||||
|
||||
// Misskey
|
||||
_DEV_: false,
|
||||
_LANGS_: false,
|
||||
_VERSION_: false,
|
||||
_ENV_: false,
|
||||
_PERF_PREFIX_: false,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-empty-interface': ['error', {
|
||||
allowSingleExtends: true,
|
||||
}],
|
||||
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
|
||||
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
|
||||
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
|
||||
'id-denylist': ['error', 'window', 'e'],
|
||||
'no-shadow': ['warn'],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
],
|
||||
},
|
||||
];
|
153
packages/frontend-builder/locale-inliner.ts
Normal file
153
packages/frontend-builder/locale-inliner.ts
Normal file
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import MagicString from 'magic-string';
|
||||
import { collectModifications } from './locale-inliner/collect-modifications.js';
|
||||
import { applyWithLocale } from './locale-inliner/apply-with-locale.js';
|
||||
import { blankLogger } from './logger.js';
|
||||
import type { Logger } from './logger.js';
|
||||
import type { Locale } from '../../locales/index.js';
|
||||
import type { Manifest as ViteManifest } from 'vite';
|
||||
|
||||
export class LocaleInliner {
|
||||
outputDir: string;
|
||||
scriptsDir: string;
|
||||
i18nFile: string;
|
||||
i18nFileName: string;
|
||||
logger: Logger;
|
||||
chunks: ScriptChunk[];
|
||||
|
||||
static async create(options: {
|
||||
outputDir: string,
|
||||
scriptsDir: string,
|
||||
i18nFile: string,
|
||||
logger: Logger,
|
||||
}): Promise<LocaleInliner> {
|
||||
const manifest: ViteManifest = JSON.parse(await fs.readFile(`${options.outputDir}/manifest.json`, 'utf-8'));
|
||||
return new LocaleInliner({ ...options, manifest });
|
||||
}
|
||||
|
||||
constructor(options: {
|
||||
outputDir: string,
|
||||
scriptsDir: string,
|
||||
i18nFile: string,
|
||||
manifest: ViteManifest,
|
||||
logger: Logger,
|
||||
}) {
|
||||
this.outputDir = options.outputDir;
|
||||
this.scriptsDir = options.scriptsDir;
|
||||
this.i18nFile = options.i18nFile;
|
||||
this.i18nFileName = this.stripScriptDir(options.manifest[this.i18nFile].file);
|
||||
this.logger = options.logger;
|
||||
this.chunks = Object.values(options.manifest).filter(chunk => this.isScriptFile(chunk.file)).map(chunk => ({
|
||||
fileName: this.stripScriptDir(chunk.file),
|
||||
chunkName: chunk.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async loadFiles() {
|
||||
await Promise.all(this.chunks.map(async chunk => {
|
||||
const filePath = path.join(this.outputDir, this.scriptsDir, chunk.fileName);
|
||||
chunk.sourceCode = await fs.readFile(filePath, 'utf-8');
|
||||
}));
|
||||
}
|
||||
|
||||
collectsModifications() {
|
||||
for (const chunk of this.chunks) {
|
||||
if (!chunk.sourceCode) {
|
||||
throw new Error(`Source code for ${chunk.fileName} is not loaded.`);
|
||||
}
|
||||
const fileLogger = this.logger.prefixed(`${chunk.fileName} (${chunk.chunkName}): `);
|
||||
chunk.modifications = collectModifications(chunk.sourceCode, chunk.fileName, fileLogger, this);
|
||||
}
|
||||
}
|
||||
|
||||
async saveAllLocales(locales: Record<string, Locale>) {
|
||||
const localeNames = Object.keys(locales);
|
||||
for (const localeName of localeNames) {
|
||||
this.logger.info(`Creating bundle for ${localeName}`);
|
||||
await this.saveLocale(localeName, locales[localeName]);
|
||||
}
|
||||
this.logger.info('Done');
|
||||
}
|
||||
|
||||
async saveLocale(localeName: string, localeJson: Locale) {
|
||||
// create directory
|
||||
await fs.mkdir(path.join(this.outputDir, localeName), { recursive: true });
|
||||
const localeLogger = localeName === 'ja-JP' ? this.logger : blankLogger; // we want to log for single locale only
|
||||
for (const chunk of this.chunks) {
|
||||
if (!chunk.sourceCode || !chunk.modifications) {
|
||||
throw new Error(`Source code or modifications for ${chunk.fileName} is not available.`);
|
||||
}
|
||||
const fileLogger = localeLogger.prefixed(`${chunk.fileName} (${chunk.chunkName}): `);
|
||||
const magicString = new MagicString(chunk.sourceCode);
|
||||
applyWithLocale(magicString, chunk.modifications, localeName, localeJson, fileLogger);
|
||||
|
||||
await fs.writeFile(path.join(this.outputDir, localeName, chunk.fileName), magicString.toString());
|
||||
}
|
||||
}
|
||||
|
||||
isScriptFile(fileName: string) {
|
||||
return fileName.startsWith(this.scriptsDir + '/') && fileName.endsWith('.js');
|
||||
}
|
||||
|
||||
stripScriptDir(fileName: string) {
|
||||
if (!fileName.startsWith(this.scriptsDir + '/')) {
|
||||
throw new Error(`${fileName} does not start with ${this.scriptsDir}/`);
|
||||
}
|
||||
return fileName.slice(this.scriptsDir.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
interface ScriptChunk {
|
||||
fileName: string;
|
||||
chunkName?: string;
|
||||
sourceCode?: string;
|
||||
modifications?: TextModification[];
|
||||
}
|
||||
|
||||
export type TextModification = {
|
||||
type: 'delete';
|
||||
begin: number;
|
||||
end: number;
|
||||
localizedOnly: boolean;
|
||||
} | {
|
||||
// can be used later to insert '../scripts' for common files
|
||||
type: 'insert';
|
||||
begin: number;
|
||||
text: string;
|
||||
localizedOnly: boolean;
|
||||
} | {
|
||||
type: 'replace';
|
||||
begin: number;
|
||||
end: number;
|
||||
text: string;
|
||||
localizedOnly: boolean;
|
||||
} | {
|
||||
type: 'localized';
|
||||
begin: number;
|
||||
end: number;
|
||||
localizationKey: string[];
|
||||
localizedOnly: true;
|
||||
} | {
|
||||
type: 'parameterized-function';
|
||||
begin: number;
|
||||
end: number;
|
||||
localizationKey: string[];
|
||||
localizedOnly: true;
|
||||
} | {
|
||||
type: 'locale-name';
|
||||
begin: number;
|
||||
end: number;
|
||||
literal: boolean;
|
||||
localizedOnly: true;
|
||||
} | {
|
||||
type: 'locale-json';
|
||||
begin: number;
|
||||
end: number;
|
||||
localizedOnly: true;
|
||||
};
|
102
packages/frontend-builder/locale-inliner/apply-with-locale.ts
Normal file
102
packages/frontend-builder/locale-inliner/apply-with-locale.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MagicString from 'magic-string';
|
||||
import { assertNever } from '../utils.js';
|
||||
import type { Locale, ILocale } from '../../../locales/index.js';
|
||||
import type { TextModification } from '../locale-inliner.js';
|
||||
import type { Logger } from '../logger.js';
|
||||
|
||||
export function applyWithLocale(
|
||||
sourceCode: MagicString,
|
||||
modifications: TextModification[],
|
||||
localeName: string,
|
||||
localeJson: Locale,
|
||||
fileLogger: Logger,
|
||||
) {
|
||||
for (const modification of modifications) {
|
||||
switch (modification.type) {
|
||||
case 'delete':
|
||||
sourceCode.remove(modification.begin, modification.end);
|
||||
break;
|
||||
case 'insert':
|
||||
sourceCode.appendRight(modification.begin, modification.text);
|
||||
break;
|
||||
case 'replace':
|
||||
sourceCode.update(modification.begin, modification.end, modification.text);
|
||||
break;
|
||||
case 'localized': {
|
||||
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
|
||||
if (accessed == null) {
|
||||
fileLogger.warn(`Cannot find localization key ${modification.localizationKey.join('.')}`);
|
||||
}
|
||||
sourceCode.update(modification.begin, modification.end, JSON.stringify(accessed));
|
||||
break;
|
||||
}
|
||||
case 'parameterized-function': {
|
||||
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
|
||||
let replacement: string;
|
||||
if (typeof accessed === 'string') {
|
||||
replacement = formatFunction(accessed);
|
||||
} else if (typeof accessed === 'object' && accessed !== null) {
|
||||
replacement = `({${Object.entries(accessed).map(([key, value]) => `${JSON.stringify(key)}:${formatFunction(value)}`).join(',')}})`;
|
||||
} else {
|
||||
fileLogger.warn(`Cannot find localization key ${modification.localizationKey.join('.')}`);
|
||||
replacement = '(() => "")'; // placeholder for missing locale
|
||||
}
|
||||
sourceCode.update(modification.begin, modification.end, replacement);
|
||||
break;
|
||||
|
||||
function formatFunction(format: string): string {
|
||||
const params = new Set<string>();
|
||||
const components: string[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of format.matchAll(/\{(.+?)}/g)) {
|
||||
const [fullMatch, paramName] = match;
|
||||
if (lastIndex < match.index) {
|
||||
components.push(JSON.stringify(format.slice(lastIndex, match.index)));
|
||||
}
|
||||
params.add(paramName);
|
||||
components.push(paramName);
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
}
|
||||
components.push(JSON.stringify(format.slice(lastIndex)));
|
||||
|
||||
// we replace with `(({name,count})=>(name+count+"some"))`
|
||||
const paramList = Array.from(params).join(',');
|
||||
let body = components.filter(x => x !== '""').join('+');
|
||||
if (body === '') body = '""'; // if the body is empty, we return empty string
|
||||
return `(({${paramList}})=>(${body}))`;
|
||||
}
|
||||
}
|
||||
case 'locale-name': {
|
||||
sourceCode.update(modification.begin, modification.end, modification.literal ? JSON.stringify(localeName) : localeName);
|
||||
break;
|
||||
}
|
||||
case 'locale-json': {
|
||||
// locale-json is inlined to place where initialize module-level variable which is executed only once.
|
||||
// In such case we can use JSON.parse to speed up the parsing script.
|
||||
// https://v8.dev/blog/cost-of-javascript-2019#json
|
||||
sourceCode.update(modification.begin, modification.end, `JSON.parse(${JSON.stringify(JSON.stringify(localeJson))})`);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(modification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPropertyByPath(localeJson: ILocale, localizationKey: string[]): string | object | null {
|
||||
if (localizationKey.length === 0) return localeJson;
|
||||
let current: ILocale | string = localeJson;
|
||||
for (const key of localizationKey) {
|
||||
if (typeof current !== 'object' || !(key in current)) {
|
||||
return null; // Key not found
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
}
|
|
@ -0,0 +1,425 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { parseAst } from 'vite';
|
||||
import * as estreeWalker from 'estree-walker';
|
||||
import { assertNever, assertType } from '../utils.js';
|
||||
import type { AstNode, ProgramNode } from 'rollup';
|
||||
import type * as estree from 'estree';
|
||||
import type { LocaleInliner, TextModification } from '../locale-inliner.js';
|
||||
import type { Logger } from '../logger.js';
|
||||
|
||||
// WalkerContext is not exported from estree-walker, so we define it here
|
||||
interface WalkerContext {
|
||||
skip: () => void;
|
||||
}
|
||||
|
||||
export function collectModifications(sourceCode: string, fileName: string, fileLogger: Logger, inliner: LocaleInliner): TextModification[] {
|
||||
let programNode: ProgramNode;
|
||||
try {
|
||||
programNode = parseAst(sourceCode);
|
||||
} catch (err) {
|
||||
fileLogger.error(`Failed to parse source code: ${err}`);
|
||||
return [];
|
||||
}
|
||||
if (programNode.sourceType !== 'module') {
|
||||
fileLogger.error('Source code is not a module.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const modifications: TextModification[] = [];
|
||||
|
||||
// first
|
||||
// 1) replace all `scripts/` path literals with locale code
|
||||
// 2) replace all `localStorage.getItem("lang")` with `localeName` variable
|
||||
// 3) replace all `await window.fetch(`/assets/locales/${d}.${x}.json`).then(u=>u.json())` with `localeJson` variable
|
||||
estreeWalker.walk(programNode, {
|
||||
enter(this: WalkerContext, node: Node) {
|
||||
assertType<AstNode>(node);
|
||||
|
||||
if (node.type === 'Literal' && typeof node.value === 'string' && node.raw) {
|
||||
if (node.raw.substring(1).startsWith(inliner.scriptsDir)) {
|
||||
// we find `scripts/\w+\.js` literal and replace 'scripts' part with locale code
|
||||
fileLogger.debug(`${lineCol(sourceCode, node)}: found ${inliner.scriptsDir}/ path literal ${node.raw}`);
|
||||
modifications.push({
|
||||
type: 'locale-name',
|
||||
begin: node.start + 1,
|
||||
end: node.start + 1 + inliner.scriptsDir.length,
|
||||
literal: false,
|
||||
localizedOnly: true,
|
||||
});
|
||||
}
|
||||
if (node.raw.substring(1, node.raw.length - 1) === `${inliner.scriptsDir}/${inliner.i18nFileName}`) {
|
||||
// we find `scripts/i18n.ts` literal.
|
||||
// This is tipically in depmap and replace with this file name to avoid unnecessary loading i18n script
|
||||
fileLogger.debug(`${lineCol(sourceCode, node)}: found ${inliner.i18nFileName} path literal ${node.raw}`);
|
||||
modifications.push({
|
||||
type: 'replace',
|
||||
begin: node.end - 1 - inliner.i18nFileName.length,
|
||||
end: node.end - 1,
|
||||
text: fileName,
|
||||
localizedOnly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalStorageGetItemLang(node)) {
|
||||
fileLogger.debug(`${lineCol(sourceCode, node)}: found localStorage.getItem("lang") call`);
|
||||
modifications.push({
|
||||
type: 'locale-name',
|
||||
begin: node.start,
|
||||
end: node.end,
|
||||
literal: true,
|
||||
localizedOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (isAwaitFetchLocaleThenJson(node)) {
|
||||
// await window.fetch(`/assets/locales/${d}.${x}.json`).then(u=>u.json(), () => null)
|
||||
fileLogger.debug(`${lineCol(sourceCode, node)}: found await window.fetch(\`/assets/locales/\${d}.\${x}.json\`).then(u=>u.json()) call`);
|
||||
modifications.push({
|
||||
type: 'locale-json',
|
||||
begin: node.start,
|
||||
end: node.end,
|
||||
localizedOnly: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const importSpecifierResult = findImportSpecifier(programNode, inliner.i18nFileName, 'i18n');
|
||||
|
||||
switch (importSpecifierResult.type) {
|
||||
case 'no-import':
|
||||
fileLogger.debug('No import of i18n found, skipping inlining.');
|
||||
return modifications;
|
||||
case 'no-specifiers':
|
||||
fileLogger.debug('Importing i18n without specifiers, removing the import.');
|
||||
modifications.push({
|
||||
type: 'delete',
|
||||
begin: importSpecifierResult.importNode.start,
|
||||
end: importSpecifierResult.importNode.end,
|
||||
localizedOnly: false,
|
||||
});
|
||||
return modifications;
|
||||
case 'unexpected-specifiers':
|
||||
fileLogger.info(`Importing ${inliner.i18nFileName} found but with unexpected specifiers. Skipping inlining.`);
|
||||
return modifications;
|
||||
case 'specifier':
|
||||
fileLogger.debug(`Found import i18n as ${importSpecifierResult.localI18nIdentifier}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const i18nImport = importSpecifierResult.importNode;
|
||||
const localI18nIdentifier = importSpecifierResult.localI18nIdentifier;
|
||||
|
||||
// Check if the identifier is already declared in the file.
|
||||
// If it is, we may overwrite it and cause issues so we skip inlining
|
||||
let isSupported = true;
|
||||
estreeWalker.walk(programNode, {
|
||||
enter(node) {
|
||||
if (node.type === 'VariableDeclaration') {
|
||||
assertType<estree.VariableDeclaration>(node);
|
||||
for (const id of node.declarations.flatMap(x => declsOfPattern(x.id))) {
|
||||
if (id === localI18nIdentifier) {
|
||||
isSupported = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!isSupported) {
|
||||
fileLogger.error(`Duplicated identifier "${localI18nIdentifier}" in variable declaration. Skipping inlining.`);
|
||||
return modifications;
|
||||
}
|
||||
|
||||
fileLogger.debug(`imports i18n as ${localI18nIdentifier}`);
|
||||
|
||||
// In case of substitution failure, we will preserve the import statement
|
||||
// otherwise we will remove it.
|
||||
let preserveI18nImport = false;
|
||||
|
||||
const toSkip = new Set();
|
||||
toSkip.add(i18nImport);
|
||||
estreeWalker.walk(programNode, {
|
||||
enter(this: WalkerContext, node, parent, property) {
|
||||
assertType<AstNode>(node);
|
||||
assertType<AstNode>(parent);
|
||||
if (toSkip.has(node)) {
|
||||
// This is the import specifier, skip processing it
|
||||
this.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// We don't care original name part of the import declaration
|
||||
if (node.type === 'ImportDeclaration') this.skip();
|
||||
|
||||
if (node.type === 'Identifier') {
|
||||
assertType<estree.Identifier>(node);
|
||||
assertType<estree.Property | estree.MemberExpression | estree.ExportSpecifier>(parent);
|
||||
if (parent.type === 'Property' && !parent.computed && property === 'key') return; // we don't care 'id' part of { id: expr }
|
||||
if (parent.type === 'MemberExpression' && !parent.computed && property === 'property') return; // we don't care 'id' part of { id: expr }
|
||||
if (parent.type === 'ExportSpecifier' && property === 'exported') return; // we don't care 'id' part of { id: expr }
|
||||
if (node.name === localI18nIdentifier) {
|
||||
fileLogger.error(`${lineCol(sourceCode, node)}: Using i18n identifier "${localI18nIdentifier}" directly. Skipping inlining.`);
|
||||
preserveI18nImport = true;
|
||||
}
|
||||
} else if (node.type === 'MemberExpression') {
|
||||
assertType<estree.MemberExpression>(node);
|
||||
const i18nPath = parseI18nPropertyAccess(node);
|
||||
if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] === 'ts') {
|
||||
if (parent.type === 'CallExpression' && property === 'callee') return; // we don't want to process `i18n.ts.property.stringBuiltinMethod()`
|
||||
if (i18nPath.at(-1)?.startsWith('_')) fileLogger.debug(`found i18n grouped property access ${i18nPath.join('.')}`);
|
||||
else fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n property access ${i18nPath.join('.')}`);
|
||||
// it's i18n.ts.propertyAccess
|
||||
// i18n.ts.* will always be resolved to string or object containing strings
|
||||
modifications.push({
|
||||
type: 'localized',
|
||||
begin: node.start,
|
||||
end: node.end,
|
||||
localizationKey: i18nPath.slice(1), // remove 'ts' prefix
|
||||
localizedOnly: true,
|
||||
});
|
||||
this.skip();
|
||||
} else if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] === 'tsx') {
|
||||
// it's parameterized locale substitution (`i18n.tsx.property(parameters)`)
|
||||
// we expect the parameter to be an object literal
|
||||
fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n function access (object) ${i18nPath.join('.')}`);
|
||||
modifications.push({
|
||||
type: 'parameterized-function',
|
||||
begin: node.start,
|
||||
end: node.end,
|
||||
localizationKey: i18nPath.slice(1), // remove 'tsx' prefix
|
||||
localizedOnly: true,
|
||||
});
|
||||
this.skip();
|
||||
}
|
||||
} else if (node.type === 'ArrowFunctionExpression') {
|
||||
assertType<estree.ArrowFunctionExpression>(node);
|
||||
// If there is 'i18n' in the parameters, we care interior of the function
|
||||
if (node.params.flatMap(param => declsOfPattern(param)).includes(localI18nIdentifier)) this.skip();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!preserveI18nImport) {
|
||||
fileLogger.debug('removing i18n import statement');
|
||||
modifications.push({
|
||||
type: 'delete',
|
||||
begin: i18nImport.start,
|
||||
end: i18nImport.end,
|
||||
localizedOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
function parseI18nPropertyAccess(node: estree.Expression | estree.Super): string[] | null {
|
||||
if (node.type === 'Identifier' && node.name === localI18nIdentifier) return []; // i18n itself
|
||||
if (node.type !== 'MemberExpression') return null;
|
||||
// super.*
|
||||
if (node.object.type === 'Super') return null;
|
||||
|
||||
// i18n?.property is not supported
|
||||
if (node.optional) return null;
|
||||
|
||||
let id: string | null = null;
|
||||
if (node.computed) {
|
||||
if (node.property.type === 'Literal' && typeof node.property.value === 'string') {
|
||||
id = node.property.value;
|
||||
}
|
||||
} else {
|
||||
if (node.property.type === 'Identifier') {
|
||||
id = node.property.name;
|
||||
}
|
||||
}
|
||||
// non-constant property access
|
||||
if (id == null) return null;
|
||||
|
||||
const parentAccess = parseI18nPropertyAccess(node.object);
|
||||
if (parentAccess == null) return null;
|
||||
return [...parentAccess, id];
|
||||
}
|
||||
|
||||
return modifications;
|
||||
}
|
||||
|
||||
function declsOfPattern(pattern: estree.Pattern | null): string[] {
|
||||
if (pattern == null) return [];
|
||||
switch (pattern.type) {
|
||||
case 'Identifier':
|
||||
return [pattern.name];
|
||||
case 'ObjectPattern':
|
||||
return pattern.properties.flatMap(prop => {
|
||||
switch (prop.type) {
|
||||
case 'Property':
|
||||
return declsOfPattern(prop.value);
|
||||
case 'RestElement':
|
||||
return declsOfPattern(prop.argument);
|
||||
default:
|
||||
assertNever(prop);
|
||||
}
|
||||
});
|
||||
case 'ArrayPattern':
|
||||
return pattern.elements.flatMap(p => declsOfPattern(p));
|
||||
case 'RestElement':
|
||||
return declsOfPattern(pattern.argument);
|
||||
case 'AssignmentPattern':
|
||||
return declsOfPattern(pattern.left);
|
||||
case 'MemberExpression':
|
||||
// assignment pattern so no new variable is declared
|
||||
return [];
|
||||
default:
|
||||
assertNever(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
function lineCol(sourceCode: string, node: estree.Node): string {
|
||||
assertType<AstNode>(node);
|
||||
const leading = sourceCode.slice(0, node.start);
|
||||
const lines = leading.split('\n');
|
||||
const line = lines.length;
|
||||
const col = lines[lines.length - 1].length + 1; // +1 for 1-based index
|
||||
return `(${line}:${col})`;
|
||||
}
|
||||
|
||||
//region checker functions
|
||||
|
||||
type Node =
|
||||
| estree.AssignmentProperty
|
||||
| estree.CatchClause
|
||||
| estree.Class
|
||||
| estree.ClassBody
|
||||
| estree.Expression
|
||||
| estree.Function
|
||||
| estree.Identifier
|
||||
| estree.Literal
|
||||
| estree.MethodDefinition
|
||||
| estree.ModuleDeclaration
|
||||
| estree.ModuleSpecifier
|
||||
| estree.Pattern
|
||||
| estree.PrivateIdentifier
|
||||
| estree.Program
|
||||
| estree.Property
|
||||
| estree.PropertyDefinition
|
||||
| estree.SpreadElement
|
||||
| estree.Statement
|
||||
| estree.Super
|
||||
| estree.SwitchCase
|
||||
| estree.TemplateElement
|
||||
| estree.VariableDeclarator
|
||||
;
|
||||
|
||||
// localStorage.getItem("lang")
|
||||
function isLocalStorageGetItemLang(getItemCall: Node): boolean {
|
||||
if (getItemCall.type !== 'CallExpression') return false;
|
||||
if (getItemCall.arguments.length !== 1) return false;
|
||||
|
||||
const langLiteral = getItemCall.arguments[0];
|
||||
if (!isStringLiteral(langLiteral, 'lang')) return false;
|
||||
|
||||
const getItemFunction = getItemCall.callee;
|
||||
if (!isMemberExpression(getItemFunction, 'getItem')) return false;
|
||||
|
||||
const localStorageObject = getItemFunction.object;
|
||||
if (!isIdentifier(localStorageObject, 'localStorage')) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// await window.fetch(`/assets/locales/${d}.${x}.json`).then(u => u.json(), ....)
|
||||
function isAwaitFetchLocaleThenJson(awaitNode: Node): boolean {
|
||||
if (awaitNode.type !== 'AwaitExpression') return false;
|
||||
|
||||
const thenCall = awaitNode.argument;
|
||||
if (thenCall.type !== 'CallExpression') return false;
|
||||
if (thenCall.arguments.length < 1) return false;
|
||||
|
||||
const arrowFunction = thenCall.arguments[0];
|
||||
if (arrowFunction.type !== 'ArrowFunctionExpression') return false;
|
||||
if (arrowFunction.params.length !== 1) return false;
|
||||
|
||||
const arrowBodyCall = arrowFunction.body;
|
||||
if (arrowBodyCall.type !== 'CallExpression') return false;
|
||||
|
||||
const jsonFunction = arrowBodyCall.callee;
|
||||
if (!isMemberExpression(jsonFunction, 'json')) return false;
|
||||
|
||||
const thenFunction = thenCall.callee;
|
||||
if (!isMemberExpression(thenFunction, 'then')) return false;
|
||||
|
||||
const fetchCall = thenFunction.object;
|
||||
if (fetchCall.type !== 'CallExpression') return false;
|
||||
if (fetchCall.arguments.length !== 1) return false;
|
||||
|
||||
// `/assets/locales/${d}.${x}.json`
|
||||
const assetLocaleTemplate = fetchCall.arguments[0];
|
||||
if (assetLocaleTemplate.type !== 'TemplateLiteral') return false;
|
||||
if (assetLocaleTemplate.quasis.length !== 3) return false;
|
||||
if (assetLocaleTemplate.expressions.length !== 2) return false;
|
||||
if (assetLocaleTemplate.quasis[0].value.cooked !== '/assets/locales/') return false;
|
||||
if (assetLocaleTemplate.quasis[1].value.cooked !== '.') return false;
|
||||
if (assetLocaleTemplate.quasis[2].value.cooked !== '.json') return false;
|
||||
|
||||
const fetchFunction = fetchCall.callee;
|
||||
if (!isMemberExpression(fetchFunction, 'fetch')) return false;
|
||||
const windowObject = fetchFunction.object;
|
||||
if (!isIdentifier(windowObject, 'window')) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type SpecifierResult =
|
||||
| { type: 'no-import' }
|
||||
| { type: 'no-specifiers', importNode: estree.ImportDeclaration & AstNode }
|
||||
| { type: 'unexpected-specifiers', importNode: estree.ImportDeclaration & AstNode }
|
||||
| { type: 'specifier', localI18nIdentifier: string, importNode: estree.ImportDeclaration & AstNode }
|
||||
;
|
||||
|
||||
function findImportSpecifier(programNode: ProgramNode, i18nFileName: string, i18nSymbol: string): SpecifierResult {
|
||||
const imports = programNode.body.filter(x => x.type === 'ImportDeclaration');
|
||||
const importNode = imports.find(x => x.source.value === `./${i18nFileName}`) as estree.ImportDeclaration | undefined;
|
||||
if (!importNode) return { type: 'no-import' };
|
||||
assertType<AstNode>(importNode);
|
||||
|
||||
if (importNode.specifiers.length === 0) {
|
||||
return { type: 'no-specifiers', importNode };
|
||||
}
|
||||
|
||||
if (importNode.specifiers.length !== 1) {
|
||||
return { type: 'unexpected-specifiers', importNode };
|
||||
}
|
||||
const i18nImportSpecifier = importNode.specifiers[0];
|
||||
if (i18nImportSpecifier.type !== 'ImportSpecifier') {
|
||||
return { type: 'unexpected-specifiers', importNode };
|
||||
}
|
||||
|
||||
if (i18nImportSpecifier.imported.type !== 'Identifier') {
|
||||
return { type: 'unexpected-specifiers', importNode };
|
||||
}
|
||||
|
||||
const importingIdentifier = i18nImportSpecifier.imported.name;
|
||||
if (importingIdentifier !== i18nSymbol) {
|
||||
return { type: 'unexpected-specifiers', importNode };
|
||||
}
|
||||
const localI18nIdentifier = i18nImportSpecifier.local.name;
|
||||
return { type: 'specifier', localI18nIdentifier, importNode };
|
||||
}
|
||||
|
||||
// checker helpers
|
||||
function isMemberExpression(node: Node, property: string): node is estree.MemberExpression {
|
||||
return node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier' && node.property.name === property;
|
||||
}
|
||||
|
||||
function isStringLiteral(node: Node, value: string): node is estree.Literal {
|
||||
return node.type === 'Literal' && typeof node.value === 'string' && node.value === value;
|
||||
}
|
||||
|
||||
function isIdentifier(node: Node, name: string): node is estree.Identifier {
|
||||
return node.type === 'Identifier' && node.name === name;
|
||||
}
|
||||
|
||||
//endregion
|
73
packages/frontend-builder/logger.ts
Normal file
73
packages/frontend-builder/logger.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as process from 'node:process';
|
||||
|
||||
const debug = process.env.BUILDER_DEBUG !== undefined && process.env.BUILDER_DEBUG !== '0';
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string): void;
|
||||
|
||||
warn(message: string): void;
|
||||
|
||||
error(message: string): void;
|
||||
|
||||
info(message: string): void;
|
||||
|
||||
prefixed(newPrefix: string): Logger;
|
||||
}
|
||||
|
||||
interface RootLogger extends Logger {
|
||||
warningCount: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
export function createLogger(): RootLogger {
|
||||
return loggerFactory('', {
|
||||
warningCount: 0,
|
||||
errorCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
type LogContext = {
|
||||
warningCount: number;
|
||||
errorCount: number;
|
||||
};
|
||||
|
||||
function loggerFactory(prefix: string, context: LogContext): RootLogger {
|
||||
return {
|
||||
debug: (message: string) => {
|
||||
if (debug) console.log(`[DBG] ${prefix}${message}`);
|
||||
},
|
||||
warn: (message: string) => {
|
||||
context.warningCount++;
|
||||
console.log(`${debug ? '[WRN]' : 'w:'} ${prefix}${message}`);
|
||||
},
|
||||
error: (message: string) => {
|
||||
context.errorCount++;
|
||||
console.error(`${debug ? '[ERR]' : 'e:'} ${prefix}${message}`);
|
||||
},
|
||||
info: (message: string) => {
|
||||
console.error(`${debug ? '[INF]' : 'i:'} ${prefix}${message}`);
|
||||
},
|
||||
prefixed: (newPrefix: string) => {
|
||||
return loggerFactory(`${prefix}${newPrefix}`, context);
|
||||
},
|
||||
get warningCount() {
|
||||
return context.warningCount;
|
||||
},
|
||||
get errorCount() {
|
||||
return context.errorCount;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const blankLogger: Logger = {
|
||||
debug: () => void 0,
|
||||
warn: () => void 0,
|
||||
error: () => void 0,
|
||||
info: () => void 0,
|
||||
prefixed: () => blankLogger,
|
||||
};
|
25
packages/frontend-builder/package.json
Normal file
25
packages/frontend-builder/package.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "frontend-builder",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./js/*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/node": "22.17.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.38.0",
|
||||
"@typescript-eslint/parser": "8.38.0",
|
||||
"rollup": "4.46.2",
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"estree-walker": "3.0.3",
|
||||
"magic-string": "0.30.17",
|
||||
"vite": "7.0.6"
|
||||
}
|
||||
}
|
53
packages/frontend-builder/rollup-plugin-remove-unref-i18n.ts
Normal file
53
packages/frontend-builder/rollup-plugin-remove-unref-i18n.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as estreeWalker from 'estree-walker';
|
||||
import MagicString from 'magic-string';
|
||||
import { assertType } from './utils.js';
|
||||
import type { Plugin } from 'vite';
|
||||
import type { CallExpression, Expression, Program } from 'estree';
|
||||
import type { AstNode } from 'rollup';
|
||||
|
||||
// This plugin transforms `unref(i18n)` to `i18n` in the code, which is useful for removing unnecessary unref calls
|
||||
// and helps locale inliner runs after vite build to inline the locale data into the final build.
|
||||
//
|
||||
// locale inliner cannot know minifiedSymbol(i18n) is 'unref(i18n)' or 'otherFunctionsWithEffect(i18n)' so
|
||||
// it is necessary to remove unref calls before minification.
|
||||
export function pluginRemoveUnrefI18n(
|
||||
{
|
||||
i18nSymbolName = 'i18n',
|
||||
}: {
|
||||
i18nSymbolName?: string
|
||||
} = {}): Plugin {
|
||||
return {
|
||||
name: 'UnwindCssModuleClassName',
|
||||
renderChunk(code) {
|
||||
if (!code.includes('unref(i18n)')) return null;
|
||||
const ast = this.parse(code) as Program;
|
||||
const magicString = new MagicString(code);
|
||||
estreeWalker.walk(ast, {
|
||||
enter(node) {
|
||||
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'unref'
|
||||
&& node.arguments.length === 1) {
|
||||
// calls to unref with single argument
|
||||
const arg = node.arguments[0];
|
||||
if (arg.type === 'Identifier' && arg.name === i18nSymbolName) {
|
||||
// this is unref(i18n) so replace it with i18n
|
||||
// to replace, remove the 'unref(' and the trailing ')'
|
||||
assertType<CallExpression & AstNode>(node);
|
||||
assertType<Expression & AstNode>(arg);
|
||||
magicString.remove(node.start, arg.start);
|
||||
magicString.remove(arg.end, node.end);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
return {
|
||||
code: magicString.toString(),
|
||||
map: magicString.generateMap({ hires: true }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue