diff --git a/.config/example.yml b/.config/example.yml index 9716915b67..f73f4f1d79 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -121,7 +121,7 @@ redis: # ┌─────────────────────┐ #───┘ Other configuration └───────────────────────────────────── -# Maximum length of a post (default 3000, max 250000000) +# Maximum length of a post (default 3000, max 100000) #maxNoteLength: 3000 # Maximum length of an image caption (default 1500, max 8192) diff --git a/.gitignore b/.gitignore index 6bf2ea1b14..ce9e4f8a1e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ coverage !/.config/helm_values_example.yml !/.config/LICENSE -#docker dev config +# docker dev config /dev/docker-compose.yml # misskey @@ -46,6 +46,7 @@ files ormconfig.json packages/backend/assets/instance.css packages/backend/assets/sounds/None.mp3 +packages/backend/assets/LICENSE !packages/backend/src/db diff --git a/CALCKEY.md b/CALCKEY.md index 619155238c..418ab27e97 100644 --- a/CALCKEY.md +++ b/CALCKEY.md @@ -6,23 +6,16 @@ ## Planned - Stucture - - [DragonflyDB](https://dragonflydb.io/) support as a Redis alternative - - Optionally use [ScyllaDB](https://www.scylladb.com/open-source-nosql-database/) for storing notes - Rewrite backend in Rust and [Rocket](https://rocket.rs/) - - Use [Magic RegExP](https://regexp.dev/) for RegEx 🦄 - Function - User "choices" (recommended users) and featured hashtags like Mastodon and Soapbox - Join Reason system like Mastodon/Pleroma - Option to publicize server blocks - More antenna options - Groups -- Form - - Lookup/details for post/file/server - - [Rat mode?](https://stop.voring.me/notes/933fx97bmd) ## Work in progress -- Link verification - Better Messaging UI - Better API Documentation - Remote follow button @@ -30,6 +23,7 @@ - Timeline filters - Events - Fully revamp non-logged-in screen +- Optionally use [ScyllaDB](https://www.scylladb.com/open-source-nosql-database/) for storing notes ## Implemented @@ -122,6 +116,9 @@ - Let moderators see moderation nodes - Non-mangled unicode emojis - Skin tone selection support +- [DragonflyDB](https://dragonflydb.io/) support as a Redis alternative +- Link verification +- Importing posts from other Calckey/Misskey/Mastodon/Akkoma/Pleroma instances ## Implemented (remote) diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 7e97a99ebc..59dda2bcfa 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1179,7 +1179,6 @@ _profile: youCanIncludeHashtags: "يمكنك أيضًا إضافة وسوم إلى سيرتك التعريفية." metadata: "معلومات إضافية" metadataEdit: "عدّل المعلومات الإضافية" - metadataDescription: "يُمكنك عرض 4 حقول معلومات في ملفك الشخصي" metadataLabel: "التسمية" metadataContent: "المحتوى" changeAvatar: "غيّر الصورة الرمزية" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index e3fbf8cb9b..2f37a02a2e 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -1268,7 +1268,7 @@ _profile: youCanIncludeHashtags: "হ্যাশট্যাগ অন্তর্ভুক্ত করা যেতে পারে।" metadata: "অতিরিক্ত তথ্য" metadataEdit: "অতিরিক্ত তথ্য সম্পাদনা করুন" - metadataDescription: "আপনি আপনার প্রোফাইলে একটি টেবিল হিসাবে চারটি অতিরিক্ত তথ্য দেখাতে পারেন।" + metadataDescription: "আপনি আপনার প্রোফাইলে একটি টেবিল হিসাবে চারটি অতিরিক্ত তথ্য দেখাতে পারেন।. আপনি আপনার প্রোফাইলে লিঙ্কটি যাচাই করতে {rel} এর সাথে একটি {a} ট্যাগ বা {l} ট্যাগ যোগ করতে পারেন!" metadataLabel: "লেবেল" metadataContent: "বিষয়বস্তু" changeAvatar: "অ্যাভাটার পরিবর্তন করুন" diff --git a/locales/bul_BG.yml b/locales/bul_BG.yml new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/locales/bul_BG.yml @@ -0,0 +1 @@ +{} diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 4d265d7ee1..4626a832f2 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -409,8 +409,9 @@ _profile: locationDescription: Si primer introduïu la vostra ciutat, es mostrarà l'hora local a altres usuaris. name: Nom - metadataDescription: Fent servir això, podràs mostrar camps d'informació addicionals - al vostre perfil. + metadataDescription: "Fent servir això, podràs mostrar camps d'informació addicionals + al vostre perfil. Podeu afegir una etiqueta {a} o una etiqueta {l} amb {rel} per + verificar l'enllaç al vostre perfil!" _exportOrImport: followingList: "Usuaris que segueixes" muteList: "Silencia" @@ -1609,6 +1610,7 @@ _aboutMisskey: pleaseDonateToHost: Penseu també en fer una donació a la vostre instància, {host}, per ajudar-lo a suportar els costos de funcionament. donateHost: Fes una donació a {host} + sponsors: Patrocinadors de Calckey unknown: Desconegut pageLikesCount: Nombre de pàgines amb M'agrada youAreRunningUpToDateClient: Estás fent servir la versió del client més nova. @@ -2160,3 +2162,4 @@ remindMeLater: Potser després removeMember: Elimina el membre removeQuote: Elimina la cita removeRecipient: Elimina el destinatari +verifiedLink: Enllaç verificat diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 9e3a07654f..c2c89f15c0 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -1551,7 +1551,7 @@ _profile: metadata: "Zusätzliche Informationen" metadataEdit: "Zusätzliche Informationen bearbeiten" metadataDescription: "Hierdurch kannst du auf deinem Profil zusätzliche Informationsblöcke - anzeigen lassen." + anzeigen lassen. Sie können ein {a}-Tag oder ein {l}-Tag mit {rel} hinzufügen, um den Link in Ihrem Profil zu überprüfen!" metadataLabel: "Beschriftung" metadataContent: "Inhalt" changeAvatar: "Profilbild ändern" diff --git a/locales/en-US.yml b/locales/en-US.yml index 76bc5c24a9..252092adfd 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -177,7 +177,7 @@ flagShowTimelineRepliesDescription: "Shows replies of users to posts of other us autoAcceptFollowed: "Automatically approve follow requests from users you're following" addAccount: "Add account" loginFailed: "Failed to sign in" -showOnRemote: "View on remote server" +showOnRemote: "Open original page" general: "General" accountMoved: "User has moved to a new account:" wallpaper: "Wallpaper" @@ -1124,6 +1124,7 @@ remindMeLater: "Maybe later" removeQuote: "Remove quote" removeRecipient: "Remove recipient" removeMember: "Remove member" +verifiedLink: "Verified link" _sensitiveMediaDetection: description: "Reduces the effort of server moderation through automatically recognizing @@ -1676,8 +1677,10 @@ _profile: youCanIncludeHashtags: "You can also include hashtags in your bio." metadata: "Additional Information" metadataEdit: "Edit additional Information" - metadataDescription: "Using these, you can display additional information fields - in your profile." + metadataDescription: + "Using these, you can display additional information fields + in your profile. You can add an {a} tag or {l} tag with {rel} + to verify the link on your profile!" metadataLabel: "Label" metadataContent: "Content" changeAvatar: "Change avatar" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 0bd874f331..f5c0025056 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -642,7 +642,7 @@ wordMute: "Silenciar palabras" regexpError: "Error de la expresión regular" regexpErrorDescription: "Ocurrió un error en la expresión regular en la linea {line} de las palabras muteadas {tab}" -instanceMute: "Instancias silenciadas" +instanceMute: "Servidores silenciados" userSaysSomething: "{name} dijo algo" makeActive: "Activar" display: "Apariencia" @@ -671,14 +671,14 @@ sample: "Muestra" abuseReports: "Reportes" reportAbuse: "Reportar" reportAbuseOf: "Reportar a {name}" -fillAbuseReportDescription: "Ingrese los detalles del reporte. Si hay una nota en - particular, ingrese la URL de esta." +fillAbuseReportDescription: "Ingrese los detalles del reporte. Si hay una publicación + en particular, ingrese la URL de esta." abuseReported: "Se ha enviado el reporte. Muchas gracias." reporter: "Reportador" reporteeOrigin: "Reportar a" reporterOrigin: "Origen del reporte" -forwardReport: "Transferir un informe a una instancia remota" -forwardReportIsAnonymous: "No puede ver su información de la instancia remota y aparecerá +forwardReport: "Transferir reporte a un servidor remoto" +forwardReportIsAnonymous: "No puede ver su información del servidor remoto y aparecerá como una cuenta anónima del sistema" send: "Enviar" abuseMarkAsResolved: "Marcar reporte como resuelto" @@ -686,7 +686,7 @@ openInNewTab: "Abrir en una Nueva Pestaña" openInSideView: "Abrir en una vista al costado" defaultNavigationBehaviour: "Navegación por defecto" editTheseSettingsMayBreakAccount: "Editar estas configuraciones puede dañar su cuenta." -instanceTicker: "Información de notas de la instancia" +instanceTicker: "Información de publicaciones de el servidor" waitingFor: "Esperando a {x}" random: "Aleatorio" system: "Sistema" @@ -697,14 +697,14 @@ createNew: "Crear" optional: "Opcional" createNewClip: "Crear clip nuevo" unclip: "Quitar clip" -confirmToUnclipAlreadyClippedNote: "Esta nota ya está incluida en el clip \"{name}\"\ - . ¿Quiere quitar la nota del clip?" +confirmToUnclipAlreadyClippedNote: "Esta publicación ya está incluida en el clip \"\ + {name}\". ¿Quiere quitar la nota del clip?" public: "Público" i18nInfo: "Calckey está siendo traducido a varios idiomas gracias a voluntarios. Se puede colaborar traduciendo en {link}" manageAccessTokens: "Administrar tokens de acceso" accountInfo: "Información de la Cuenta" -notesCount: "Cantidad de notas" +notesCount: "Cantidad de publicaciones" repliesCount: "Cantidad de respuestas hechas" renotesCount: "Cantidad de renotas hechas" repliedCount: "Cantidad de respuestas recibidas" @@ -720,7 +720,7 @@ no: "No" driveFilesCount: "Cantidad de archivos en el drive" driveUsage: "Uso del drive" noCrawle: "Rechazar indexación del crawler" -noCrawleDescription: "Pedir a los motores de búsqueda que no indexen tu perfil, notas, +noCrawleDescription: "Pedir a los motores de búsqueda que no indexen tu perfil, publicaciones, páginas, etc." lockedAccountInfo: "A menos que configures la visibilidad de tus notas como \"Sólo seguidores\", tus notas serán visibles para cualquiera, incluso si requieres que @@ -734,7 +734,7 @@ verificationEmailSent: "Se le ha enviado un correo electrónico de confirmación configuración." notSet: "Sin especificar" emailVerified: "Su dirección de correo electrónico ha sido verificada." -noteFavoritesCount: "Número de notas favoritas" +noteFavoritesCount: "Número de publicaciones favoritas" pageLikesCount: "Número de favoritos en la página" pageLikedCount: "Número de favoritos de su página" contact: "Contacto" @@ -975,7 +975,7 @@ shuffle: "Aleatorio" account: "Cuentas" move: "Mover" _sensitiveMediaDetection: - description: "Reduce el esfuerzo de la moderación el el servidor a través del reconocimiento + description: "Reduce el esfuerzo de la moderación de el servidor a través del reconocimiento automático de contenido NSFW usando 'Machine Learning'. Esto puede incrementar ligeramente la carga en el servidor." sensitivity: "Sensibilidad de detección" @@ -1295,7 +1295,7 @@ _time: _tutorial: title: "Cómo usar Calckey" step1_1: "¡Bienvenido!" - step1_2: "Vamos a configurarte. Estarás listo y funcionando en poco tiempo" + step1_2: "Vamos a configurarte. ¡Estarás listo y funcionando en poco tiempo!" step2_1: "En primer lugar, rellena tu perfil" step2_2: "Proporcionar algo de información sobre quién eres hará que sea más fácil para los demás saber si quieren ver tus notas o seguirte." @@ -1475,7 +1475,7 @@ _profile: youCanIncludeHashtags: "Puedes añadir hashtags" metadata: "información adicional" metadataEdit: "Editar información adicional" - metadataDescription: "Muestra la información adicional en el perfil" + metadataDescription: "Muestra la información adicional en el perfil. ¡Puede agregar una etiqueta {a} o una etiqueta {l} con {rel} para verificar el enlace en su perfil!" metadataLabel: "Etiqueta" metadataContent: "Contenido" changeAvatar: "Cambiar avatar" @@ -1789,7 +1789,7 @@ _pages: splitStrByLine: "Separar texto en lineas" _splitStrByLine: arg1: "Texto" - ref: "Variables" + ref: "Variable" aiScriptVar: "Variable de AiScript" fn: "funciones" _fn: @@ -1800,8 +1800,8 @@ _pages: _for: arg1: "Cantidad de repeticiones" arg2: "Acción" - typeError: "El slot {slot} acepta el tipo {expect} pero fue ingresado el tipo - {actual}" + typeError: "El slot {slot} acepta el tipo \"{expect}\" pero fue ingresado el tipo + \"{actual}\"" thereIsEmptySlot: "El slot {slot} está vacío" types: string: "Texto" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index f81ef4520a..660be0347a 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -1413,7 +1413,7 @@ _profile: metadata: "Informations supplémentaires" metadataEdit: "Éditer les informations supplémentaires" metadataDescription: "Vous pouvez afficher jusqu'à quatre informations supplémentaires - dans votre profil." + dans votre profil. Vous pouvez ajouter une balise {a} ou une balise {l} avec {rel} pour vérifier le lien sur votre profil!" metadataLabel: "Étiquette" metadataContent: "Contenu" changeAvatar: "Changer l'image de profil" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 17bebe99cf..6aaa726dba 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -1399,7 +1399,7 @@ _profile: metadata: "Informasi tambahan" metadataEdit: "Sunting informasi tambahan" metadataDescription: "Kamu dapat menampilkan hingga 4 bagian informasi tambahan\ - \ ke dalam profilmu." + \ ke dalam profilmu. Anda dapat menambahkan tag {a} atau tag {l} dengan {rel} untuk memverifikasi tautan di profil Anda!" metadataLabel: "Label" metadataContent: "Isi" changeAvatar: "Ubah avatar" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index bdf7cab541..93dd5fcf3c 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -1266,7 +1266,7 @@ _profile: metadata: "Informazioni aggiuntive" metadataEdit: "Modifica informazioni aggiuntive" metadataDescription: "Puoi pubblicare fino a quattro informazioni aggiuntive sul - profilo." + profilo. Puoi aggiungere un tag {a} o {l} con {rel} per verificare il link sul tuo profilo!" metadataLabel: "Etichetta" metadataContent: "Contenuto" changeAvatar: "Modifica immagine profilo" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1043e35248..755afa8d3a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1491,7 +1491,7 @@ _profile: youCanIncludeHashtags: "ハッシュタグを含められます。" metadata: "追加情報" metadataEdit: "追加情報を編集" - metadataDescription: "プロフィールに表として追加情報を表示できます。" + metadataDescription: "プロフィールに表として追加情報を表示できます。{a}タグまたは{l}タグを{rel}とともに追加すると、プロフィールのリンクを確認できます。" metadataLabel: "ラベル" metadataContent: "内容" changeAvatar: "アバター画像を変更" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 2c8e548bde..459274166c 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1319,7 +1319,7 @@ _profile: youCanIncludeHashtags: "해시 태그를 포함할 수 있습니다." metadata: "추가 정보" metadataEdit: "추가 정보 편집" - metadataDescription: "프로필에 추가 정보를 표시할 수 있어요" + metadataDescription: "프로필에 추가 정보를 표시할 수 있어요. {rel}과 함께 {a} 태그 또는 {l} 태그를 추가하여 프로필의 링크를 확인할 수 있습니다!" metadataLabel: "라벨" metadataContent: "내용" changeAvatar: "아바타 이미지 변경" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 571f6af951..58624303c7 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1404,7 +1404,7 @@ _profile: metadata: "Dodatkowe informacje" metadataEdit: "Edytuj dodatkowe informacje" metadataDescription: "Możesz wyświetlać do czterech sekcji dodatkowych informacji - na swoim profilu." + na swoim profilu. Możesz dodać tag {a} lub tag {l} z {rel}, aby zweryfikować link w swoim profilu!" metadataLabel: "Etykieta" metadataContent: "Treść" changeAvatar: "Zmień awatar" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index bfef68d106..643a84eef0 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -1398,7 +1398,7 @@ _profile: youCanIncludeHashtags: "Можете использовать здесь хэштеги." metadata: "Дополнительные сведения" metadataEdit: "Редактировать дополнительные сведения" - metadataDescription: "Можно добавить до четырёх дополнительных граф в профиль." + metadataDescription: "Можно добавить до четырёх дополнительных граф в профиль. Вы можете добавить тег {a} или тег {l} с {rel}, чтобы подтвердить ссылку в своем профиле!" metadataLabel: "Метка" metadataContent: "Содержимое" changeAvatar: "Поменять аватар" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index dce23d7558..046e67aec9 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -1337,7 +1337,7 @@ _profile: youCanIncludeHashtags: "Vo svojom bio môžete mať aj hashtagy." metadata: "Dodatočné informácie" metadataEdit: "Upraviť dodatočné informácie" - metadataDescription: "Vo svojom profile môžete uviesť až štyri dodatočné informačné polia." + metadataDescription: "Vo svojom profile môžete uviesť až štyri dodatočné informačné polia. Dodate lahko oznako {a} ali oznako {l} z {rel}, da preverite povezavo v svojem profile!" metadataLabel: "Popisok" metadataContent: "Obsah" changeAvatar: "Zmeniť avatara" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index 82da6f649c..f73d693a80 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -1,9 +1,6 @@ _lang_: "Türkçe" -introMisskey: "Açık kaynaklı bir dağıtılmış mikroblog hizmeti olan Calckey'e hoş geldiniz.\n - Misskey, neler olup bittiğini paylaşmak ve herkese sizden bahsetmek için \"notlar\"\ - \ oluşturmanıza olanak tanıyan, açık kaynaklı, dağıtılmış bir mikroblog hizmetidir.\n - Herkesin notlarına kendi tepkilerinizi hızlıca eklemek için \"Tepkiler\" özelliğini - de kullanabilirsiniz👍.\nYeni bir dünyayı keşfedin🚀." +introMisskey: "Hoş geldin! Calckey, sonsuza kadar ücretsiz olan, açık kaynaklı, merkezi + olmayan bir sosyal medya platformudur! 🚀" monthAndDay: "{month}Ay {day}Gün" search: "Arama" notifications: "Bildirimler" @@ -11,10 +8,10 @@ username: "Kullanıcı Adı" password: "Şifre" forgotPassword: "şifremi unuttum" ok: "TAMAM" -gotIt: "Anladım" +gotIt: "Anladım!" cancel: "İptal" enterUsername: "Kullanıcı adınızı giriniz" -noNotes: "Gönderiler mevcut değil." +noNotes: "Gönderiler mevcut değil" noNotifications: "Bildirim bulunmuyor" settings: "Ayarlar" basicSettings: "Temel Ayarlar" @@ -22,11 +19,11 @@ otherSettings: "Diğer Ayarlar" openInWindow: "Bir pencere ile aç" profile: "Profil" timeline: "Zaman çizelgesi" -noAccountDescription: "Bu kullanıcı henüz biyografisini yazmadı" -login: "Giriş Yap " +noAccountDescription: "Bu kullanıcı henüz hakkındasını yazmadı." +login: "Giriş Yap" logout: "Çıkış Yap" signup: "Kayıt Ol" -uploading: "Yükleniyor" +uploading: "Yükleniyor..." users: "Kullanıcı" addUser: "Kullanıcı Ekle" favorite: "Favoriler" @@ -54,17 +51,175 @@ user: "Kullanıcı" searchByGoogle: "Arama" _mfm: search: "Arama" + play: MFM'i çal + stop: MFM'i durdur + cheatSheet: MFM Kopya Kağıdı + intro: MFM, Misskey, Calckey, Akkoma ve daha pek çok yerde kullanılabilen bir biçimlendirme + dilidir. Burada mevcut tüm MFM sözdiziminin bir listesini görüntüleyebilirsiniz. + link: Link + boldDescription: Harfleri kalınlaştırarak vurgular. + small: Küçük + smallDescription: İçeriği küçük ve ince görüntüler. + warn: MFM, hızla hareket eden veya gösterişli animasyonlar içerebilir + alwaysPlay: Her zaman tüm animasyonlu MFM'yi otomatik oynat + x4Description: İçeriği büyükten de büyükten daha büyük görüntüler. + rainbowDescription: İçeriğin gökkuşağı renklerinde görünmesini sağlar. + bounceDescription: İçeriğe sıçarayan bir animasyon verir. + sparkle: Işıltı + sparkleDescription: İçeriğe ışıltılı bir parçacık efekti verir. + rotateDescription: İçeriği belirli bir açıyla döndürür. + fadeDescription: İçeriği içeri ve dışarı karartır. + fade: Karart + position: Pozisyon + blockCode: Kod (Blok) + crop: Kırp + positionDescription: İçeriği belirli bir miktarda taşıyın. + scale: Ölçek + scaleDescription: İçeriği belirtilen bir miktara göre ölçeklendirin. + foreground: Ön plan rengi + mention: Bahset + mentionDescription: Bir et-sembolü (@) ve bir kullanıcı adı kullanarak bir kullanıcı + belirleyebilirsiniz. + hashtag: Etiket + dummy: Calckey, Fediverse dünyasını genişletiyor + hashtagDescription: Sayı işareti ve metin kullanarak bir etiket belirtebilirsiniz. + url: URL + urlDescription: URL'ler görüntülenebilir. + inlineMath: Matematik (Satır İçi) + blockCodeDescription: Bir blokta çok satırlı (program) kod için sözdizimi vurgulamasını + görüntüler. + inlineMathDescription: Matematik formüllerini (KaTeX) satır içinde görüntüleyin + quote: Alıntı + quoteDescription: İçeriği alıntı olarak görüntüler. + twitch: Animasyon (Seğir) + emoji: Özel Emoji + jelly: Animasyon (Jöle) + blur: Bulanık + blurDescription: İçeriği bulanıklaştırır. Fareyle üzerine gelindiğinde net bir şekilde + görüntülenecektir. + spinDescription: İçeriğe dönen bir animasyon verir. + plainDescription: Bu MFM efektinde bulunan tüm MFM'lerin etkilerini devre dışı bırakır. + background: Arka plan rengi + backgroundDescription: Metnin arka plan rengini değiştirin. + jump: Animasyon (Zıpla) + cropDescription: İçeriği kırpar. + advancedDescription: Devre dışı bırakılırsa, animasyonlu MFM oynatılmadığı sürece + yalnızca temel işaretlemeye izin verir + bold: Kalın + inlineCodeDescription: (Program) kodu için satır içi sözdizimi vurgulamasını görüntüler. + flip: Tersine Çevir + flipDescription: İçeriği yatay veya dikey olarak çevirir. + font: Yazı Tipi + twitchDescription: İçeriğe güçlü bir şekilde seğiren bir animasyon verir. + spin: Animasyon (Dön) + x2Description: İçeriği büyük gösterir. + rotate: Döndür + plain: Düz + linkDescription: Metnin belirli bölümleri bir URL olarak görüntülenebilir. + searchDescription: Önceden girilmiş metin içeren bir arama kutusu görüntüler. + blockMathDescription: Matematik formüllerini (KaTeX) bir blokta görüntüleyin + jumpDescription: İçeriğe zıplama animasyonu verir. + rainbow: Gökkuşağı + x4: İnanılmaz derecede büyük + tadaDescription: İçeriğe "Tada!" benzeri bir animasyon verir. + shake: Animasyon (Salla) + x3: Büyük göster + blockMath: Matematik (Blok) + x2: Büyük + fontDescription: İçeriğin görüntüleneceği yazı tipini ayarlar. + foregroundDescription: Metnin ön plan rengini değiştirin. + centerDescription: İçeriği ortada görüntüler. + inlineCode: Kod (Satır İçi) + advanced: Gelişmiş MFM + center: Ortala + x3Description: İçeriği daha büyük gösterir. + tada: Animasyon (Tada) + emojiDescription: Özel bir emoji adını iki nokta ile çevreleyerek, özel emoji görüntülenebilir. + jellyDescription: İçeriğe jöle benzeri bir animasyon verir. + shakeDescription: İçeriğe sallanan bir animasyon verir. + bounce: Animasyon (Sıçra) _sfx: notification: "Bildirim" + noteMy: Kendi Gönderim + note: Yeni gönderi + antenna: Anten + chat: Sohbet + channel: Kanal bildirimleri + chatBg: Sohbet (Arkaplan) _widgets: notifications: "Bildirim" timeline: "Zaman çizelgesi" + photos: Fotoğraflar + userList: Kullanıcı Listesi + _userList: + chooseList: Liste seç + onlineUsers: Aktif Kullanıcılar + aiscript: AiScript Konsolu + activity: Aktivite + digitalClock: Dijital Saat + unixClock: UNIX Saati + meiliIndexCount: Indexlenmiş gönderiler + calendar: Takvim + trends: Popüler + memo: Yapışkan Notlar + rssTicker: RSS Ticker + federation: Federasyon + instanceCloud: Sunucu Bulutu + postForm: Gönderi Formu + meiliSize: Index boyutu + slideshow: Slayt Gösterisi + button: Düğme + clock: Saat + rss: RSS Okuyucu + serverInfo: Sunucu Bilgisi + meiliStatus: Sunucu Durumu + jobQueue: İş Sırası + serverMetric: Sunucu Bilgileri _profile: username: "Kullanıcı Adı" + changeBanner: Afişini değiştir + locationDescription: Önce şehrinizi girerseniz, yerel saatinizi diğer kullanıcılara + gösterecektir. + youCanIncludeHashtags: Hakkımdan'da etiket kullanabilirsin. + description: Hakkımda + metadataDescription: 'Bunları kullanarak profilinizde ek bilgi alanları görüntüleyebilirsiniz. Profilinizdeki bağlantıyı doğrulamak için {rel} ile bir {a} etiketi veya {l} etiketi ekleyebilirsiniz!' + metadata: Ek Bilgi + metadataContent: İçerik + metadataLabel: Etiket + changeAvatar: Avatarını değiştir + name: İsim + metadataEdit: Ek Bilgini Düzenle _deck: _columns: notifications: "Bildirim" tl: "Zaman çizelgesi" + antenna: Anten + list: Liste + widgets: Araçlar + channel: Kanal + direct: Direkt mesajlar + main: Ana + mentions: Bahsetmeler + swapLeft: Sol sütunla değiştir + addColumn: Sütun ekle + configureColumn: Sütun ayarları + swapRight: Sağ sütunla değiştir + swapUp: Üstteki sütunla değiştir + stackLeft: Sol sütunla birleştir + swapDown: Alttaki sütunla değiştir + popRight: Sağdaki sütunu aç + introduction2: İstediğiniz zaman yeni sütunlar eklemek için ekranın sağındaki + + işaretini tıklayın. + alwaysShowMainColumn: Her zaman ana sütunu göster + columnAlign: Sütunları hizala + profile: Çalışma alanı + newProfile: Yeni çalışma alanı + renameProfile: Çalışma alanını yeniden adlandır + deleteProfile: Çalışma alanını sil + nameAlreadyExists: Bu çalışma alanı zaten mevcut. + introduction: Sütunları özgürce düzenleyerek sizin için mükemmel arayüzü oluşturun! + widgetsIntroduction: Lütfen sütun menüsünde "Araç'ları düzenle"yi seçin ve bir widget + ekleyin. searchPlaceholder: Calckey'de Ara reply: Yanıtla jumpToPrevious: Öncekini görüntüle @@ -80,7 +235,7 @@ headlineMisskey: Sonsuza kadar ücretsiz, açık kaynak kodlu, merkeziyetsiz sos platformu! 🚀 loadMore: Daha fazla yükle instance: Sunucu -fetchingAsApObject: Fedevren'den çekiliyor +fetchingAsApObject: Fediverse'den çekiliyor removeReaction: Tepkini sil rememberNoteVisibility: Gönderi görünürlüğü ayarlarını hatırla attachCancel: Eklentiyi kaldır @@ -279,7 +434,7 @@ lockedAccountInfo: Gönderi görünürlüğünüzü "Yalnızca takipçiler" olar görülebilir. unlikeConfirm: Beğeniyi kaldırmak istiyor musunuz? notSpecifiedMentionWarning: Bu gönderi, alıcı olarak dahil edilmeyen kullanıcılardan - bahsetmektedir. + bahsetmektedir hideOnlineStatus: Çevrimiçi bilgisini gizle hideOnlineStatusDescription: Çevrimiçi durumunuzu gizlemek, arama gibi bazı özelliklerin rahatlığını azaltır. @@ -505,7 +660,7 @@ driveCapacityPerLocalAccount: Kullanıcı başı Driver kapasitesi driveCapacityPerRemoteAccount: Uzak kullanıcı başı Driver kapasitesi inMb: Megabayt cinsinden pinnedClipId: Sabitlenecek atacın ID'si -withFiles: Dosyaları içer +withFiles: Dosya içeren recentlyRegisteredUsers: Yeni katılmış kullanıcılar recentlyDiscoveredUsers: Yeni keşfedilmiş kullanıcılar nUsersMentioned: '{n} kullanıcı tarafından bahsedildi' @@ -517,7 +672,7 @@ objectStorageRegionDesc: "'xx-east-1' gibi bir bölge belirtin. Hizmetiniz bölg arasında ayrım yapmıyorsa, bunu boş bırakın veya 'us-east-1' girin." objectStorageUseSSL: SSL Kullan popout: Açılır Pencere -volume: Ses +volume: Ses Kuvveti showInPage: Sayfada göster masterVolume: Ana ses undeck: Desteden çık @@ -565,7 +720,7 @@ moveAccountDescription: Bu süreç geri döndürülemez. Taşımadan önce yeni bu hesap için bir takma ad ayarladığınızdan emin olun. Lütfen @person@server.com şeklinde biçimlendirilmiş hesabın etiketini girin emojis: Emoji -flagAsCat: Kedi misin? +flagAsCat: Kedi misin? 😺 selectChannel: Kanal seç emojiName: Emoji adı showOnRemote: Uzak sunucuda görüntüle @@ -922,7 +1077,7 @@ manageAccounts: Hesapları Düzenle makeReactionsPublicDescription: Bu, tüm geçmiş tepkilerinizin listesini herkesin görebileceği bir hale getirecektir. unmuteThread: İleti dizisinin sesini aç -ffVisibility: Takipçiler/Takipçiler Görünürlüğü +ffVisibility: Takipler/Takipçiler Görünürlüğü reflectMayTakeTime: Bunun yansıması biraz zaman alabilir. cropImageAsk: Bu resmi kırpmak istediğinize emin misiniz? check: Kontrol Et @@ -1082,7 +1237,7 @@ objectStorageRegion: Region invisibleNote: Gizli Gönderi deletedNote: Silinmiş Gönderi visibility: Görünürlük -poll: Oylama +poll: Anket themeEditor: Tema düzenleyicisi enterFileDescription: Başlık gir description: Açıklama @@ -1109,7 +1264,7 @@ adminCustomCssWarn: Bu ayar yalnızca ne işe yaradığını biliyorsanız kulla emin olun. customSplashIcons: Özel açılış ekranı simgeleri (url'ler) recommendedInstancesDescription: Önerilen zaman çizelgesinde görünmesi için satır - sonlarıyla ayrılmış önerilen sunucular. `https://` EKLEMEYİN, YALNIZCA etki alanı. + sonlarıyla ayrılmış önerilen sunucular. recommendedInstances: Önerilen sunucular enableServerMachineStats: Sunucu donanımı istatistiklerini etkinleştir _sensitiveMediaDetection: @@ -1170,3 +1325,802 @@ sendModMail: Moderasyon Bildirimi Gönder noGraze: Calckey ile çakıştığı için lütfen "Graze for Mastodon" tarayıcı uzantısını devre dışı bırakın. isBot: Bu hesap bir bottur +_2fa: + renewTOTPOk: Yeniden Yapılandır + registerTOTP: Doğrulayıcı uygulamasını kaydedin + renewTOTPCancel: İptal Et + renewTOTPConfirm: Bu, önceki uygulamanızdaki doğrulama kodlarının çalışmamasına + neden olur + alreadyRegistered: Zaten bir 2 faktörlü kimlik doğrulama cihazını kaydettiniz. + chromePasskeyNotSupported: Chrome geçiş anahtarları şu anda desteklenmemektedir. + registerSecurityKey: Bir güvenlik veya geçiş anahtarı kaydedin + securityKeyName: Bir anahtar adı girin + removeKey: Güvenlik anahtarını kaldır + removeKeyConfirm: '{name} anahtarı gerçekten silinsin mi?' + renewTOTP: Kimlik doğrulayıcı uygulamasını yeniden yapılandırın + token: 2FA Tokeni + step1: Öncelikle, cihazınıza bir kimlik doğrulama uygulaması ({a} veya {b} gibi) + yükleyin. + step2Click: Bu QR koduna tıklamak, 2FA'yı güvenlik anahtarınıza veya telefon kimlik + doğrulayıcı uygulamanıza kaydetmenize olanak tanır. + step3Title: Bir kimlik doğrulama kodu girin + securityKeyNotSupported: Tarayıcınız güvenlik anahtarlarını desteklemiyor. + step2: Ardından, bu ekranda görüntülenen QR kodunu tarayın. + step2Url: "Bir masaüstü programı kullanıyorsanız bu URL'yi de girebilirsiniz:" + step3: Kurulumu tamamlamak için uygulamanız tarafından sağlanan tokeni girin. + step4: Şu andan itibaren, gelecekteki herhangi bir oturum açma denemesi böyle bir + oturum açma tokeni isteyecektir. + securityKeyInfo: Parmak izi veya PIN kimlik doğrulamasının yanı sıra, hesabınızın + güvenliğini daha da artırmak için FIDO2'yi destekleyen donanım güvenlik anahtarları + aracılığıyla kimlik doğrulama ayarlayabilirsiniz. + tapSecurityKey: Güvenlik veya geçiş anahtarını kaydetmek için lütfen tarayıcınızı + takip edin + registerTOTPBeforeKey: Bir güvenlik veya geçiş anahtarı kaydetmek için lütfen bir + kimlik doğrulama uygulaması kurun. + whyTOTPOnlyRenew: Kimlik doğrulayıcı uygulaması, bir güvenlik anahtarı kaydedildiği + sürece kaldırılamaz. +_poll: + voted: Oylandı + deadlineTime: Zaman + remainingDays: '{d} gün {h} saat kaldı' + remainingHours: '{h} saat {m} dakika kaldı' + remainingSeconds: '{s} saniye kaldı' + remainingMinutes: '{m} dakika {s} saniye kaldı' + noOnlyOneChoice: En az 2 seçenek gerekiyor + noMore: Daha fazla seçenek ekleyemezsin + at: Bitiş... + deadlineDate: Bitiş tarihi + duration: Süre + votesCount: '{n} oy' + expiration: Anketi bitir + totalVotes: toplam {n} oy + closed: Bitti + infinite: Asla + vote: Oyla + showResult: Sonuçları görüntüle + after: "'den sonra bitiş..." + choiceN: Seçenek {n} + canMultipleVote: Birden fazla seçime izin ver +_theme: + code: Tema kodu + description: Açıklama + builtinThemes: Yerleşik temalar + color: Renk + keys: + fgHighlighted: Vurgulanan Metin + infoWarnFg: Uyarı metni + mention: Bahsetme + mentionMe: Bahsetme (Kendim) + buttonBg: Düğme arka planı + buttonHoverBg: Düğme arka planı (Üstüne Gelince) + shadow: Gölge + navBg: Kenar çubuğu arka planı + accent: Vurgu + fg: Yazı + dateLabelFg: Tarih etiketi metni + navActive: Kenar çubuğu metni (Etkin) + wallpaperOverlay: Arkaplan arayüzü + messageBg: Sohbet arkaplanı + focus: Fokus + accentLighten: Vurgu (Aydınlık) + bg: Arkaplan + indicator: Gösterge + hashtag: Etiket + renote: Yükseltme + modalBg: Modal arka plan + divider: Bölücü + scrollbarHandle: Kaydırma çubuğu kolu + scrollbarHandleHover: Kaydırma çubuğu tutacağı (Üzerine Gelince) + infoBg: Bilgi geçmişi + accentDarken: Vurgu (Karanlık) + header: Başlık + navFg: Kenar çubuğu metni + navIndicator: Kenar çubuğu göstergesi + link: Link + infoFg: Bilgi metni + infoWarnBg: Uyarı arka planı + badge: Rozen + panel: Panel + navHoverFg: Kenar çubuğu metni (Üzerine Gelince) + cwBg: CW düğmesi arka planı + cwFg: CW düğmesi meni + cwHoverBg: CW düğmesi arka planı (Üzerine Gelince) + toastFg: Bildirim metni + inputBorder: Giriş alanı sınırı + listItemHoverBg: Liste öğesi arka planı (Üstüne Gelince) + toastBg: Bildirim arka planı + driveFolderBg: Drive klasörü arkaplanı + funcKind: Fonksiyon tipi + argument: Argüman + lighten: Aydınlat + inputConstantName: Bu sabit için bir ad girin + deleteConstantConfirm: '{const} sabitini gerçekten silmek istiyor musunuz?' + explore: Temaları Keşfet + darken: Karart + base: Temel + manage: Temaları düzenle + installedThemes: Yüklenen temalar + invalid: Bu temanın biçimi geçersiz + make: Tema oluştur + key: Anahtar + alpha: Opaklık + install: Tema yükle + installed: '{name} başarıyla yüklendi' + alreadyInstalled: Bu tema zaten yüklendi + importInfo: Buraya tema kodunu girerseniz, onu tema düzenleyiciye aktarabilirsiniz + func: Fonksiyonlar + basedProp: Referenslanan özellik + constant: Sabit + defaultValue: Varsayılan değer + refConst: Bir sabiti referansla + refProp: Bir mülkü referansla + addConstant: Sabit ekle +_menuDisplay: + sideIcon: Yan (Simgeler) + sideFull: Yan + hide: Gizle + top: Üst +_filters: + fromUser: Kullanıcıdan + withFile: Dosya ile + notesBefore: Gönderiden önce + notesAfter: Gönderiden sonra + followingOnly: Sadece takip ettiklerim + fromDomain: Alan adı ile + followersOnly: Sadece takipçiler +_permissions: + "write:blocks": Engelli kullanıcıları düzenle + "read:drive": Drive dosya ve klasörlerine eriş + "read:favorites": Yer imlerini görüntüle + "write:mutes": Susturulmuş kullanıcıları düzenle + "read:notifications": Bildirimleri görüntüle + "write:notifications": Bildirimleri düzenle + "write:page-likes": Sayfalardaki beğenilerini düzenle + "read:user-groups": Kullanıcı gruplarını göster + "write:reactions": Tepkilerini düzenle + "read:pages": Sayfalarını göster + "write:channels": Kanallarını düzenle + "read:gallery": Galerini göster + "read:gallery-likes": Beğenilen galeri gönderilerini göster + "write:gallery-likes": Galeri gönderilerini düzenle + "write:messaging": Sohbet mesajı oluştur veya sil + "write:user-groups": Kullanıcı gruplarını düzenle veya sil + "read:messaging": Sohbetlerini görüntüle + "read:mutes": Susturulmuş kullanıcıları göster + "write:votes": Bir ankete oy ver + "read:page-likes": Beğenilen sayfalarını göster + "read:reactions": Tepkilerini göster + "read:channels": Kanallarını göster + "write:notes": Gönderi oluştur veya sil + "write:drive": Drive dosya ve klasörlerini düzenle + "write:favorites": Yer imlerini düzenle + "read:following": Kimleri takip ettiğini göster + "write:account": Hesap bilgisini düzenle + "read:account": Hesap bilgisini görüntüle + "read:blocks": Engelli kullanıcıları gör + "write:following": Hesapları takip et veya takipten çıkar + "write:pages": Sayfalarını düzenle veya sil + "write:gallery": Galerini düzenle +_auth: + pleaseGoBack: Lütfen uygulamaya geri dönün + callback: Uygulamaya geri dönülüyor + shareAccess: '"{name}" adlı kişinin bu hesaba erişmesine izin vermek ister misiniz?' + permissionAsk: 'Bu uygulama aşağıdaki izinleri ister:' + allPermissions: Tam hesap erişimi + denied: Erişim reddedildi + copyAsk: 'Lütfen aşağıdaki yetkilendirme kodunu uygulamaya yapıştırın:' + shareAccessAsk: Bu uygulamanın hesabınıza erişmesine izin vermek istediğinizden + emin misiniz? +_antennaSources: + users: Belirli kullanıcılardan gönderiler + homeTimeline: Takip edilen kullanıcılardan gönderiler + all: Tüm gönderiler + instances: Bir sunucudaki tüm kullanıcılardan gelen gönderiler + userList: Belirli bir kullanıcı listesinden gönderiler + userGroup: Belirli bir gruptaki kullanıcıların gönderileri +_charts: + usersIncDec: Kullanıcı sayısı farkı + usersTotal: Toplam kullanıcı sayısı + remoteNotesIncDec: Uzak gönderilerin sayısındaki fark + notesTotal: Toplam gönderi sayısı + filesTotal: Toplam dosya sayısı + apRequest: İstekler + storageUsageIncDec: Depolama kullanımındaki fark + localNotesIncDec: Yerel gönderilerin sayısındaki fark + storageUsageTotal: Toplam depolama kullanımı + federation: Federasyon + notesIncDec: Gönderi sayısındaki fark + activeUsers: Aktif kullanıcılar + filesIncDec: Dosya sayısındaki fark +_pages: + fontSerif: Serif + fontSansSerif: Sans Serif + chooseBlock: Bloğu sil + blocks: + _canvas: + id: Tuval ID + height: Yükseklik + width: Genişlik + _button: + _action: + resetRandom: Rastgele çekirdeği sıfırla + _pushEvent: + no-variable: Hiçbiri + event: Etkinlik ismi + message: Aktif olduğunda gösterilecek mesaj + variable: Gönderilecek değişken + callAiScript: AiScript'i çağırın + _callAiScript: + functionName: Fonksiyon ismi + dialog: Dialog göster + _dialog: + content: İçerik + pushEvent: Etkinlik gönder + text: Başlık + action: Düğmeye basıldığında olacaklar + colored: Renkli + text: Yazı + if: Eğer + _if: + variable: Değişken + canvas: Tuval + note: Gömülü yazı + _note: + id: Gönderi ID + idDescription: Alternatif olarak gönderi URL'sini buraya yapıştırabilirsiniz. + detailed: Detaylı görüntüleme + _counter: + text: Başlık + inc: Adım + name: Değer ismi + radioButton: Seçenek + _radioButton: + name: Değişken ismi + values: Seçenekleri satırlarla ayırın + title: Başlık + default: Varsayılan değer + _post: + text: İçerik + attachCanvasImage: Tuval resmi ekle + canvasId: Tuval ID + _textInput: + text: Başlık + name: Değişken ismi + default: Varsayılan değer + _numberInput: + name: Değiken ismi + text: Başlık + default: Varsayılan değer + _textareaInput: + text: Başlık + name: Değişken ismi + default: Varsayılan değer + textarea: Yazı alanı + _switch: + name: Değişken ismi + default: Varsayılan değer + text: Başlık + counter: Sayaç + switch: Değiştir + post: Gönderi formu + image: Resimler + section: Bölüm + textareaInput: Çok satırlı yazı girişi + button: Düğme + textInput: Yazı girişi + numberInput: Sayısal giriş + script: + categories: + text: Yazı işlemleri + flow: Akış kontrolü + random: Rastgele + fn: Fonksiyonlar + convert: Dönüşümler + list: Listeler + logical: Mantıksal işlem + operation: Hesaplama + comparison: Karşılaştırma + value: Değerler + blocks: + and: A ve B + _or: + arg2: B + arg1: A + _lt: + arg1: A + arg2: B + _ltEq: + arg1: A + arg2: B + textList: Yazı listesi + strReverse: Yazıyı çevir + multiply: Çarp + subtract: Çıkar + _mod: + arg1: A + arg2: B + _divide: + arg2: B + arg1: A + round: Ondalık yuvarlama + _round: + arg1: Sayı + _eq: + arg1: A + arg2: B + notEq: A ve B farklıysa + _notEq: + arg1: A + arg2: B + or: A veya B + gt: "> A, B'den çoksa" + ltEq: <= A, B'den az veya eşitse + gtEq: ">= A, B'den çok veya eşitse" + _gtEq: + arg1: A + arg2: B + _not: + arg1: OLUMSUZ + random: Rastgele + randomPick: Listeden rastgele seç + seedRandom: Random (çekirdek ile) + _for: + arg1: Tekrarlama sayısı + arg2: Eylem + _seedRannum: + arg3: Maksimum değer + arg2: Minimum değer + arg1: Çekirdek + _strReplace: + arg3: ile değiştir + arg2: Değiştirilecek yazı + arg1: Yazı + _subtract: + arg2: B + arg1: A + mod: Kalan + _and: + arg1: A + arg2: B + _DRPWPM: + arg1: Yazı listesi + _fn: + slots-info: Her yuvayı bir satır sonu ile ayırın + arg1: Çıkış + slots: Yuvalar + for: dögü + dailyRandomPick: Listeden rastgele seçim yapın (Her kullanıcı için günde bir + kez değişir) + _dailyRannum: + arg1: Minimum değer + arg2: Maksimum değer + _seedRandomPick: + arg2: Liste + arg1: Çekirdek + _pick: + arg2: Pozisyon + arg1: Liste + number: Sayı + _if: + arg3: Yoksa + arg1: Eğer + arg2: Sonra + _rannum: + arg1: Minimum değer + arg2: Maksimum değer + eq: A ve B eşitse + _gt: + arg1: A + arg2: B + rannum: Rastgele sayı + _randomPick: + arg1: Liste + pick: Listeden seç + _listLen: + arg1: Liste + _multiply: + arg2: B + arg1: A + divide: Böl + strPick: Dize ayıklayın + _strPick: + arg1: Yazı + arg2: Dize konunumu + dailyRandom: Rastgele (Her kullanıcı için günde bir kez değişir) + _dailyRandom: + arg1: Olasılık + dailyRannum: Rastgele sayı (Her kullanıcı için günde bir kez değişir) + _stringToNumber: + arg1: Yazı + if: Şube + strReplace: Yedek dize + text: Yazı + _splitStrByLine: + arg1: Yazı + not: OLUMSUZ + _seedRandom: + arg1: Çekirdek + arg2: Olasılık + seedRandomPick: Listeden rastgele seçim yapın (çekirdek ile) + fn: Fonksiyon + multiLineText: Yazı (çok satırlı) + _textList: + info: Her girişi satırlar ile ayırın + _strReverse: + arg1: Yazı + join: Yazıyı birleştirme + _join: + arg1: Listeler + arg2: Ayraç + add: Ekle + _add: + arg1: A + arg2: B + _strLen: + arg1: Yazu + aiScriptVar: AiScript Değişkeni + ref: Değişken + splitStrByLine: Yazıyı satır sonlarına göre bölme + strLen: Yazı uzunluğu + lt: < A, B'den azsa + _random: + arg1: Olasılık + DRPWPM: Ağırlıklı listeden rastgele seçim yapın (Her kullanıcı için günde bir + kez değişir) + listLen: Listenin uzunluğunu al + numberToString: Sayıdan yazıya + _dailyRandomPick: + arg1: Liste + stringToNumber: Yazıdan Sayıya + seedRannum: Rastgele sayı (çekirdek ile) + _numberToString: + arg1: Sayı + types: + number: Sayı + boolean: Etiket + array: Liste + stringArray: Yazı listesi + string: Yazı + emptySlot: Boş yuva + enviromentVariables: Ortam değikenleri + argVariables: Giriş yuvaları + thereIsEmptySlot: Yuva {slot} boş! + typeError: Yuva {slot}, "{expect}" türündeki değerleri kabul eder, ancak sağlanan + değer "{actual}" türündedir! + pageVariables: Sayfa değişkenleri + readPage: Bu sayfanın kaynağını görüntüle + created: Sayfa başarıyla oluşturuldu + eyeCatchingImageRemove: Afişi sil + selectType: Tip seç + pageSetting: Sayfa ayarları + viewSource: Kaynağı görüntüle + variables: Değişkenler + url: Sayfa URL'si + unlike: Beğeniyi kaldır + my: Sayfalarım + content: Sayfa bloğu + deleted: Sayfa başarıyla silindi + newPage: Yeni sayfa oluştur + editPage: Bu sayfayı düzenle + viewPage: Sayfalarını görüntüle + like: Beğen + nameAlreadyExists: Belirtilen Sayfa URL'si zaten var + invalidNameTitle: Belirtilen Sayfa URL'si geçersiz + invalidNameText: Sayfa başlığının boş olmadığından emin olun + editThisPage: Sayfayı düzenle + featured: Popüler + inspector: Denetçi + contents: İçerik + title: Başlık + liked: Beğenilen Sayfalar + font: Yazı Tipi + alignCenter: İçerikleri ortala + eyeCatchingImageSet: Afiş ayarla + enterVariableName: Değişken ismi ekle + hideTitleWhenPinned: Profile sabitlendiğinde Sayfa başlığını gizle + variableNameIsAlreadyUsed: Bu değişken adı zaten kullanımda + contentBlocks: İçerik + inputBlocks: Giriş + specialBlocks: Özel + updated: Sayfa başarıyla düzenlendi + summary: Sayfa özeti +_notification: + _types: + follow: Yeni takipçiler + mention: Bahsetmeler + app: Bağlı uygulamalardan bildirimler + pollEnded: Biten anket + receiveFollowRequest: Takip istekleri alındı + reaction: Tepkiler + all: Hepsi + followRequestAccepted: Takip istekleri kabul edildi + pollVote: Anket oylamaları + renote: Yükseltmeler + reply: Yanıtlar + groupInvited: Grup davetleri + quote: Alıntılar + pollEnded: Anket sonuçları açıklandı + fileUploaded: Dosya başarıyla yüklendi + youRenoted: '{name} tarafından yükseltildin' + _actions: + followBack: Seni geri takip etti + reply: Yanıtla + renote: Yükseltmeler + youGotMention: '{name} senden bahsetti' + youWereFollowed: seni takip etti + youGotMessagingMessageFromGroup: '{name} grubuna bir sohbet mesajı gönderildi' + renoted: gönderini yükseltti + youGotQuote: '{name} seni alıntıladı' + youGotReply: '{name} seni yanıtladı' + reacted: gönderine tepki ekledi + yourFollowRequestAccepted: Takip isteğin kabul edildi + emptyPushNotificationMessage: Push bildirimleri güncellendi + youWereInvitedToGroup: '{userName} seni gruba davet etti' + voted: anketine oy verdi + youReceivedFollowRequest: Bir takip isteği geldi + youGotPoll: '{name} anketinde oylama yaptı' + youGotMessagingMessageFromUser: '{name} sana bir sohbet mesajı gönderdi' +_experiments: + title: Deneyler + postImportsCaption: Kullanıcıların geçmiş Calckey, Misskey, Mastodon, Akkoma ve + Pleroma hesaplarından gönderilerini içe aktarmalarına izin verir. Kuyruğunuz tıkanırsa + yükleme sırasında yavaşlamalara neden olabilir. + enablePostImports: Gönderi içeri aktarmasını aktif et +_dialog: + charactersExceeded: 'Maksimum karakter aşıldı! Geçerli: {current}/Sınır: {max}' + charactersBelow: 'Yeterli karakter yok! Geçerli: {current}/Sınır: {min}' +_signup: + emailSent: Mail adresinize ({email}) bir onay maili gönderildi. Hesap oluşturmayı + tamamlamak için lütfen verilen bağlantıya tıklayın. + almostThere: Neredeyse vardık + emailAddressInfo: Lütfen mail adresinizi giriniz. Herkese açık gözükmeyecektir. +_ad: + back: Geri + reduceFrequencyOfThisAd: Daha az reklam göster +_accountDelete: + accountDelete: Hesabı sil + mayTakeTime: Hesap silme, kaynak yoğun bir işlem olduğundan, ne kadar içerik oluşturduğunuza + ve ne kadar dosya yüklediğinize bağlı olarak tamamlanması biraz zaman alabilir. + sendEmail: Hesap silme işlemi tamamlandıktan sonra, bu hesapta kayıtlı olan mail + adresine bir mail gönderilecektir. + started: Silme işlemi başlatıldı. + requestAccountDelete: Hesap silme talebinde bulun + inProgress: Silme işlemi şu anda devam ediyor +_forgotPassword: + enterEmail: Kaydolmak için kullandığınız mail adresini girin. Parolanızı sıfırlayabileceğiniz + bir bağlantı daha sonra ona gönderilecektir. + contactAdmin: Bu sunucu, mail adreslerinin kullanılmasını desteklemiyor, bunun yerine + şifrenizi sıfırlamak için lütfen sunucu yöneticisiyle iletişime geçin. + ifNoEmail: Kayıt sırasında bir mail kullanmadıysanız, sunucu yöneticisiyle iletişime + geçin. +_gallery: + my: Galerim + liked: Beğenilen Gönderiler + like: Beğen + unlike: Beğeniyi kaldır +_registry: + key: Anahtar + scope: Kapsam + keys: Anahtarlar + createKey: Anahtar oluştur + domain: Alan adı +_email: + _follow: + title: Yeni bir takipçin var + _receiveFollowRequest: + title: Yeni bir takip isteğin var +_preferencesBackups: + apply: Bu cihaza uygula + invalidFile: Geçersiz dosya formatı + applyConfirm: '"{name}" yedeğini bu cihaza gerçekten uygulamak istiyor musunuz? + Bu cihazın mevcut ayarlarının üzerine yazılacak.' + inputName: Lütfen bu yedekleme için bir ad girin + cannotSave: Kaydedilemedi + saveConfirm: Yedekleme {name} olarak kaydedilsin mi? + renameConfirm: '"{old}" olan bu yedeğin adı "{new}" olarak değiştirilsin mi?' + createdAt: 'Oluşturma tarihi: {date} {time}' + save: Değişiklikleri Kaydet + nameAlreadyExists: '"{name}" adlı bir yedek zaten var. Lütfen farklı bir ad girin.' + deleteConfirm: '{name} yedeği silinsin mi?' + noBackups: Yedekleme yok. "Yeni yedekleme oluştur" seçeneğini kullanarak bu sunucudaki + istemci ayarlarınızı yedekleyebilirsiniz. + list: Oluşturulan yedekler + saveNew: Yeni bir yedek oluştur + loadFile: Dosyadan yükle + updatedAt: 'Güncelleme tarihi: {date} {time}' + cannotLoad: Yüklenemedi +_aboutMisskey: + patronsList: Bağış büyüklüğüne göre değil, kronolojik olarak listelenmiştir. Adınızı + buraya almak için yukarıdaki bağlantıyla bağış yapın! + about: Calckey, 2022'den beri geliştirilmekte olan ThatOneCalculator tarafından + yapılan bir Misskey çatalıdır. + allContributors: Tüm katkıda bulunanlar + patrons: Calckey patronları + morePatrons: Burada listelenmeyen diğer birçok yardımcının desteğini de takdir ediyoruz. + Teşekkür ederim! 🥰 + donate: Calckey'e bağışta bulunun + contributors: Ana katkıda bulunanlar + source: Kaynak Kodu + translation: Calckey'i tercüme et + donateTitle: Calckey'den hoşlanıyor musunuz? + pleaseDonateToCalckey: Lütfen gelişimini desteklemek için Calckey'e bağış yapmayı + düşünün. + pleaseDonateToHost: İşletme maliyetlerini desteklemek için lütfen ev sunucunuz {host}'a + bağış yapmayı da düşünün. + donateHost: '{ev sahibi} için bağış yapın' + sponsors: Calckey sponsorları +_weekday: + saturday: Cumartesi + sunday: Pazar + wednesday: Çarşamba + friday: Cuma + thursday: Perşembe + monday: Pazartesi + tuesday: Salı +_serverDisconnectedBehavior: + reload: Otomatik olarak yenile + quiet: Göze çarpmayan uyarı göster + nothing: Hiçbir şey yapma + dialog: Uyarı mesajını göster +_channel: + removeBanner: Afişi sil + owned: Sahip Olunan + nameOnly: Sadece isim + featured: Popüler + setBanner: Afiş ayarla + usersCount: '{n} Katılımcı' + create: Kanal oluştur + following: Takip + notesCount: '{n} Gönderi' + nameAndDescription: İsim ve açıklama + edit: Kanalı düzenle +_messaging: + groups: Gruplar + dms: Özel +_tutorial: + step5_5: Sosyal {icon} zaman çizelgesi, Ev ve Yerel zaman çizelgelerinin bir kombinasyonudur. + step5_6: Önerilen {icon} zaman çizelgesi, yöneticilerin önerdiği sunuculardan gelen + gönderileri görebileceğiniz yerdir. + step6_1: Peki burası neresi? + title: Calckey nasıl kullanılır + step3_2: "Ev ve sosyal zaman çizelgeleriniz, kimi takip ettiğinize bağlıdır, bu + nedenle başlamak için birkaç hesabı takip etmeyi deneyin.\nTakip etmek için bir + profilin sağ üstündeki artı dairesine tıklayın." + step5_3: Ana Sayfa {icon} zaman çizelgesi, takip ettiğiniz hesaplardan gelen gönderileri + görebileceğiniz yerdir. + step5_4: Yerel {icon} zaman çizelgesi, bu sunucudaki diğer herkesin gönderilerini + görebileceğiniz yerdir. + step6_2: Calckey'e öylece katılmadın. Binlerce sunucudan oluşan birbirine bağlı + bir ağ olan Fediverse'e giden bir portala katıldınız. + step6_4: Şimdi gidin, keşfedin ve eğlenin! + step5_7: Global {icon} zaman çizelgesi, bağlı diğer tüm sunuculardan gelen gönderileri + görebileceğiniz yerdir. + step2_1: Öncelikle lütfen profilinizi doldurunuz. + step2_2: Kim olduğunuz hakkında biraz bilgi vermeniz, başkalarının gönderilerinizi + görmek mi yoksa sizi takip etmek mi istediklerini anlamalarını kolaylaştıracaktır. + step3_1: Şimdi birkaç kullanıcı takip etme zamanı! + step1_1: Hoşgeldin! + step1_2: Hadi seni hazırlayalım. Kısa sürede kullanmaya başlayacaksınız! + step5_1: Zaman çizelgeleri, her yerde zaman çizelgeleri! + step6_3: Her sunucu farklı şekillerde çalışır ve tüm sunucular Calckey'i çalıştırmaz. + Ama bu sunucu kullanıyor! Biraz karışık ama kısa sürede anlayacaksın. + step4_1: Seni oradan çıkaralım. + step5_2: Sunucunuzda etkinleştirilmiş {timelines} farklı zaman çizelgesi var. + step4_2: İlk gönderiniz için, bazı insanlar bir {introduction} gönderisi veya basit + bir "Merhaba dünya!" gönderir +_visibility: + public: Herkese açık + publicDescription: Gönderiniz herkese açık tüm zaman çizelgelerinde görünür olacak + specified: Direkt + followersDescription: Yalnızca takipçilerinize ve adı geçen kullanıcılara görünür + kılın + localOnlyDescription: Uzak kullanıcılara gözükmez + home: Listelenmemiş + homeDescription: Yalnızca ev zaman çizelgesine yayınla + followers: Takipçiler + specifiedDescription: Belirli kullanıcılara özel yapın + localOnly: Sadece yerel +_postForm: + quotePlaceholder: Bu gönderiyi alıntıla... + _placeholders: + a: Ne ile meşgulsün? + b: Etrafında neler oluyor? + f: Yazman bekleniyor... + c: Aklınızdan ne geçiyor? + d: Ne demek istiyorsun? + e: Yazmaya başka... + replyPlaceholder: Bu gönderiyi yanıtla... + channelPlaceholder: Bir kanala gönder... +_exportOrImport: + allNotes: Tüm gönderiler + followingList: Takip edilen kullanıcılar + muteList: Susturulmuş kullanıcılar + excludeMutingUsers: Susturulmuş kullanıcıları hariç tut + excludeInactiveUsers: Aktif olmayan kullanıcıları hariç tut + userLists: Kullanıcı listeleri + blockingList: Engellenimş kullanıcılar +_instanceCharts: + notes: Gönderi sayısındaki fark + notesTotal: Toplu gönderi sayısı + files: Dosya sayısındaki fark + filesTotal: Toplu dosya sayısı + requests: İstekler + usersTotal: Toplu kullanıcı sayısı + users: Kullanıcı sayısı farkı + cacheSize: Önbellek boyutundaki fark + ff: 'Takip edilen / Takipçi sayısındaki fark ' + cacheSizeTotal: Toplam önbellek boyutu + ffTotal: Toplu Takip edilen / Takipçi sayısı +_wordMute: + soft: Yumuşak + muteWords: Susturulmuş kelimeler + muteWordsDescription: AND koşulu için boşluklarla veya OR koşulu için satır sonlarıyla + ayırın. + softDescription: Belirlenen koşulları karşılayan gönderileri zaman çizelgesinden + gizleyin. + hardDescription: Belirlenen koşulları sağlayan gönderilerin zaman çizelgesineeklenmesini + engeller. Ayrıca bu gönderiler, koşullar değişse dahi zaman tüneline eklenmeyecektir. + mutedNotes: Susturulmuş gönderiler + hard: Sert + muteWordsDescription2: Normal ifadeleri kullanmak için anahtar kelimeleri eğik çizgilerle + çevreleyin. +_ago: + weeksAgo: '{n}hafta önce' + minutesAgo: '{n}dakika önce' + daysAgo: '{n}gün önce' + future: Gelecek + justNow: Şimdi + secondsAgo: '{n}saniye önce' + hoursAgo: '{n}saat önce' + monthsAgo: '{n}ay önce' + yearsAgo: '{n}yıl önce' +_timelines: + home: Ev + local: Yerel + social: Sosyal + global: Global + recommended: Tavsiye Edilen +_nsfw: + respect: NSFW medyasını gizle + force: Tüm medyayı gizle + ignore: NSFW medyasını gizleme +_cw: + files: '{count} dosya(lar)' + chars: '{count} harf' + hide: Gizle + show: İçeriği göster +_relayStatus: + rejected: Reddedildi + accepted: Kabul edildi + requesting: Bekleniyor +_time: + day: Gün(ler) + hour: Saat(ler) + second: Saniye(ler) + minute: Dakika(lar) +_skinTones: + light: Aydınlık + medium: Orta + mediumLight: Orta Aydınlık + dark: Karanlık + yellow: Sarı + mediumDark: Orta Karanlık +_plugin: + install: Eklenti indir + installWarn: Lütfen güvenli olmayan eklentiler kurmayınız. + manage: Eklentileri yönet +_instanceTicker: + remote: Uzak kullanıcılar için göster + always: Her zaman göster + none: Asla gösterme +_instanceMute: + heading: Sessize alınacak sunucuların listesi + instanceMuteDescription2: Yeni satırlarla ayırın + title: Listelenen sunuculardan gönderileri gizler. + instanceMuteDescription: Bu, sessize alınmış bir sunucudan bir kullanıcıya yanıt + veren kullanıcılarınkiler de dahil olmak üzere, listelenen sunuculardan gelen + tüm gönderileri/yükseltmeleri sessize alacaktır. +_ffVisibility: + followers: Takipçilere açık + private: Gizli + public: Herkese açık diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 4df6dd2126..6bd72b75be 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -153,10 +153,11 @@ flagAsBotDescription: "Ввімкніть якщо цей обліковий з Ця опція позначить обліковий запис як бота. Це потрібно щоб виключити безкінечну інтеракцію між ботами а також відповідного підлаштування Calckey." flagAsCat: "Акаунт кота" -flagAsCatDescription: "Ввімкніть, щоб позначити, що обліковий запис є котиком." -flagShowTimelineReplies: "Показувати відповіді на нотатки на часовій шкалі" -flagShowTimelineRepliesDescription: "Показує відповіді користувачів на нотатки інших - користувачів на часовій шкалі." +flagAsCatDescription: "Ввімкніть, щоб позначити, що обліковий запис є котиком, та + отримати котячі вуха!" +flagShowTimelineReplies: "Показувати відповіді на записи в стрічці" +flagShowTimelineRepliesDescription: "Показує відповіді користувачів на записи інших + користувачів у стрічці." autoAcceptFollowed: "Автоматично приймати запити на підписку від користувачів, на яких ви підписані" addAccount: "Додати акаунт" @@ -169,7 +170,7 @@ removeWallpaper: "Прибрати шпалери" searchWith: "Пошук: {q}" youHaveNoLists: "У вас немає списків" followConfirm: "Підписатися на {name}?" -proxyAccount: "Проксі-акаунт" +proxyAccount: "Обліковий запис проксі" proxyAccountDescription: "Обліковий запис проксі – це обліковий запис, який діє як віддалений підписник для користувачів за певних умов. Наприклад, коли користувач додає віддаленого користувача до списку, активність віддаленого користувача не буде @@ -217,7 +218,7 @@ blockedUsers: "Заблоковані користувачі" noUsers: "Немає користувачів" editProfile: "Редагувати обліковий запис" noteDeleteConfirm: "Ви дійсно хочете видалити цей запис?" -pinLimitExceeded: "Більше записів не можна закріпити" +pinLimitExceeded: "Ви не можете закріпити більше записів" intro: "Встановлення Calckey завершено! Будь ласка, створіть обліковий запис адміністратора." done: "Готово" processing: "Обробка" @@ -232,7 +233,7 @@ all: "Всі" subscribing: "Підписка" publishing: "Публікація" notResponding: "Не відповідає" -instanceFollowing: "Підписка на інстанс" +instanceFollowing: "Підписка на сервер" instanceFollowers: "Підписники серверу" instanceUsers: "Користувачі цього серверу" changePassword: "Змінити пароль" @@ -359,7 +360,7 @@ pinnedUsersDescription: "Впишіть в список користувачів \"Знайти\", ім'я в стовпчик." pinnedPages: "Закріплені сторінки" pinnedPagesDescription: "Введіть шляхи сторінок, які ви бажаєте закріпити на головній - сторінці цього інстанса, розділені новими рядками." + сторінці цього сервера, розділені новими рядками." pinnedClipId: "Ідентифікатор закріпленої замітки" pinnedNotes: "Закріплений запис" hcaptcha: "hCaptcha" @@ -506,12 +507,14 @@ promote: "Виділити" numberOfDays: "Кількість днів" hideThisNote: "Сховати цей запис" showFeaturedNotesInTimeline: "Показувати популярні записи у стрічці" -objectStorage: "Object Storage" +objectStorage: "Сховище" useObjectStorage: "Використовувати object storage" -objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "Це початкова частина адреси, що використовується CDN або - проксі, наприклад для S3: https://.s3.amazonaws.com, або GCS: 'https://storage.googleapis.com/'" -objectStorageBucket: "Bucket" +objectStorageBaseUrl: "Базовий URL" +objectStorageBaseUrlDesc: "URL-адреса, що використовується як джерело. Вкажіть URL-адресу + вашого CDN або проксі-сервера, якщо ви їх використовуєте.\nДля S3 використовуйте + 'https://.s3.amazonaws.com', а для GCS або подібних сервісів - 'https://storage.googleapis.com/', + тощо." +objectStorageBucket: "Сховище (Bucket)" objectStorageBucketDesc: "Будь ласка вкажіть назву відра в налаштованому сервісі." objectStoragePrefix: "Prefix" objectStoragePrefixDesc: "Файли будуть зберігатись у розташуванні з цим префіксом." @@ -665,11 +668,11 @@ reportAbuse: "Поскаржитись" reportAbuseOf: "Поскаржитись на {name}" fillAbuseReportDescription: "Будь ласка вкажіть подробиці скарги. Якщо скарга стосується запису, вкажіть посилання на нього." -abuseReported: "Дякуємо, вашу скаргу було відправлено. " +abuseReported: "Дякуємо. Ваш звіт було відправлено." reporter: "Репортер" reporteeOrigin: "Про кого повідомлено" reporterOrigin: "Хто повідомив" -forwardReport: "Переслати звіт на віддалений інстанс" +forwardReport: "Переслати звіт на віддалений сервер" forwardReportIsAnonymous: "Замість вашого облікового запису, анонімний системний обліковий запис буде відображатися як доповідач на віддаленому сервері." send: "Відправити" @@ -679,16 +682,16 @@ openInSideView: "Відкрити збоку" defaultNavigationBehaviour: "Поведінка навігації за замовчуванням" editTheseSettingsMayBreakAccount: "Зміна цих параметрів може призвести до пошкодження вашого акаунта." -instanceTicker: "Мітка з назвою інстанса в нотатках" +instanceTicker: "Інформація про записи на сервері" waitingFor: "Чекаємо на {x}" random: "Випадковий" system: "Система" switchUi: "Інтерфейс" desktop: "Десктоп" -clip: "Добірка" +clip: "Підбірка" createNew: "Створити новий" optional: "Необов'язково" -createNewClip: "Створити нотатку" +createNewClip: "Створити підбірку" public: "Публічний" i18nInfo: "Calckey перекладається на різні мови волонтерами. Ви можете допомогти за посиланням: {link}." @@ -793,24 +796,33 @@ hide: "Сховати" searchByGoogle: "Пошук" indefinitely: "Ніколи" file: "Файли" -reverse: "Перевернути" +reverse: "Переворот" colored: "Кольоровий" label: "Назва" localOnly: "Локально" _ffVisibility: public: "Опублікувати" + private: Приватні + followers: Доступно тільки для підписників _ad: back: "Назад" + reduceFrequencyOfThisAd: Менше показувати цю рекламу _gallery: unlike: "Не вподобати" + liked: Вподобані записи + like: Подобається + my: Моя галерея _email: _follow: title: "Новий підписник" + _receiveFollowRequest: + title: Ви отримали запит на підписку _registry: key: "Ключ" keys: "Ключі" domain: "Домен" createKey: "Створити ключ" + scope: Область _aboutMisskey: about: "Misskey - це програмне забезпечення з відкритим кодом, яке розробляє syuilo з 2014 року." @@ -822,12 +834,20 @@ _aboutMisskey: morePatrons: "Ми дуже цінуємо підтримку багатьох інших помічників, не перелічених тут. Дякуємо! 🥰" patrons: "Підтримали" + patronsList: Перераховані в хронологічному порядку, а не за розміром пожертви. Зробіть + внесок за посиланням вище, щоб ваше ім'я було тут! + donateTitle: Сподобався Calckey? + pleaseDonateToCalckey: Будь ласка, підтримайте розробку Calckey. + pleaseDonateToHost: Також не забудьте підтримати ваш домашній сервер {host}, щоб + допомогти з його операційними витратами. + donateHost: Зробити внесок на рахунок {host} + sponsors: Спонсори Calckey _nsfw: respect: "Приховувати NSFW медіа" ignore: "Не приховувати NSFW медіа" force: "Приховувати всі медіа файли" _mfm: - cheatSheet: " Довідка MFM" + cheatSheet: "Довідка MFM" intro: "MFM це ексклюзивна мова розмітки тексту в Calckey, яку можна використовувати в багатьох місцях. Тут ви можете переглянути приклади її синтаксису." dummy: "Calckey розширює світ Федіверсу" @@ -839,35 +859,36 @@ _mfm: url: "URL" urlDescription: "Відображаються URL-адреси." link: "Посилання" - linkDescription: "Окремі частини тексту можуть містити посилання" + linkDescription: "Окремі частини тексту можуть містити посилання." bold: "Жирний шрифт" - boldDescription: "Виділяє літери, роблячи їх товще" + boldDescription: "Виділяє літери, роблячи їх товщими." small: "Дрібний шрифт" - smallDescription: "Робить текст маленьким і тонким" + smallDescription: "Робить текст маленьким і тонким." center: "По центру" - centerDescription: "Показує вміст у центрі" + centerDescription: "Показує вміст у центрі." inlineCode: "Код (у рядку)" - inlineCodeDescription: "Показує фрагмент тексту у рядку як програмний код" + inlineCodeDescription: "Відображає підсвічування синтаксису для коду (програми)." blockCode: "Код (блок)" - blockCodeDescription: "Показує кілька рядків тексту як блок програмного кода" + blockCodeDescription: "Відображає підсвічування синтаксису для багаторядкового (програмного) + коду в блоці." inlineMath: "Формула (у рядку)" inlineMathDescription: "Відображення математичних формул (KaTeX) у рядку" blockMath: "Формули (блок)" - blockMathDescription: "Відображати багаторядкові формули (KaTeX) блоками" + blockMathDescription: "Відображати математичні формули (KaTeX) блоками" quote: "Цитата" quoteDescription: "Відображає зміст як цитату." emoji: "Кастомні емоджі" emojiDescription: "Щоб показати нетиповий емоджі, потрібно ввести його назву в двокрапках." search: "Пошук" - searchDescription: "Відображає вікно пошуку з попередньо введеним текстом" + searchDescription: "Відображає вікно пошуку з попередньо введеним текстом." flip: "Перевернути" - flipDescription: "Віддзеркалює вміст по горизонталі або вертикалі" + flipDescription: "Віддзеркалює вміст по горизонталі або вертикалі." jelly: "Анімація (желе)" - jellyDescription: "Створює желеподібну анімацію" + jellyDescription: "Створює желеподібну анімацію." tada: "Анімація (Тада!)" - tadaDescription: "Створює анімацію з відчуттям \"Тада!\"" + tadaDescription: "Створює анімацію з відчуттям \"Тада!\"." jump: "Анімація (стрибки)" - jumpDescription: "Показує стрибаючу анімацію" + jumpDescription: "Надає вмісту стрибучу анімацію." bounce: "Анімація (пружина)" shake: "Анімація (Shake)" twitch: "Анімація (Twitch)" @@ -884,6 +905,36 @@ _mfm: font: "Шрифт" fontDescription: "Встановлює шрифт для контенту." rotate: "Обертати" + play: Відтворити MFM + alwaysPlay: Завжди автозапускати всі анімовані MFM + twitchDescription: Надає контенту анімацію, що сильно сіпається. + spinDescription: Надає контенту анімацію обертання. + sparkle: Блиск + sparkleDescription: Надає вмісту ефект мерехтливого блиску. + fade: Згасання + fadeDescription: Зменшує та збільшує видимість контенту. + crop: Обрізати + cropDescription: Обрізати вміст. + scale: Масштабувати + positionDescription: Перемістити вміст на вказане значення. + scaleDescription: Масштабувати вміст на вказану величину. + background: Фоновий колір + foreground: Колір переднього плану + foregroundDescription: Змінити колір тексту на передньому плані. + bounceDescription: Надає контенту пружної анімації. + shakeDescription: Надає контенту тремтливої анімації. + rainbowDescription: Робить вміст веселковим. + rotateDescription: Повертає вміст на вказаний кут. + advancedDescription: Якщо вимкнено, дозволяє лише базову розмітку, якщо не відтворюється + анімований MFM + plainDescription: Вимикає ефекти всіх MFM, що містяться в цьому MFM-ефекті. + stop: Зупинити MFM + plain: Звичайний текст + advanced: Розширені MFM + warn: MFM може містити швидко-рухому або яскраву анімацію + position: Розташування + rainbow: Веселка + backgroundDescription: Змінити колір фону тексту. _instanceTicker: none: "Не відображати" remote: "Відображати для віддалених користувачів" @@ -892,6 +943,7 @@ _serverDisconnectedBehavior: reload: "Автоматично перезавантажити" dialog: "Показати діалогове вікно" quiet: "Показати ненав’язливе попередження" + nothing: Нічого не робити _channel: create: "Створити канал" edit: "Редагувати канал" @@ -900,22 +952,27 @@ _channel: featured: "Тренди" following: "Підписки" usersCount: "{n} учасників" - notesCount: "{n} дописів" + notesCount: "{n} записів" + nameOnly: Тільки назва + nameAndDescription: Назва та опис + owned: Власні _menuDisplay: hide: "Сховати" + sideFull: Збоку + sideIcon: Збоку (тільки іконки) + top: Верх _wordMute: muteWords: "Заглушені слова" - muteWordsDescription: "Розділення ключових слів пробілами для \"І\" або з нової - лінійки для \"АБО\"" + muteWordsDescription: "Відокремліть ключові слова пробілами для умови \"І\" або + з нового рядку для умови \"АБО\"." muteWordsDescription2: "Для використання RegEx, ключові слова потрібно вписати поміж слешів \"/\"." - softDescription: "Приховати записи які відповідають критеріям зі стрічки подій." + softDescription: "Приховати записи які відповідають критеріям зі стрічки." hardDescription: "Приховати записи які відповідають критеріям зі стрічки подій. - Також приховані записи не будуть додані до стрічки подій навіть якщо критерії - буде змінено." + Також приховані записи не будуть додані до стрічки навіть якщо критерії буде змінено." soft: "М'яко" hard: "Жорстко" - mutedNotes: "Заблоковані нотатки" + mutedNotes: "Ігноровані записи" _theme: explore: "Оглянути теми" install: "Встановити тему" @@ -979,9 +1036,20 @@ _theme: accentDarken: "Акцент (Затемлений)" accentLighten: "Акцент (Освітлений)" fgHighlighted: "Виділений текст" + color: Колір + refProp: Посилання на властивість + alpha: Прозорість + constant: Стала + refConst: Посилання на сталу + key: Ключ + funcKind: Тип функції + darken: Затемнення + argument: Аргумент + basedProp: Початкова властивість + addConstant: Додати сталу _sfx: - note: "Нотатки" - noteMy: "Мої нотатки" + note: "Новий запис" + noteMy: "Мої записи" notification: "Сповіщення" chat: "Чати" chatBg: "Чати (фон)" @@ -991,7 +1059,7 @@ _ago: future: "Майбутнє" justNow: "Щойно" secondsAgo: "{n}с тому" - minutesAgo: "{n}х тому" + minutesAgo: "{n}хв тому" hoursAgo: "{n}г тому" daysAgo: "{n}д тому" weeksAgo: "{n} тиж. тому" @@ -1006,36 +1074,66 @@ _tutorial: title: "Як використовувати Calckey" step1_1: "Ласкаво просимо!" step1_2: "Давайте налаштуємо вас. Ви будете працювати в найкоротші терміни!" - step2_1: "Спочатку, будь ласка, заповніть свій профіль" - step2_2: "Надавши деяку інформацію про себе, іншим людям буде легше зрозуміти, чи + step2_1: "Спочатку, будь ласка, заповніть свій профіль." + step2_2: "Після надання інформації про себе, іншим людям буде легше зрозуміти, чи хочуть вони бачити ваші записи або стежити за вами." - step3_1: "Тепер настав час стежити за деякими людьми!" + step3_1: "Тепер настав час на когось підписатися!" step3_2: "Ваша домашня і соціальна стрічки ґрунтуються на тому, за ким ви стежите, тому для початку спробуйте стежити за кількома акаунтами.\nНатисніть на гурток із плюсом у правому верхньому кутку профілю, щоб стежити за ним." - step4_1: "Давайте вийдемо на вас" + step4_1: "Давайте вийдемо на вас." step4_2: "Для свого першого повідомлення деякі люди люблять робити {introduction} повідомлення або просте \"Hello world!\"" - step5_1: "Тимчасові рамки, скрізь тимчасові рамки!" - step5_2: "У вашому екземплярі включені {timelines} різних часових ліній." - step5_3: "Головна {icon} часова шкала - це шкала, де ви можете бачити повідомлення - ваших підписників." - step5_4: "Місцева {icon} тимчасова шкала - це шкала, де ви можете бачити повідомлення - всіх інших користувачів даного екземпляра" - step5_5: "Тимчасова шкала Рекомендовані {icon} - це шкала, де ви можете бачити повідомлення - від інстанцій, рекомендованих адміністраторами." - step5_6: "На часовій шкалі Social {icon} відображаються повідомлення від друзів - ваших підписників" - step5_7: "Глобальна {icon} часова шкала - це місце, де ви можете бачити повідомлення - від усіх інших підключених екземплярів" + step5_1: "Стрічки, скрізь одні стрічки!" + step5_2: "У вашому сервері включені {timelines} різні стрічки." + step5_3: "Головна {icon} стрічка - це стрічка, де ви можете бачити записи тих, на + кого ви підписалися." + step5_4: "Місцева {icon} стрічка - це стрічка, де ви можете бачити записи всіх інших + користувачів даного серверу." + step5_5: "Стрічка рекомендованих {icon} - це комбінація домашньої та місцевої стрічок." + step5_6: "На стрічці Рекомендованих {icon} ви можете бачити записи з серверів, які + рекомендують адміністратори." + step5_7: "Глобальна {icon} стрічка - це місце, де ви можете бачити записи від усіх + інших приєднаних серверів." step6_1: "Отже, що це за місце?" - step6_2: "Ну, ви не просто приєдналися до Кальки. Ви приєдналися до порталу в Fediverse, - взаємопов'язаної мережі з тисяч серверів, званих \"інстансами\"." + step6_2: "Ну, ви не просто приєдналися до Calckey. Ви увійшли в Fediverse, взаємопов'язану + мережу з тисяч серверів." step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але - цей працює! Це трохи складно, але ви швидко розберетеся" + цей працює! Це трохи складно, але ви швидко розберетеся." step6_4: "Тепер ідіть, вивчайте і розважайтеся!" _2fa: registerSecurityKey: "Зареєструвати новий ключ безпеки" + registerTOTP: Зареєструйте новий пристрій + tapSecurityKey: Будь ласка, дотримуйтесь інструкцій вашого браузера, щоб зареєструвати + апаратний ключ безпеки або ключ-пароль + securityKeyName: Введіть назву ключа + chromePasskeyNotSupported: Паролі Chrome наразі не підтримуються. + renewTOTPOk: Переналаштувати + removeKey: Видалити ключ безпеки + alreadyRegistered: 2FA вже налаштовано. + step2Click: Натиснувши на цей QR-код, ви зможете зареєструвати 2FA у вашому ключі + безпеки або додатку-автентифікаторі для телефону. + step3Title: Введіть код автентифікації + step1: По-перше, встановіть програму 2FA (наприклад, {a} або {b}) на свій пристрій. + securityKeyNotSupported: Ваш браузер не підтримує ключі безпеки. + step4: Відтепер при наступних спробах входу в систему буде запитуватися такий токен. + securityKeyInfo: Окрім автентифікації за відбитком пальця або PIN-кодом, ви також + можете налаштувати автентифікацію за допомогою апаратних ключів безпеки, які підтримують + FIDO2, щоб додатково захистити свій обліковий запис. + removeKeyConfirm: Дійсно видалити ключ {name}? + whyTOTPOnlyRenew: Додаток автентифікатора не можна видалити, доки зареєстровано + ключ безпеки. + renewTOTP: Переналаштувати додаток-автентифікатор + renewTOTPCancel: Скасувати + renewTOTPConfirm: Це призведе до того, що коди підтвердження з попереднього додатку + перестануть працювати + token: 2FA Токен + registerTOTPBeforeKey: Будь ласка, налаштуйте додаток-автентифікатор, щоб зареєструвати + ключ безпеки або пароль. + step2Url: 'Також, ви можете ввести цю URL-адресу, якщо використовуєте десктопну + програму:' + step3: Введіть токен, наданий вашим додатком, щоб завершити налаштування. + step2: Потім відскануйте QR-код, що відображається на цьому екрані. _permissions: "read:account": "Переглядати дані профілю" "write:account": "Змінити дані акаунту" @@ -1051,7 +1149,7 @@ _permissions: "write:messaging": "Створювати та видаляти повідомлення" "read:mutes": "Переглядати список ігнорованих" "write:mutes": "Змінювати список ігнорованих" - "write:notes": "Писати і видаляти нотатки" + "write:notes": "Створення та видалення записів" "read:notifications": "Переглядати сповіщення" "read:reactions": "Переглядати реакції" "write:reactions": "Змінювати реакції" @@ -1064,13 +1162,27 @@ _permissions: "write:user-groups": "Змінювати групи користувача" "read:channels": "Переглядати канали" "write:channels": "Змінювати канали" + "read:gallery": Переглянути галерею + "write:gallery": Редагування галереї + "read:gallery-likes": Переглянути список вподобаних записів галереї + "write:notifications": Керування сповіщеннями + "write:gallery-likes": Редагувати список вподобаних записів галереї _auth: shareAccess: "Ви хочете надати \"{name}\" доступ до цього акаунту?" shareAccessAsk: "Ви впевнені, що хочете надати цій програмі доступ до вашого акаунту?" denied: "У доступі відмовлено" + allPermissions: Повний доступ до облікового запису + permissionAsk: 'Цей додаток запитує наступні дозволи:' + copyAsk: 'Будь ласка, вставте наступний код авторизації в додаток:' + pleaseGoBack: Будь ласка, поверніться до додатку + callback: Повернення до додатку _antennaSources: - all: "Всі нотатки" - homeTimeline: "Нотатки тих, на кого ви підписані" + all: "Усі записи" + homeTimeline: "Записи тих, на кого ви підписані" + instances: Записи від усіх користувачів на сервері + userGroup: Записи від користувачів у вказаній групі + users: Записи обраних користувачів + userList: Дописи користувачів із вказаного списку _weekday: sunday: "Неділя" monday: "Понеділок" @@ -1091,20 +1203,30 @@ _widgets: photos: "Фото" digitalClock: "Цифровий годинник" federation: "Федіверс" - postForm: "Створення нотатки" + postForm: "Створення запису" slideshow: "Слайд-шоу" button: "Кнопка" onlineUsers: "Користувачі онлайн" jobQueue: "Черга завдань" - serverMetric: "Показники сервера " + serverMetric: "Показники сервера" aiscript: "Консоль AiScript" + _userList: + chooseList: Оберіть список + meiliStatus: Стан сервера + meiliSize: Розмір індексу + rssTicker: RSS-тікер + instanceCloud: Хмара серверів + unixClock: Годинник UNIX + userList: Список користувачів + serverInfo: Інформація про сервер + meiliIndexCount: Індексовані записи _cw: hide: "Сховати" show: "Показати більше" chars: "{count} символів" files: "{count} файлів" _poll: - noOnlyOneChoice: "Потрібні принаймні два варіанти." + noOnlyOneChoice: "Потрібні принаймні два варіанти" choiceN: "Варіант {n}" noMore: "Більше варіантів додати не можна" canMultipleVote: "Можна вибрати кілька варіантів" @@ -1127,19 +1249,19 @@ _poll: remainingSeconds: "Залишилось {s} секунд" _visibility: public: "Публічний" - publicDescription: "Для всіх користувачів" - home: "Домівка" + publicDescription: "Ваш запис буде видно в усіх публічних стрічках" + home: "Домашній" homeDescription: "Лише на домашній стрічці" followers: "Підписники" - followersDescription: "Тільки для підписників" + followersDescription: "Зробити видимим тільки для ваших підписників і згаданих користувачів" specified: "Особисто" specifiedDescription: "Лише для певних користувачів" localOnly: "Локально" localOnlyDescription: "Приховано для віддалених користувачів" _postForm: - replyPlaceholder: "Відповідь на цю нотатку..." - quotePlaceholder: "Прокоментуйте цю нотатку..." - channelPlaceholder: "Опублікувати в каналі" + replyPlaceholder: "Відповідь на цей запис..." + quotePlaceholder: "Прокоментуйте цей запис..." + channelPlaceholder: "Опублікувати в каналі..." _placeholders: a: "Чим займаєтесь?" b: "Що відбувається навколо вас?" @@ -1155,51 +1277,65 @@ _profile: metadata: "Додаткова інформація" metadataEdit: "Редагувати додаткову інформацію" metadataDescription: "Ви можете вказати до чотирьох пунктів додаткової інформації - у своєму профілі." + у своєму профілі. Ви можете додати тег {a} або {l} за допомогою {rel}, щоб підтвердити + посилання у своєму профілі!" metadataLabel: "Назва" metadataContent: "Вміст" changeAvatar: "Змінити аватар" changeBanner: "Змінити банер" + locationDescription: Якщо ви спочатку введете своє місто, іншим користувачам буде + показано ваш місцевий час. _exportOrImport: - allNotes: "Всі нотатки" + allNotes: "Всі записи" followingList: "Підписки" muteList: "Ігнорувати" blockingList: "Заблокувати" userLists: "Списки" + excludeInactiveUsers: Вилучити неактивних користувачів + excludeMutingUsers: Вилучити заглушених користувачів _charts: federation: "Федіверс" apRequest: "Запити" usersTotal: "Загальна кількість користувачів" activeUsers: "Активні користувачі" - notesTotal: "Загальна кількість нотаток" + notesTotal: "Загальна кількість записів" filesIncDec: "Зміни кількості файлів" filesTotal: "Загальна кількість файлів" + storageUsageIncDec: Різниця в використанні ємності диску + remoteNotesIncDec: Різниця в кількості віддалених записів + notesIncDec: Різниця в кількості записів + localNotesIncDec: Різниця в кількості локальних записів + storageUsageTotal: Загальне використання пам'яті + usersIncDec: Різниця в кількості користувачів _instanceCharts: requests: "Запити" usersTotal: "Сумарна кількість користувачів" - notes: "Різниця кількості зроблених записів" - notesTotal: "Сумарна кількість нотаток" - ff: "Різниця кількості підписників" + notes: "Різниця в кількості зроблених записів" + notesTotal: "Сумарна кількість записів" + ff: "Різниця кількості підписників " ffTotal: "Кількість підписників" cacheSizeTotal: "Сумарний розмір кешу" files: "Різниця в кількості файлів" filesTotal: "Сумарна кількість файлів" + users: Різниця в кількості користувачів + cacheSize: Різниця в розмірі кешу _timelines: home: "Домівка" local: "Локальна" social: "Соціальна" global: "Глобальна" + recommended: Рекомендована _pages: newPage: "Створити сторінку" editPage: "Редагувати сторінку" readPage: "Перегляд вихідного коду" - created: "Сторінка успішно створена." - updated: "Сторінка успішно оновлена." + created: "Сторінка успішно створена" + updated: "Сторінка успішно оновлена" deleted: "Сторінку видалено" pageSetting: "Налаштування сторінки" - nameAlreadyExists: "Вказана адреса сторінки вже існує." - invalidNameTitle: "Вказана адреса сторінки неприпустима." - invalidNameText: "Переконайтеся, що не залишили порожнім." + nameAlreadyExists: "Вказана адреса сторінки вже існує" + invalidNameTitle: "Вказана адреса сторінки неприпустима" + invalidNameText: "Переконайтеся, що поле заголовка сторінки не порожнє" editThisPage: "Редагувати цю сторінку" viewSource: "Переглянути вихідний код" viewPage: "Переглянути свої сторінки" @@ -1242,6 +1378,7 @@ _pages: _post: text: "Вміст" canvasId: "Ідентифікатор полотна" + attachCanvasImage: Прикріпити зображення полотна textInput: "Введення тексту" _textInput: name: "Ім'я змінної" @@ -1262,10 +1399,10 @@ _pages: id: "Ідентифікатор полотна" width: "Ширина" height: "Висота" - note: "Вбудована нотатка" + note: "Вбудований запис" _note: - id: "Ідентифікатор нотатки" - idDescription: "Також можна вказати посилання на нотатку" + id: "Ідентифікатор запису" + idDescription: "Також можна вказати посилання на запис." detailed: "Детальний вигляд" switch: "Перемикач" _switch: @@ -1456,7 +1593,7 @@ _pages: arg1: "Текст" ref: "Змінні" aiScriptVar: "Змінна AiScript" - fn: "Функції" + fn: "Функція" _fn: slots: "Паз" slots-info: "Використовувати нову лінію як роздільник пазів" @@ -1508,9 +1645,16 @@ _notification: followRequestAccepted: "Прийняті підписки" groupInvited: "Запрошення до груп" app: "Сповіщення від додатків" + pollEnded: Опитування закінчено _actions: reply: "Відповісти" - renote: "Поширити" + renote: "Поширення" + followBack: також підписався на вас + emptyPushNotificationMessage: Push-сповіщення були оновлені + voted: проголосував на вашому опитуванні + renoted: поширив ваш запис + reacted: відреагував на ваш запис + pollEnded: Стали доступні результати опитування _deck: alwaysShowMainColumn: "Завжди показувати головну колонку" columnAlign: "Вирівняти стовпці" @@ -1521,16 +1665,27 @@ _deck: swapDown: "Пересунути вниз" stackLeft: "У стовпчик вліво" popRight: "Витягнути вправо" - profile: "Обліковий запис" + profile: "Простір" _columns: main: "Головна" widgets: "Віджети" notifications: "Сповіщення" tl: "Стрічка" - antenna: "Антени" + antenna: "Антена" list: "Списки" mentions: "Згадки" - direct: "Особисте" + direct: "Особисті повідомлення" + channel: Канал + newProfile: Новий простір + introduction2: Натисніть на + у правій частині екрана, щоб додавати нові стовпці + по бажанню. + configureColumn: Налаштування стовпців + introduction: Створіть ідеальний інтерфейс для себе, вільно розташовуючи стовпці! + widgetsIntroduction: Будь ласка, виберіть "Редагувати віджети" в меню колонки і + додайте віджет. + renameProfile: Перейменувати простір + deleteProfile: Видалити простір + nameAlreadyExists: Простір із такою назвою вже існує. removeReaction: Видалити вашу реакцію renoteMute: Ігнорувати поширення renoteUnmute: Показувати поширення @@ -1623,3 +1778,358 @@ usernameInfo: Ім'я, яке ідентифікує ваш обліковий Ім'я користувача не може бути змінено пізніше. noThankYou: Ні, дякую keepCw: Зберігати попередження про вміст +showEmojisInReactionNotifications: Показувати емодзі у сповіщеннях про реакції +accountMoved: 'Користувач переїхав до нового облікового запису:' +expandOnNoteClickDesc: Якщо цю опцію вимкнено, ви все одно зможете відкривати дописи + в меню, клацнувши правою кнопкою миші або натиснувши на мітку часу. +deleteAccountConfirm: Це призведе до незворотного видалення вашого облікового запису. + Приступити? +unread: Непрочитане +filter: Фільтри +useDrawerReactionPickerForMobile: Відображати вибирач реакцій як шухляду на мобільному + телефоні +leaveGroupConfirm: Ви впевнені, що хочете залишити "{name}"? +clickToFinishEmailVerification: Будь ласка, натисніть [{ok}], щоб завершити перевірку + електронної пошти. +welcomeBackWithName: Ласкаво просимо назад, {name} +overridedDeviceKind: Тип пристрою +themeColor: Колір теми серверу +oneDay: Один день +instanceDefaultLightTheme: Світла тема за замовчуванням для сервера +oneWeek: Одна неділя +instanceDefaultDarkTheme: Темна тема за замовчуванням для сервера +video: Відео +audio: Аудіо +rateLimitExceeded: Перевищено ліміт +numberOfPageCacheDescription: Збільшення цієї величини покращить зручність для користувачів, + але призведе до збільшення навантаження на сервер та використання більшої кількості + пам'яті. +lastActiveDate: Останній раз використовувався у +statusbar: Панель статусу +speed: Швидкість +sensitiveMediaDetection: Виявлення NSFW медіа +cannotUploadBecauseNoFreeSpace: Завантаження не вдалося через брак місця на Диску. +cannotUploadBecauseExceedsFileSizeLimit: Цей файл не може бути завантажений, оскільки + він перевищує максимально дозволений розмір. +account: Обліковий запис +move: Перемістити +pushNotification: Push-сповіщення +subscribePushNotification: Увімкнути push-сповіщення +unsubscribePushNotification: Вимкнути push-сповіщення +pushNotificationAlreadySubscribed: Push-сповіщення вже увімкнено +enterSendsMessage: Натисніть Enter у повідомленнях, щоб надіслати повідомлення (якщо + вимкнено, то Ctrl + Enter) +showAds: Показувати рекламу +customMOTD: Користувацькі MOTD (повідомлення на заставці) +customSplashIcons: Користувацькі іконки заставки (URL) +splash: Заставка +adminCustomCssWarn: Цей параметр слід використовувати, тільки якщо ви знаєте, що він + робить. Введення неправильних значень може призвести до того, що ВСІ клієнти перестануть + нормально працювати. Будь ласка, переконайтеся, що ваш CSS працює належним чином, + протестувавши його в налаштуваннях користувача. +_filters: + followersOnly: Тільки підписники + fromUser: Від користувача + notesBefore: Записи до + withFile: З файлом + fromDomain: З домену + notesAfter: Записи після + followingOnly: Тільки підписки +sendModMail: Надіслати повідомлення про модерацію +enableServerMachineStats: Увімкнути статистику серверного обладнання +enableIdenticonGeneration: Увімкнути генерацію Identicon +_sensitiveMediaDetection: + analyzeVideosDescription: Аналізує відео так само як і зображення. Це трохи збільшить + навантаження на сервер. + description: Зменшує навантаження на серверну модерацію завдяки автоматичному розпізнаванню + NSFW медіа за допомогою машинного навчання. Це трохи збільшить навантаження на + сервер. + sensitivity: Чутливість виявлення + sensitivityDescription: Зменшення чутливості призведе до зменшення кількості хибних + спрацьовувань, тоді як збільшення чутливості призведе до зменшення кількості пропущених + спрацьовувань. + setSensitiveFlagAutomatically: Позначити як NSFW + setSensitiveFlagAutomaticallyDescription: Результати внутрішнього виявлення будуть + збережені, навіть якщо цю опцію вимкнено. + analyzeVideos: Ввімкнути аналіз відео +_emailUnavailable: + used: Ця електронна пошта вже використовується + format: Формат цієї адреси електронної пошти є неправильним + mx: Цей сервер електронної пошти є недійсним + disposable: Використовувати одноразові адреси електронної пошти заборонено + smtp: Цей поштовий сервер не відповідає +_messaging: + dms: Приватні + groups: Групи +_instanceMute: + instanceMuteDescription: Це приховає всі записи/поширення із вказаних серверів, + включно з відповідями користувачам заглушеного серверу. + title: Приховує записи з перелічених серверів. + instanceMuteDescription2: Розділити новими рядками + heading: Список серверів для заглушення +_experiments: + enablePostImports: Ввімкнути імпорт записів + title: Експерименти + postImportsCaption: Дозволяє користувачам імпортувати свої публікації з минулих + облікових записів Calckey, Misskey, Mastodon, Akkoma і Pleroma. Це може спричинити + зниження швидкості під час завантаження, якщо ваша черга перевантажена. +_dialog: + charactersExceeded: 'Перевищено максимальну кількість символів! Обмеження: {current}/{max}' + charactersBelow: 'Недостатньо символів! Обмеження: {current}/{min}' +jumpToSpecifiedDate: Перейти до конкретної дати +quitFullView: Закрити повний вигляд +ffVisibility: Видимість підписок/підписників +numberOfColumn: Кількість стовпців +failedToFetchAccountInformation: Не вдалося отримати інформацію про обліковий запис +reflectMayTakeTime: Може пройти деякий час, перш ніж зміни набудуть чинності. +recentNHours: Останні {n} годин +logoutConfirm: Ви впевнені, що хочете вийти? +enableRecommendedTimeline: Увімкнути рекомендовану стрічку +_accountDelete: + requestAccountDelete: Запросити видалення облікового запису + accountDelete: Видалити обліковий запис + mayTakeTime: Оскільки видалення облікового запису є ресурсоємним процесом, він може + зайняти деякий час, залежно від того, скільки контенту ви створили та скільки + файлів завантажили. + sendEmail: Коли ваш обліковий запис буде видалено, ми повідомимо на вказану вами + електронну пошту. + started: Процес видалення розпочався. + inProgress: Наразі триває видалення +_preferencesBackups: + deleteConfirm: Видалити резервну копію {name}? + applyConfirm: Ви дійсно хочете застосувати резервну копію "{name}" до цього пристрою? + Існуючі налаштування цього пристрою буде замінено. + saveConfirm: Зберегти резервну копію як {name}? + saveNew: Зберегти нову резервну копію + save: Зберегти зміни + inputName: Будь ласка, введіть назву для цієї резервної копії + loadFile: Завантажити з файлу + updatedAt: 'Оновлено: {date} {time}' + invalidFile: Неправильний формат файлу + apply: Застосувати до цього пристрою + list: Створені резервні копії + cannotSave: Збереження невдале + nameAlreadyExists: Резервна копія з назвою "{name}" вже існує. Будь ласка, введіть + іншу назву. + renameConfirm: Перейменувати цю резервну копію з "{old}" на "{new}"? + noBackups: Резервних копій немає. Ви можете створити резервну копію налаштувань + клієнта на цьому сервері за допомогою "Створити нову резервну копію". + createdAt: 'Створено: {date} {time}' + cannotLoad: Не вдалося завантажити +beta: Бета +customMOTDDescription: Користувацькі повідомлення для MOTD (заставки), розділені новими + рядками, які будуть показуватися випадковим чином щоразу, коли користувач завантажує/перезавантажує + сторінку. +replayTutorial: Перезапустити туторіал +_forgotPassword: + ifNoEmail: Якщо ви не використовували електронну пошту під час реєстрації, зверніться + до адміністратора серверу. + enterEmail: Введіть адресу електронної пошти, яку ви використовували для реєстрації. + На неї буде надіслано посилання, за яким ви зможете скинути пароль. + contactAdmin: Цей сервер не підтримує використання адрес електронної пошти, будь + ласка, зверніться до адміністратора сервера, щоб скинути пароль. +reactionPickerSkinTone: Бажаний колір шкіри емодзі +addInstance: Додати сервер +jumpToPrevious: Перейти до попереднього +listsDesc: Списки дозволяють створювати стрічки із вказаними користувачами. Доступ + до них можна отримати на сторінці стрічок. +channelFederationWarn: Канали наразі федеруються з іншими серверами +lastCommunication: Останнє повідомлення +edited: Відредаговано {date} о {time} +confirmToUnclipAlreadyClippedNote: Цей запис уже в підбірці "{name}". Чи бажаєте ви + натомість видалити пост із підбірки? +quickAction: Швидкі дії +remoteOnly: Тільки віддалені +failedToUpload: Помилка завантаження +moveFrom: Мігрувати на цей обліковий запис зі старого облікового запису +preventAiLearning: Захист від скрепінгу ШІ-ботів +moveAccountDescription: Цей процес є незворотнім. Переконайтеся, що ви створили псевдонім + для цього акаунта в новому акаунті перед переїздом. Будь ласка, введіть тег акаунта + у форматі @person@server.com +_signup: + almostThere: Майже готово + emailAddressInfo: Будь ласка, введіть свою адресу електронної пошти. Вона не буде + опублікована. + emailSent: На вашу електронну адресу ({email}) було надіслано лист із підтвердженням. + Будь ласка, перейдіть за посиланням, щоб завершити створення облікового запису. +defaultValueIs: 'За замовчуванням: {value}' +shareWithNote: Поділитися з записом +classic: Відцентрований +size: Розмір +slow: Повільно +alt: ALT +auto: Автоматично +oneHour: Одна година +instanceDefaultThemeDescription: Введіть код теми в об'єктному форматі. +cropImageAsk: Чи бажаєте ви обрізати це зображення? +noEmailServerWarning: Поштовий сервер не налаштовано. +thereIsUnresolvedAbuseReportWarning: Є не розглянуті звіти. +image: Зображення +check: Перевірити +isSystemAccount: Цей акаунт створений і автоматично управляється системою. Будь ласка, + не модеруйте, не редагуйте, не видаляйте та не втручайтеся в цей акаунт будь-яким + іншим чином, інакше це може призвести до поломки вашого серверу. +document: Документація +driveCapOverrideCaption: Ви можете скинути ємність до значення за замовчуванням, ввівши + значення 0 або менше. +numberOfPageCache: Кількість кешованих сторінок +pleaseSelect: Оберіть варіант +refreshInterval: 'Інтервал оновлення ' +enableAutoSensitive: Автоматичне маркування NSFW +cannotUploadBecauseInappropriate: Цей файл не може бути завантажений тому що його + частини були виявлені як потенційне NSFW. +sendPushNotificationReadMessageCaption: На короткий час буде показано сповіщення з + текстом "{emptyPushNotificationMessage}". Це може призвести до збільшення споживання + заряду акумулятора вашого пристрою, якщо це можливо. +pushNotificationNotSupported: Ваш браузер або сервер не підтримує push-сповіщення +showUpdates: Показувати спливаюче вікно при оновленні Calckey +updateAvailable: Можливо, є доступне оновлення! +recommendedInstancesDescription: Рекомендовані сервери відокремлюються переведенням + рядка, щоб з'явитися на стрічці рекомендацій. +caption: Автоматичний підпис +showAdminUpdates: Вказати, що доступна нова версія Calckey (тільки для адміністратора) +defaultReaction: Емодзі реакція за замовчуванням для вихідних і вхідних записів +license: Ліцензія +indexPosts: Індексувати пости +indexFrom: Індексувати записи з ID +indexFromDescription: Залиште порожнім, щоб індексувати кожен запис +indexNotice: Зараз відбувається індексація. Це, ймовірно, займе деякий час, будь ласка, + не перезавантажуйте сервер принаймні годину. +signupsDisabled: Реєстрація на цьому сервері наразі відключена, але ви завжди можете + зареєструватися на іншому сервері! Якщо у вас є код запрошення на цей сервер, будь + ласка, введіть його нижче. +findOtherInstance: Знайти інший сервер +customKaTeXMacro: Користувацькі макроси KaTeX +enableCustomKaTeXMacro: Увімкнути користувацькі макроси KaTeX +apps: Додатки +isModerator: Модератор +isAdmin: Адміністратор +isPatron: Патрон Calckey +swipeOnMobile: Дозволити гортання між сторінками +migration: Міграція +swipeOnDesktop: Дозволити свайп у мобільному стилі на десктопі +logoImageUrl: URL-адреса зображення логотипу +moveTo: Перенести поточний обліковий запис на новий +moveFromDescription: Це встановить псевдонім вашого старого облікового запису, щоб + ви могли перейти зі старого облікового запису до цього поточного. Зробіть це ДО + переходу зі старого акаунта. Будь ласка, введіть тег акаунта у форматі @person@server.com +moveToLabel: 'Обліковий запис, на який ви мігруєте:' +moveAccount: Перемістити обліковий запис! +moveFromLabel: 'Обліковий запис, з якого ви мігруєте:' +_plugin: + install: Встановлення плагінів + manage: Керування плагінами + installWarn: Будь ласка, не встановлюйте ненадійні плагіни. +_skinTones: + yellow: Жовтий + mediumLight: Помірно-світлий + medium: Помірний + mediumDark: Помірно-темний + dark: Темний + light: Світлий +tenMinutes: 10 хвилин +expandOnNoteClick: Відкрити запис кліком +preferencesBackups: Резервне копіювання +unlikeConfirm: Дійсно видалити вподобайку? +fullView: Повний вигляд +postToGallery: Опублікувати в галереї +memo: Нотатки +allowedInstancesDescription: Хости серверів, які будуть допущені до федерації, кожен + з яких відокремлюється новим рядком (стосується лише приватного режиму). +squareAvatars: Квадратні аватарки +aiChanMode: Режим ШІ +controlPanel: Панель керування +manageAccounts: Керування обліковими записами +incorrectPassword: Неправильний пароль. +voteConfirm: Підтвердити свій голос за "{choice}"? +leaveGroup: Залишити групу +smartphone: Смартфон +mutePeriod: Тривалість глушіння +requireAdminForView: Ви маєте увійти з облікового запису адміністратора, щоб переглянути + це. +fast: Швидко +isBot: Цей обліковий запис є ботом +isLocked: Цей обліковий запис має схвалення запитів на підписку +silenceThisInstance: Ігнорувати цей сервер +hideOnlineStatusDescription: Приховування вашого онлайн-статусу знижує зручність деяких + функцій, таких як пошук. +accountDeletionInProgress: Наразі триває видалення облікового запису +makeReactionsPublic: Зробити історію реакцій публічною +continueThread: Показати наступні відповіді +unmuteThread: Скасувати глушіння гілки +ffVisibilityDescription: Дозволяє налаштувати, хто може бачити, на кого ви підписані + і хто підписаний на вас. +tablet: Планшет +cropImage: Обрізати зображення +recentNDays: Останні {n} днів +navbar: Панель навігації +noGraze: Будь ласка, вимкніть розширення браузера "Graze для Mastodon", оскільки воно + заважає роботі Calckey. +preventAiLearningDescription: Попросити сторонні мовні моделі ШІ не вивчати вміст, + який ви завантажуєте, наприклад, записи та зображення. +userSaysSomethingReasonReply: '{name} відповів на пост з {reason}' +secureMode: Безпечний режим (Authorized Fetch) +seperateRenoteQuote: Розділити кнопки поширення та цитати +makeReactionsPublicDescription: Це зробить список усіх ваших минулих реакцій публічно + видимим. +muteThread: Заглушити гілку +sendPushNotificationReadMessage: Видаляти push-сповіщення після того, як відповідні + сповіщення або повідомлення будуть прочитані +unclip: Видалити з підбірки +silencedInstances: Ігноровані сервери +typeToConfirm: Введіть {x} щоб підтвердити +silencedWarning: Ця сторінка відображається тому, що ці користувачі з серверів, які + ваш адміністратор заглушив, тому вони потенційно можуть бути спамом. +shuffle: Перетасувати +ratio: Співвідношення +secureModeInfo: У разі запитів з інших серверів не надсилати непідтверджену відповідь. +pubSub: Облікові записи Pub/Sub +driveCapOverrideLabel: Змінити ємність диску для цього користувача +deleteAccount: Видалити обліковий запис +type: Тип +enableAutoSensitiveDescription: Дозволяє автоматично виявляти та позначати медіафайли + NSFW за допомогою машинного навчання, де це можливо. Навіть якщо цю опцію вимкнено, + вона може бути увімкнена на всьому сервері. +recommendedInstances: Рекомендовані сервери +noteId: Ідентифікатор запису +showPopup: Сповіщати користувачів спливаючим вікном +showWithSparkles: Показати з блиском +youHaveUnreadAnnouncements: У вас є непрочитані оголошення +donationLink: Посилання на сторінку для внесків +neverShow: Не показувати знову +remindMeLater: Можливо пізніше +removeQuote: Видалити цитату +removeRecipient: Видалити одержувача +removeMember: Видалити члена +silencedInstancesDescription: Вкажіть імена хостів серверів, які ви хочете ігнорувати. + Облікові записи на перелічених серверах вважаються "Ігнорованими", можуть робити + лише запити на підписку і не можуть згадувати локальні облікові записи, якщо на + них не підписалися. Це не вплине на заблоковані сервери. +hiddenTagsDescription: 'Перелічіть хештеги (без #), які ви хочете приховати з трендів + і дослідження. Приховані хештеги все одно можна знайти іншими способами.' +antennasDesc: "Антени показують нові дописи, що відповідають встановленим вами критеріям!\n + Доступ до них можна отримати зі сторінки стрічок." +clipsDesc: Підбірки схожі на категоризовані закладки, до яких можна надавати спільний + доступ. Ви можете створювати підбірки з меню окремих записів. +migrationConfirm: "Ви точно впевнені, що хочете перенести свій обліковий запис на + {account}? Якщо ви це зробите, ви не зможете скасувати цю операцію і не зможете + користуватися своїм обліковим записом як раніше.\nТакож, будь ласка, переконайтеся, + що ви вибрали цей поточний обліковий запис як обліковий запис, з якого ви переходите." +customKaTeXMacroDescription: 'Налаштуйте макроси, щоб легко писати математичні вирази! + Позначення відповідає визначенню команд LaTeX і записується у вигляді \newcommand{\ + name}{content} або \newcommand{\name}[number of arguments]{content}. Наприклад, + \newcommand{\add}[2]{#1 + #2} розширить \add{3}{foo} to 3 + foo. Фігурні дужки навколо + назви макросу можна змінити на круглі або квадратні. Це вплине на дужки, що використовуються + для аргументів. В одному рядку можна визначити один (і тільки один) макрос, і жоден + рядок не можна розривати посередині визначення. Неправильні рядки просто ігноруються. + Підтримуються лише прості функції заміни рядків; розширений синтаксис, такий як + умовне розгалуження, не може бути використаний тут.' +activeEmailValidationDescription: Вмикає більш сувору перевірку адрес електронної + пошти, яка включає перевірку на наявність одноразових адрес і перевірку того, чи + дійсно з нею можна зв'язатися. Якщо цей прапорець знято, перевіряється лише формат + електронної пошти. +customSplashIconsDescription: URL-адреси іконок для заставки, розділені новими рядками, + які будуть показуватися випадковим чином щоразу, коли користувач завантажує/перезавантажує + сторінку. Будь ласка, переконайтеся, що зображення знаходяться на статичній URL-адресі, + бажано, щоб вони були змінені до розміру 192x192. +verifiedLink: Перевірене посилання diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index ddd79084fc..40b3909cec 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1342,7 +1342,7 @@ _profile: youCanIncludeHashtags: "Bạn có thể dùng hashtag trong tiểu sử." metadata: "Thông tin bổ sung" metadataEdit: "Sửa thông tin bổ sung" - metadataDescription: "Sử dụng phần này, bạn có thể hiển thị các mục thông tin bổ sung trong hồ sơ của mình." + metadataDescription: "Sử dụng phần này, bạn có thể hiển thị các mục thông tin bổ sung trong hồ sơ của mình. Bạn có thể thêm thẻ {a} hoặc thẻ {l} với {rel} để xác minh liên kết trên tiểu sử của mình!" metadataLabel: "Nhãn" metadataContent: "Nội dung" changeAvatar: "Đổi ảnh đại diện" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 36453551da..a0353e0ec8 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1402,7 +1402,7 @@ _profile: youCanIncludeHashtags: "您可以包含一个话题标签。" metadata: "附加信息" metadataEdit: "附加信息编辑" - metadataDescription: "使用这些,您可以在您的个人资料中显示其它信息字段。" + metadataDescription: "使用这些,您可以在您的个人资料中显示其它信息字段。您可以添加带有 {rel} 的 {a} 标签或 {l} 标签来验证您个人资料上的链接!" metadataLabel: "标签" metadataContent: "内容" changeAvatar: "修改头像" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index d1bad4ad1d..8b59b63a74 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -984,6 +984,12 @@ _aboutMisskey: donate: "贊助Calckey" morePatrons: "還有許許多多幫助我們的其他人,非常感謝你們。 🥰" patrons: "贊助者" + patronsList: 按時間順序列出,而不是按贊助規模列出。使用上面的連結贊助,在這裡獲得顯示您名字的機會! + sponsors: Calckey 贊助者們 + donateTitle: 覺得 Calckey 棒嗎? + pleaseDonateToCalckey: 請考慮向 Calckey 贊助以支持其發展。 + pleaseDonateToHost: 還請考慮捐贈給您在使用的伺服器 {host},以支援龐大的運營成本。 + donateHost: 贊助給 {host} _nsfw: respect: "隱藏敏感內容" ignore: "不隱藏敏感內容" @@ -1060,6 +1066,8 @@ _mfm: position: 位置 alwaysPlay: 自動播放所有MFM動畫 positionDescription: 按指定數量移動內容。 + advancedDescription: 如果禁用,則僅允許基本標記,除非正在播放 MFM 動畫 + advanced: 高級MFM _instanceTicker: none: "隱藏" remote: "向遠端使用者顯示" @@ -1202,14 +1210,14 @@ _tutorial: step1_1: "歡迎!" step1_2: "讓我們把你安排好。你很快就會啟動並運行!" step2_1: "首先,請完成你的個人資料。" - step2_2: "通過提供一些關於你自己的資料,其他人會更容易了解他們是否想看到你的帖子或關注你。" + step2_2: "通過提供一些關於你自己的資料,其他人會更容易了解他們是否想看到你的貼文或關注你。" step3_1: "現在是時候追隨一些人了!" step3_2: "你的主頁和社交時間線是基於你所追蹤的人,所以試著先追蹤幾個帳戶。\n點擊個人資料右上角的加號圈就可以關注它。" step4_1: "讓我們出去找你。" step4_2: "對於他們的第一條信息,有些人喜歡做 {introduction} 或一個簡單的 \"hello world!\"" step5_1: "時間線,到處都是時間線!" step5_2: "您的伺服器已啟用了{timelines}個時間線。" - step5_3: "首頁 {icon} 時間線是顯示你追蹤的帳號的帖子。" + step5_3: "首頁 {icon} 時間線是顯示你追蹤的帳號的貼文。" step5_4: "本地 {icon} 時間線是你可以看到伺服器中所有其他用戶的貼文的時間線。" step5_5: "社交 {icon} 時間線是你的 首頁時間線 和 本地時間線 的結合體。" step5_6: "推薦 {icon} 時間線是顯示你的伺服器管理員推薦的貼文。" @@ -1361,7 +1369,7 @@ _profile: youCanIncludeHashtags: "你也可以在「關於我」中加上 #tag。" metadata: "進階資訊" metadataEdit: "編輯進階資訊" - metadataDescription: "可以在個人資料中以表格形式顯示其他資訊。" + metadataDescription: "可以在個人資料中以表格形式顯示其他資訊。您可以添加帶有 {rel} 的 {a} 標籤或 {l} 標籤來驗證您個人資料上的鏈接!" metadataLabel: "標籤" metadataContent: "内容" changeAvatar: "更換大頭貼" @@ -1820,12 +1828,12 @@ _experiments: title: 試驗功能 findOtherInstance: 找找另一個伺服器 noGraze: 瀏覽器擴展 "Graze for Mastodon" 會與Calckey發生衝突,請停用該擴展。 -userSaysSomethingReasonRenote: '{name} 轉傳了包含 {reason} 的帖子' +userSaysSomethingReasonRenote: '{name} 轉傳了包含 {reason} 的貼文' pushNotificationNotSupported: 你的瀏覽器或伺服器不支援推送通知 accessibility: 輔助功能 -userSaysSomethingReasonReply: '{name} 回復了包含 {reason} 的帖子' +userSaysSomethingReasonReply: '{name} 回覆了包含 {reason} 的貼文' hiddenTags: 隱藏主題標籤 -indexPosts: 索引帖子 +indexPosts: 索引貼文 indexNotice: 現在開始索引。 這可能需要一段時間,請不要在一個小時內重啟你的伺服器。 deleted: 已刪除 editNote: 編輯筆記 @@ -1861,9 +1869,33 @@ audio: 音訊 sendPushNotificationReadMessageCaption: 包含文本 “{emptyPushNotificationMessage}” 的通知將顯示一小段時間。 這可能會增加您設備的電池使用量(如果適用)。 channelFederationWarn: 頻道功能尚未與聯邦宇宙連動 -swipeOnMobile: 允許在頁面之間滑動 +swipeOnMobile: 允許以滑動在頁面之間切換 sendPushNotificationReadMessage: 閱讀相關通知或消息後刪除推送通知 image: 圖片 seperateRenoteQuote: 分別獨立的轉傳及引用按鈕 clipsDesc: 摘錄就像一個可以分享的書籤。 你可以從每個貼文的菜單創建新摘錄或將貼文加入已有的摘錄。 noteId: 貼文 ID +sendModMail: 發送審核通知 +enableIdenticonGeneration: 啟用碎片生成 +enableServerMachineStats: 啟用伺服器硬體統計資訊 +reactionPickerSkinTone: 首選表情符號膚色 +indexFromDescription: 留空以索引每個貼文 +preventAiLearning: 防止 AI 機器人抓取 +preventAiLearningDescription: 請求第三方 AI 語言模型不要研究您上傳的內容,例如貼文和圖像。 +indexFrom: 從貼文 ID 開始的索引 +isLocked: 該帳戶已獲得以下批准 +isModerator: 板主 +isAdmin: 管理員 +isPatron: Calckey 項目贊助者 +silencedWarning: 顯示此頁面是因為這些使用者來自您伺服器管理員已靜音的伺服器,因此他們可能是垃圾訊息。 +signupsDisabled: 該伺服器上的註冊當前已被禁用,但您隨時可以在另一台伺服器上註冊!或是您有該伺服器的邀請碼,請在下面輸入。 +showPopup: 通過彈出式視窗通知用戶 +showWithSparkles: 閃閃發光的顯示 +youHaveUnreadAnnouncements: 您有未讀的公告 +donationLink: 連結到贊助頁面 +neverShow: 不再顯示 +remindMeLater: 可能之後 +removeQuote: 删除引用 +removeRecipient: 刪除收件者 +removeMember: 刪除成員 +isBot: 此帳戶是機器人 diff --git a/package.json b/package.json index b38da39fa7..eccc170e03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "calckey", - "version": "14.0.0-dev77", + "version": "14.0.0-dev83", "codename": "aqua", "repository": { "type": "git", @@ -57,7 +57,7 @@ "gulp-replace": "1.1.4", "gulp-terser": "2.1.0", "install-peers": "^1.0.4", - "rome": "^12.1.3", + "rome": "^v12.1.3-nightly.f65b0d9", "start-server-and-test": "1.15.2", "typescript": "5.1.6" } diff --git a/packages/backend/assets/LICENSE b/packages/backend/assets/LICENSE new file mode 100644 index 0000000000..342509dec1 --- /dev/null +++ b/packages/backend/assets/LICENSE @@ -0,0 +1,13 @@ +Copyright 2023 Calckey + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/backend/migration/1678426061773-tweak-varchar-length.js b/packages/backend/migration/1678426061773-tweak-varchar-length.js new file mode 100644 index 0000000000..00ddcaebea --- /dev/null +++ b/packages/backend/migration/1678426061773-tweak-varchar-length.js @@ -0,0 +1,16 @@ +export class tweakVarcharLength1678426061773 { + name = "tweakVarcharLength1678426061773"; + + async up(queryRunner) { + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "smtpUser" TYPE character varying(1024)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "smtpPass" TYPE character varying(1024)`, + undefined, + ); + } + + async down(queryRunner) {} +} diff --git a/packages/backend/native-utils/Cargo.lock b/packages/backend/native-utils/Cargo.lock index 1a20b792c2..e5f8af37a7 100644 --- a/packages/backend/native-utils/Cargo.lock +++ b/packages/backend/native-utils/Cargo.lock @@ -458,6 +458,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "console" version = "0.15.7" @@ -486,6 +500,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.4" @@ -1278,11 +1302,14 @@ dependencies = [ "futures", "indicatif", "native-utils", + "redis", + "sea-orm", "sea-orm-migration", "serde", "serde_json", "serde_yaml", "tokio", + "url", "urlencoding", ] @@ -1509,6 +1536,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + [[package]] name = "os_str_bytes" version = "6.5.0" @@ -1843,6 +1876,29 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redis" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea8c51b5dc1d8e5fd3350ec8167f464ec0995e79f2e90a075b63371500d557f" +dependencies = [ + "async-trait", + "bytes", + "combine", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.3", + "rustls-native-certs", + "ryu", + "sha1_smol", + "tokio", + "tokio-rustls 0.24.1", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -2043,6 +2099,30 @@ dependencies = [ "webpki", ] +[[package]] +name = "rustls" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b19faa85ecb5197342b54f987b142fb3e30d0c90da40f80ef4fa9a726e6676ed" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.2" @@ -2052,6 +2132,16 @@ dependencies = [ "base64 0.21.2", ] +[[package]] +name = "rustls-webpki" +version = "0.101.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f36a6828982f422756984e47912a7a51dcbc2a197aa791158f8ca61cd8204e" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.12" @@ -2076,6 +2166,15 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +[[package]] +name = "schannel" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "schemars" version = "0.8.12" @@ -2286,6 +2385,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.17" @@ -2370,6 +2492,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + [[package]] name = "sha2" version = "0.10.6" @@ -2518,7 +2646,7 @@ dependencies = [ "percent-encoding", "rand", "rust_decimal", - "rustls", + "rustls 0.20.8", "rustls-pemfile", "serde", "serde_json", @@ -2564,7 +2692,7 @@ checksum = "804d3f245f894e61b1e6263c84b23ca675d96753b5abfd5cc8597d86806e8024" dependencies = [ "once_cell", "tokio", - "tokio-rustls", + "tokio-rustls 0.23.4", ] [[package]] @@ -2778,11 +2906,21 @@ version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" dependencies = [ - "rustls", + "rustls 0.20.8", "tokio", "webpki", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.3", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.14" @@ -2949,6 +3087,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/packages/backend/native-utils/Cargo.toml b/packages/backend/native-utils/Cargo.toml index f93180fe43..6f4dd91759 100644 --- a/packages/backend/native-utils/Cargo.toml +++ b/packages/backend/native-utils/Cargo.toml @@ -9,7 +9,7 @@ members = ["migration"] [features] default = [] noarray = [] -napi = ["dep:napi", "dep:napi-derive", "dep:radix_fmt"] +napi = ["dep:napi", "dep:napi-derive"] [lib] crate-type = ["cdylib", "lib"] @@ -31,11 +31,11 @@ serde_json = "1.0.96" thiserror = "1.0.40" tokio = { version = "1.28.1", features = ["full"] } utoipa = "3.3.0" +radix_fmt = "1.0.0" # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix napi = { version = "2.13.1", default-features = false, features = ["napi6", "tokio_rt"], optional = true } napi-derive = { version = "2.12.0", optional = true } -radix_fmt = { version = "1.0.0", optional = true } [dev-dependencies] pretty_assertions = "1.3.0" diff --git a/packages/backend/native-utils/__test__/index.spec.mjs b/packages/backend/native-utils/__test__/index.spec.mjs index 6e6a91858c..9ff8ead6c9 100644 --- a/packages/backend/native-utils/__test__/index.spec.mjs +++ b/packages/backend/native-utils/__test__/index.spec.mjs @@ -12,18 +12,18 @@ test("convert to mastodon id", (t) => { t.is(convertId("9gf61ehcxv", IdConvertType.MastodonId), "960365976481219"); t.is( convertId("9fbr9z0wbrjqyd3u", IdConvertType.MastodonId), - "3954607381600562394", + "2083785058661759970208986", ); t.is( convertId("9fbs680oyviiqrol9md73p8g", IdConvertType.MastodonId), - "3494513243013053824", + "5878598648988104013828532260828151168", ); }); test("create cuid2 with timestamp prefix", (t) => { nativeInitIdGenerator(16, ""); - t.not(nativeCreateId(BigInt(Date.now())), nativeCreateId(BigInt(Date.now()))); - t.is(nativeCreateId(BigInt(Date.now())).length, 16); + t.not(nativeCreateId(Date.now()), nativeCreateId(Date.now())); + t.is(nativeCreateId(Date.now()).length, 16); }); test("create random string", (t) => { diff --git a/packages/backend/native-utils/migration/Cargo.toml b/packages/backend/native-utils/migration/Cargo.toml index 7ed9fd5f03..9bf793e042 100644 --- a/packages/backend/native-utils/migration/Cargo.toml +++ b/packages/backend/native-utils/migration/Cargo.toml @@ -21,14 +21,17 @@ futures = { version = "0.3.28", optional = true } serde_yaml = "0.9.21" serde = { version = "1.0.163", features = ["derive"] } urlencoding = "2.1.2" +redis = { version = "0.23.0", features = ["tokio-rustls-comp"] } +sea-orm = "0.11.3" +url = { version = "2.4.0", features = ["serde"] } [dependencies.sea-orm-migration] version = "0.11.0" features = [ - # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. - # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. - # e.g. - "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature - "sqlx-postgres", # `DATABASE_DRIVER` feature + # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. + # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. + # e.g. + "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature + "sqlx-postgres", # `DATABASE_DRIVER` feature "sqlx-sqlite", ] diff --git a/packages/backend/native-utils/migration/src/lib.rs b/packages/backend/native-utils/migration/src/lib.rs index 94e2b08cc4..5ad23f1625 100644 --- a/packages/backend/native-utils/migration/src/lib.rs +++ b/packages/backend/native-utils/migration/src/lib.rs @@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*; mod m20230531_180824_drop_reversi; mod m20230627_185451_index_note_url; +mod m20230709_000510_move_antenna_to_cache; pub struct Migrator; @@ -11,6 +12,7 @@ impl MigratorTrait for Migrator { vec![ Box::new(m20230531_180824_drop_reversi::Migration), Box::new(m20230627_185451_index_note_url::Migration), + Box::new(m20230709_000510_move_antenna_to_cache::Migration), ] } } diff --git a/packages/backend/native-utils/migration/src/m20230709_000510_move_antenna_to_cache.rs b/packages/backend/native-utils/migration/src/m20230709_000510_move_antenna_to_cache.rs new file mode 100644 index 0000000000..83bd0448ab --- /dev/null +++ b/packages/backend/native-utils/migration/src/m20230709_000510_move_antenna_to_cache.rs @@ -0,0 +1,248 @@ +use redis::streams::StreamMaxlen; +use sea_orm::Statement; +use sea_orm_migration::prelude::*; +use std::env; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let cache_url = env::var("CACHE_URL").unwrap(); + let skip_copy = env::var("ANTENNA_MIGRATION_SKIP").unwrap_or_default(); + let copy_limit = env::var("ANTENNA_MIGRATION_COPY_LIMIT").unwrap_or_default(); + let read_limit: u64 = env::var("ANTENNA_MIGRATION_READ_LIMIT") + .unwrap_or("10000".to_string()) + .parse() + .unwrap(); + let copy_limit: i64 = match copy_limit.parse() { + Ok(limit) => limit, + Err(_) => 0, + }; + + if skip_copy == "true" { + println!("Skipped antenna migration"); + } else { + let prefix = env::var("CACHE_PREFIX").unwrap(); + + let db = manager.get_connection(); + let bk = manager.get_database_backend(); + + let count_stmt = + Statement::from_string(bk, "SELECT COUNT(1) FROM antenna_note".to_owned()); + let total_num = db + .query_one(count_stmt) + .await? + .unwrap() + .try_get_by_index::(0)?; + let copy_limit = if copy_limit > 0 { + copy_limit + } else { + total_num + }; + println!( + "Copying {} out of {} entries in antenna_note.", + copy_limit, total_num + ); + + let stmt_base = Query::select() + .column((AntennaNote::Table, AntennaNote::Id)) + .column(AntennaNote::AntennaId) + .column(AntennaNote::NoteId) + .from(AntennaNote::Table) + .order_by((AntennaNote::Table, AntennaNote::Id), Order::Asc) + .limit(read_limit) + .to_owned(); + + let mut stmt = stmt_base.clone(); + + let client = redis::Client::open(cache_url).unwrap(); + let mut redis_conn = client.get_connection().unwrap(); + + let mut remaining = total_num; + let mut pagination: i64 = 0; + + loop { + let res = db.query_all(bk.build(&stmt)).await?; + if res.len() == 0 { + break; + } + let val: Vec<(String, String, String)> = res + .iter() + .filter_map(|q| q.try_get_many_by_index().ok()) + .collect(); + + remaining -= val.len() as i64; + if remaining <= copy_limit { + let mut pipe = redis::pipe(); + for v in &val { + pipe.xadd_maxlen( + format!("{}:antennaTimeline:{}", prefix, v.1), + StreamMaxlen::Approx(200), + "*", + &[("note", v.2.to_owned())], + ) + .ignore(); + } + pipe.query::<()>(&mut redis_conn).unwrap(); + } + + let copied = total_num - remaining; + let copied = std::cmp::min(copied, total_num); + pagination += 1; + if pagination % 10 == 0 { + println!( + "Migrating antenna [{:.2}%]", + (copied as f64 / total_num as f64) * 100_f64, + ); + } + + if let Some((last_id, _, _)) = val.last() { + stmt = stmt_base + .clone() + .and_where( + Expr::col((AntennaNote::Table, AntennaNote::Id)).gt(last_id.to_owned()), + ) + .to_owned(); + } else { + break; + } + } + + println!("Migrating antenna [100.00%]"); + } + + manager + .drop_table( + Table::drop() + .table(AntennaNote::Table) + .if_exists() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(AntennaNote::Table) + .if_not_exists() + .col( + ColumnDef::new(AntennaNote::Id) + .string_len(32) + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(AntennaNote::NoteId) + .string_len(32) + .not_null(), + ) + .col( + ColumnDef::new(AntennaNote::AntennaId) + .string_len(32) + .not_null(), + ) + .col( + ColumnDef::new(AntennaNote::Read) + .boolean() + .default(false) + .not_null(), + ) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("IDX_0d775946662d2575dfd2068a5f") + .table(AntennaNote::Table) + .col(AntennaNote::AntennaId) + .if_not_exists() + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("IDX_bd0397be22147e17210940e125") + .table(AntennaNote::Table) + .col(AntennaNote::NoteId) + .if_not_exists() + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("IDX_335a0bf3f904406f9ef3dd51c2") + .table(AntennaNote::Table) + .col(AntennaNote::NoteId) + .col(AntennaNote::AntennaId) + .unique() + .if_not_exists() + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("IDX_9937ea48d7ae97ffb4f3f063a4") + .table(AntennaNote::Table) + .col(AntennaNote::Read) + .if_not_exists() + .to_owned(), + ) + .await?; + manager + .create_foreign_key( + ForeignKey::create() + .name("FK_0d775946662d2575dfd2068a5f5") + .from(AntennaNote::Table, AntennaNote::AntennaId) + .to(Antenna::Table, Antenna::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + manager + .create_foreign_key( + ForeignKey::create() + .name("FK_bd0397be22147e17210940e125b") + .from(AntennaNote::Table, AntennaNote::NoteId) + .to(Note::Table, Note::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +enum AntennaNote { + Table, + Id, + #[iden = "noteId"] + NoteId, + #[iden = "antennaId"] + AntennaId, + Read, +} + +#[derive(Iden)] +enum Antenna { + Table, + Id, +} + +#[derive(Iden)] +enum Note { + Table, + Id, +} diff --git a/packages/backend/native-utils/migration/src/main.rs b/packages/backend/native-utils/migration/src/main.rs index f0f761f657..896f1ed597 100644 --- a/packages/backend/native-utils/migration/src/main.rs +++ b/packages/backend/native-utils/migration/src/main.rs @@ -5,6 +5,10 @@ use urlencoding::encode; use sea_orm_migration::prelude::*; +const DB_URL_ENV: &str = "DATABASE_URL"; +const CACHE_URL_ENV: &str = "CACHE_URL"; +const CACHE_PREFIX_ENV: &str = "CACHE_PREFIX"; + #[cfg(feature = "convert")] mod vec_to_json; @@ -15,17 +19,48 @@ async fn main() { .expect("Failed to open '.config/default.yml'"); let config: Config = serde_yaml::from_reader(yml).expect("Failed to parse yaml"); - env::set_var( - "DATABASE_URL", - format!( - "postgres://{}:{}@{}:{}/{}", - config.db.user, - encode(&config.db.pass), - config.db.host, - config.db.port, - config.db.db, - ), - ); + if env::var_os(DB_URL_ENV).is_none() { + env::set_var( + DB_URL_ENV, + format!( + "postgres://{}:{}@{}:{}/{}", + config.db.user, + encode(&config.db.pass), + config.db.host, + config.db.port, + config.db.db, + ), + ); + }; + + if env::var_os(CACHE_URL_ENV).is_none() { + let redis_conf = match config.cache_server { + None => config.redis, + Some(conf) => conf, + }; + let redis_proto = match redis_conf.tls { + None => "redis", + Some(_) => "rediss", + }; + let redis_uri_userpass = match redis_conf.user { + None => "".to_string(), + Some(user) => format!("{}:{}@", user, encode(&redis_conf.pass.unwrap_or_default())), + }; + let redis_uri_hostport = format!("{}:{}", redis_conf.host, redis_conf.port); + let redis_uri = format!( + "{}://{}{}/{}", + redis_proto, redis_uri_userpass, redis_uri_hostport, redis_conf.db + ); + env::set_var(CACHE_URL_ENV, redis_uri); + env::set_var( + CACHE_PREFIX_ENV, + if redis_conf.prefix.is_empty() { + config.url.host_str().unwrap() + } else { + &redis_conf.prefix + }, + ); + } cli::run_cli(migration::Migrator).await; @@ -34,13 +69,15 @@ async fn main() { } #[derive(Debug, PartialEq, Deserialize)] -#[serde(rename = "camelCase")] +#[serde(rename_all = "camelCase")] pub struct Config { + pub url: url::Url, pub db: DbConfig, + pub redis: RedisConfig, + pub cache_server: Option, } #[derive(Debug, PartialEq, Deserialize)] -#[serde(rename = "camelCase")] pub struct DbConfig { pub host: String, pub port: u32, @@ -48,3 +85,23 @@ pub struct DbConfig { pub user: String, pub pass: String, } + +#[derive(Debug, PartialEq, Deserialize)] +pub struct RedisConfig { + pub host: String, + pub port: u32, + pub user: Option, + pub pass: Option, + pub tls: Option, + #[serde(default)] + pub db: u32, + #[serde(default)] + pub prefix: String, +} + +#[derive(Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TlsConfig { + pub host: String, + pub reject_unauthorized: bool, +} diff --git a/packages/backend/native-utils/package.json b/packages/backend/native-utils/package.json index 385330d776..962b4bc4c4 100644 --- a/packages/backend/native-utils/package.json +++ b/packages/backend/native-utils/package.json @@ -43,6 +43,7 @@ "universal": "napi universal", "version": "napi version", "format": "cargo fmt --all", + "lint": "cargo clippy --fix", "cargo:test": "pnpm run cargo:unit && pnpm run cargo:integration", "cargo:unit": "cargo test unit_test && cargo test -F napi unit_test", "cargo:integration": "cargo test -F noarray int_test -- --test-threads=1" diff --git a/packages/backend/native-utils/src/model/entity.rs b/packages/backend/native-utils/src/model/entity.rs index d71057fdee..ccf3f00601 100644 --- a/packages/backend/native-utils/src/model/entity.rs +++ b/packages/backend/native-utils/src/model/entity.rs @@ -8,7 +8,6 @@ pub mod ad; pub mod announcement; pub mod announcement_read; pub mod antenna; -pub mod antenna_note; pub mod app; pub mod attestation_challenge; pub mod auth_session; diff --git a/packages/backend/native-utils/src/model/entity/announcement.rs b/packages/backend/native-utils/src/model/entity/announcement.rs index e8a2a28aa8..5cdb690d23 100644 --- a/packages/backend/native-utils/src/model/entity/announcement.rs +++ b/packages/backend/native-utils/src/model/entity/announcement.rs @@ -15,6 +15,10 @@ pub struct Model { pub image_url: Option, #[sea_orm(column_name = "updatedAt")] pub updated_at: Option, + #[sea_orm(column_name = "showPopup")] + pub show_popup: bool, + #[sea_orm(column_name = "isGoodNews")] + pub is_good_news: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/packages/backend/native-utils/src/model/entity/antenna.rs b/packages/backend/native-utils/src/model/entity/antenna.rs index 85bdfbfeac..9d5a64f82f 100644 --- a/packages/backend/native-utils/src/model/entity/antenna.rs +++ b/packages/backend/native-utils/src/model/entity/antenna.rs @@ -37,8 +37,6 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm(has_many = "super::antenna_note::Entity")] - AntennaNote, #[sea_orm( belongs_to = "super::user::Entity", from = "Column::UserId", @@ -65,12 +63,6 @@ pub enum Relation { UserList, } -impl Related for Entity { - fn to() -> RelationDef { - Relation::AntennaNote.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::User.def() diff --git a/packages/backend/native-utils/src/model/entity/meta.rs b/packages/backend/native-utils/src/model/entity/meta.rs index 2c0dc315c6..ac43a78427 100644 --- a/packages/backend/native-utils/src/model/entity/meta.rs +++ b/packages/backend/native-utils/src/model/entity/meta.rs @@ -189,6 +189,10 @@ pub struct Model { pub silenced_hosts: StringVec, #[sea_orm(column_name = "experimentalFeatures", column_type = "JsonBinary")] pub experimental_features: Json, + #[sea_orm(column_name = "enableServerMachineStats")] + pub enable_server_machine_stats: bool, + #[sea_orm(column_name = "enableIdenticonGeneration")] + pub enable_identicon_generation: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/packages/backend/native-utils/src/model/entity/note.rs b/packages/backend/native-utils/src/model/entity/note.rs index 077841e48b..5c97a20cf9 100644 --- a/packages/backend/native-utils/src/model/entity/note.rs +++ b/packages/backend/native-utils/src/model/entity/note.rs @@ -67,8 +67,6 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm(has_many = "super::antenna_note::Entity")] - AntennaNote, #[sea_orm( belongs_to = "super::channel::Entity", from = "Column::ChannelId", @@ -131,12 +129,6 @@ pub enum Relation { UserNotePining, } -impl Related for Entity { - fn to() -> RelationDef { - Relation::AntennaNote.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::Channel.def() diff --git a/packages/backend/native-utils/src/model/entity/prelude.rs b/packages/backend/native-utils/src/model/entity/prelude.rs index 8be696cb40..a859a9cc37 100644 --- a/packages/backend/native-utils/src/model/entity/prelude.rs +++ b/packages/backend/native-utils/src/model/entity/prelude.rs @@ -6,7 +6,6 @@ pub use super::ad::Entity as Ad; pub use super::announcement::Entity as Announcement; pub use super::announcement_read::Entity as AnnouncementRead; pub use super::antenna::Entity as Antenna; -pub use super::antenna_note::Entity as AntennaNote; pub use super::app::Entity as App; pub use super::attestation_challenge::Entity as AttestationChallenge; pub use super::auth_session::Entity as AuthSession; diff --git a/packages/backend/native-utils/src/model/repository/antenna.rs b/packages/backend/native-utils/src/model/repository/antenna.rs index 7c614b954b..80f34fed10 100644 --- a/packages/backend/native-utils/src/model/repository/antenna.rs +++ b/packages/backend/native-utils/src/model/repository/antenna.rs @@ -1,9 +1,9 @@ use async_trait::async_trait; use cfg_if::cfg_if; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use sea_orm::EntityTrait; use crate::database; -use crate::model::entity::{antenna, antenna_note, user_group_joining}; +use crate::model::entity::{antenna, user_group_joining}; use crate::model::error::Error; use crate::model::schema::Antenna; @@ -14,12 +14,6 @@ use super::Repository; impl Repository for antenna::Model { async fn pack(self) -> Result { let db = database::get_database()?; - let has_unread_note = antenna_note::Entity::find() - .filter(antenna_note::Column::AntennaId.eq(self.id.to_owned())) - .filter(antenna_note::Column::Read.eq(false)) - .one(db) - .await? - .is_some(); let user_group_joining = match self.user_group_joining_id { None => None, Some(id) => user_group_joining::Entity::find_by_id(id).one(db).await?, @@ -46,13 +40,13 @@ impl Repository for antenna::Model { src: self.src.try_into()?, user_list_id: self.user_list_id, user_group_id, - users: self.users.into(), + users: self.users, instances: self.instances.into(), case_sensitive: self.case_sensitive, notify: self.notify, with_replies: self.with_replies, with_file: self.with_file, - has_unread_note, + has_unread_note: false, }) } diff --git a/packages/backend/native-utils/src/model/schema/antenna.rs b/packages/backend/native-utils/src/model/schema/antenna.rs index 4ec1e07946..da2c3061b4 100644 --- a/packages/backend/native-utils/src/model/schema/antenna.rs +++ b/packages/backend/native-utils/src/model/schema/antenna.rs @@ -58,7 +58,7 @@ impl TryFrom for super::AntennaSrc { // ---- TODO: could be macro impl Schema for super::Antenna {} -pub static VALIDATOR: Lazy = Lazy::new(|| super::Antenna::validator()); +pub static VALIDATOR: Lazy = Lazy::new(super::Antenna::validator); // ---- cfg_if! { diff --git a/packages/backend/native-utils/src/model/schema/app.rs b/packages/backend/native-utils/src/model/schema/app.rs index 682b82ec07..5f859ecb3a 100644 --- a/packages/backend/native-utils/src/model/schema/app.rs +++ b/packages/backend/native-utils/src/model/schema/app.rs @@ -91,7 +91,7 @@ pub enum AppPermission { impl Schema for App {} -pub static VALIDATOR: Lazy = Lazy::new(|| App::validator()); +pub static VALIDATOR: Lazy = Lazy::new(App::validator); #[cfg(test)] mod unit_test { @@ -105,9 +105,9 @@ mod unit_test { #[test] fn app_valid() { - init_id(12, ""); + init_id(16, ""); let instance = json!({ - "id": create_id().unwrap(), + "id": create_id(0).unwrap(), "name": "Test App", "secret": gen_string(24), "callbackUrl": "urn:ietf:wg:oauth:2.0:oob", @@ -119,9 +119,9 @@ mod unit_test { #[test] fn app_invalid() { - init_id(12, ""); + init_id(16, ""); let instance = json!({ - "id": create_id().unwrap(), + "id": create_id(0).unwrap(), // "name" is required "name": null, // "permission" must be one of the app permissions diff --git a/packages/backend/native-utils/src/util/id.rs b/packages/backend/native-utils/src/util/id.rs index d922518f9d..b18637fdbb 100644 --- a/packages/backend/native-utils/src/util/id.rs +++ b/packages/backend/native-utils/src/util/id.rs @@ -1,7 +1,10 @@ //! ID generation utility based on [cuid2] use cfg_if::cfg_if; +use chrono::Utc; use once_cell::sync::OnceCell; +use radix_fmt::radix_36; +use std::cmp; use crate::impl_into_napi_error; @@ -14,47 +17,56 @@ impl_into_napi_error!(ErrorUninitialized); static FINGERPRINT: OnceCell = OnceCell::new(); static GENERATOR: OnceCell = OnceCell::new(); +const TIME_2000: i64 = 946_684_800_000; +const TIMESTAMP_LENGTH: u16 = 8; + /// Initializes Cuid2 generator. Must be called before any [create_id]. -pub fn init_id(length: u16, fingerprint: impl Into) { - FINGERPRINT.get_or_init(move || format!("{}{}", fingerprint.into(), cuid2::create_id())); +pub fn init_id<'a>(length: u16, fingerprint: &'a str) { + FINGERPRINT.get_or_init(move || format!("{}{}", fingerprint, cuid2::create_id())); GENERATOR.get_or_init(move || { cuid2::CuidConstructor::new() - .with_length(length) + // length to pass shoule be greater than or equal to 8. + .with_length(cmp::max(length - TIMESTAMP_LENGTH, 8)) .with_fingerprinter(|| FINGERPRINT.get().unwrap().clone()) }); } /// Returns Cuid2 with the length specified by [init_id]. Must be called after /// [init_id], otherwise returns [ErrorUninitialized]. -pub fn create_id() -> Result { +/// The current timestamp via [chrono::Utc] is used if `date_num` is `0`. +pub fn create_id(date_num: i64) -> Result { match GENERATOR.get() { None => Err(ErrorUninitialized), - Some(gen) => Ok(gen.create_id()), + Some(gen) => { + let date_num = if date_num > 0 { + date_num + } else { + Utc::now().timestamp_millis() + }; + let time = cmp::max(date_num - TIME_2000, 0); + Ok(format!( + "{:0>8}{}", + radix_36(time).to_string(), + gen.create_id() + )) + } } } cfg_if! { if #[cfg(feature = "napi")] { - use radix_fmt::radix_36; - use std::cmp; - use napi::bindgen_prelude::BigInt; use napi_derive::napi; - const TIME_2000: u64 = 946_684_800_000; - const TIMESTAMP_LENGTH: u16 = 8; - /// Calls [init_id] inside. Must be called before [native_create_id]. #[napi] pub fn native_init_id_generator(length: u16, fingerprint: String) { - // length to pass init_id shoule be greater than or equal to 8. - init_id(cmp::max(length - TIMESTAMP_LENGTH, 8), fingerprint); + init_id(length, &fingerprint); } /// Generates #[napi] - pub fn native_create_id(date_num: BigInt) -> String { - let time = cmp::max(date_num.get_u64().1 - TIME_2000, 0); - format!("{:0>8}{}", radix_36(time).to_string(), create_id().unwrap()) + pub fn native_create_id(date_num: i64) -> String { + create_id(date_num).unwrap() } } } @@ -62,37 +74,17 @@ cfg_if! { #[cfg(test)] mod unit_test { use crate::util::id; - use cfg_if::cfg_if; use pretty_assertions::{assert_eq, assert_ne}; use std::thread; - cfg_if! { - if #[cfg(feature = "napi")] { - use chrono::Utc; - - #[test] - fn can_generate_aid_compat_ids() { - id::native_init_id_generator(20, "".to_string()); - let id1 = id::native_create_id(Utc::now().timestamp_millis().into()); - assert_eq!(id1.len(), 20); - let id1 = id::native_create_id(Utc::now().timestamp_millis().into()); - let id2 = id::native_create_id(Utc::now().timestamp_millis().into()); - assert_ne!(id1, id2); - let id1 = thread::spawn(|| id::native_create_id(Utc::now().timestamp_millis().into())); - let id2 = thread::spawn(|| id::native_create_id(Utc::now().timestamp_millis().into())); - assert_ne!(id1.join().unwrap(), id2.join().unwrap()); - } - } else { - #[test] - fn can_generate_unique_ids() { - assert_eq!(id::create_id(), Err(id::ErrorUninitialized)); - id::init_id(12, ""); - assert_eq!(id::create_id().unwrap().len(), 12); - assert_ne!(id::create_id().unwrap(), id::create_id().unwrap()); - let id1 = thread::spawn(|| id::create_id().unwrap()); - let id2 = thread::spawn(|| id::create_id().unwrap()); - assert_ne!(id1.join().unwrap(), id2.join().unwrap()); - } - } + #[test] + fn can_generate_unique_ids() { + assert_eq!(id::create_id(0), Err(id::ErrorUninitialized)); + id::init_id(16, ""); + assert_eq!(id::create_id(0).unwrap().len(), 16); + assert_ne!(id::create_id(0).unwrap(), id::create_id(0).unwrap()); + let id1 = thread::spawn(|| id::create_id(0).unwrap()); + let id2 = thread::spawn(|| id::create_id(0).unwrap()); + assert_ne!(id1.join().unwrap(), id2.join().unwrap()); } } diff --git a/packages/backend/native-utils/tests/common.rs b/packages/backend/native-utils/tests/common.rs index 186e862bd5..674297606f 100644 --- a/packages/backend/native-utils/tests/common.rs +++ b/packages/backend/native-utils/tests/common.rs @@ -139,17 +139,17 @@ async fn cleanup() { } async fn setup_model(db: &DbConn) { - init_id(12, ""); + init_id(16, ""); db.transaction::<_, (), DbErr>(|txn| { Box::pin(async move { - let user_id = create_id().unwrap(); + let user_id = create_id(0).unwrap(); let name = "Alice"; let user_model = entity::user::Model { id: user_id.to_owned(), created_at: Utc::now().into(), - username: name.to_lowercase().to_string(), - username_lower: name.to_lowercase().to_string(), + username: name.to_lowercase(), + username_lower: name.to_lowercase(), name: Some(name.to_string()), token: Some(gen_string(16)), is_admin: true, @@ -161,7 +161,7 @@ async fn setup_model(db: &DbConn) { .insert(txn) .await?; let antenna_model = entity::antenna::Model { - id: create_id().unwrap(), + id: create_id(0).unwrap(), created_at: Utc::now().into(), user_id: user_id.to_owned(), name: "Alice Antenna".to_string(), @@ -186,7 +186,7 @@ async fn setup_model(db: &DbConn) { .insert(txn) .await?; let note_model = entity::note::Model { - id: create_id().unwrap(), + id: create_id(0).unwrap(), created_at: Utc::now().into(), text: Some("Testing 123".to_string()), user_id: user_id.to_owned(), diff --git a/packages/backend/native-utils/tests/model/repository/antenna.rs b/packages/backend/native-utils/tests/model/repository/antenna.rs index 3bda2ca183..9b03d3652d 100644 --- a/packages/backend/native-utils/tests/model/repository/antenna.rs +++ b/packages/backend/native-utils/tests/model/repository/antenna.rs @@ -43,18 +43,16 @@ mod int_test { keywords: vec![ vec!["foo".to_string(), "bar".to_string()], vec!["foobar".to_string()], - ] - .into(), + ], exclude_keywords: vec![ vec!["abc".to_string()], vec!["def".to_string(), "ghi".to_string()], - ] - .into(), + ], src: schema::AntennaSrc::All, user_list_id: None, user_group_id: None, - users: vec![].into(), - instances: vec![].into(), + users: vec![], + instances: vec![], case_sensitive: true, notify: true, with_replies: false, @@ -95,7 +93,7 @@ mod int_test { .unwrap() .expect("note not found"); let antenna_note = antenna_note::Model { - id: util::id::create_id().unwrap(), + id: util::id::create_id(0).unwrap(), antenna_id: alice_antenna.id.to_owned(), note_id: note_model.id.to_owned(), read: false, diff --git a/packages/backend/package.json b/packages/backend/package.json index fe8c078a03..99f1cadd59 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -59,18 +59,21 @@ "color-convert": "2.0.1", "content-disposition": "0.5.4", "date-fns": "2.30.0", + "decompress": "^4.2.1", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", "feed": "4.2.2", "file-type": "17.1.6", "fluent-ffmpeg": "2.1.2", "got": "12.5.3", + "gunzip-maybe": "^1.4.2", "hpagent": "0.1.2", "ioredis": "5.3.2", "ip-cidr": "3.1.0", "is-svg": "4.3.2", "js-yaml": "4.1.0", "jsdom": "20.0.3", + "json5": "2.2.3", "jsonld": "8.2.0", "jsrsasign": "10.8.6", "koa": "2.14.2", @@ -125,6 +128,7 @@ "summaly": "2.7.0", "syslog-pro": "1.0.0", "systeminformation": "5.17.17", + "tar-stream": "^3.1.6", "tesseract.js": "^3.0.3", "tinycolor2": "1.5.2", "tmp": "0.2.1", @@ -185,7 +189,6 @@ "cross-env": "7.0.3", "eslint": "^8.44.0", "execa": "6.1.0", - "json5": "2.2.3", "json5-loader": "4.0.1", "mocha": "10.2.0", "pug": "3.0.2", diff --git a/packages/backend/src/config/load.ts b/packages/backend/src/config/load.ts index fa98789554..df163a8ac9 100644 --- a/packages/backend/src/config/load.ts +++ b/packages/backend/src/config/load.ts @@ -54,9 +54,9 @@ export default function load() { mixin.userAgent = `Calckey/${meta.version} (${config.url})`; mixin.clientEntry = clientManifest["src/init.ts"]; - if (!config.redis.prefix) config.redis.prefix = mixin.host; + if (!config.redis.prefix) config.redis.prefix = mixin.hostname; if (config.cacheServer && !config.cacheServer.prefix) - config.cacheServer.prefix = mixin.host; + config.cacheServer.prefix = mixin.hostname; return Object.assign(config, mixin); } diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index 1f1e63292f..2a955ee521 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -1,7 +1,13 @@ import config from "@/config/index.js"; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js"; +import { + DB_MAX_NOTE_TEXT_LENGTH, + DB_MAX_IMAGE_COMMENT_LENGTH, +} from "@/misc/hard-limits.js"; -export const MAX_NOTE_TEXT_LENGTH = config.maxNoteLength ?? 3000; +export const MAX_NOTE_TEXT_LENGTH = Math.min( + config.maxNoteLength ?? 3000, + DB_MAX_NOTE_TEXT_LENGTH, +); export const MAX_CAPTION_TEXT_LENGTH = Math.min( config.maxCaptionLength ?? 1500, DB_MAX_IMAGE_COMMENT_LENGTH, diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 89b7a7bf6e..10ea5b15f6 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -58,7 +58,6 @@ import { AnnouncementRead } from "@/models/entities/announcement-read.js"; import { Clip } from "@/models/entities/clip.js"; import { ClipNote } from "@/models/entities/clip-note.js"; import { Antenna } from "@/models/entities/antenna.js"; -import { AntennaNote } from "@/models/entities/antenna-note.js"; import { PromoNote } from "@/models/entities/promo-note.js"; import { PromoRead } from "@/models/entities/promo-read.js"; import { Relay } from "@/models/entities/relay.js"; @@ -168,7 +167,6 @@ export const entities = [ Clip, ClipNote, Antenna, - AntennaNote, PromoNote, PromoRead, Relay, diff --git a/packages/backend/src/misc/download-url.ts b/packages/backend/src/misc/download-url.ts index 7fafb635ba..e9975f3486 100644 --- a/packages/backend/src/misc/download-url.ts +++ b/packages/backend/src/misc/download-url.ts @@ -24,6 +24,7 @@ export async function downloadUrl(url: string, path: string): Promise { .stream(url, { headers: { "User-Agent": config.userAgent, + Host: new URL(url).hostname, }, timeout: { lookup: timeout, diff --git a/packages/backend/src/misc/gen-id.ts b/packages/backend/src/misc/gen-id.ts index ea0d414e77..580c39c3c1 100644 --- a/packages/backend/src/misc/gen-id.ts +++ b/packages/backend/src/misc/gen-id.ts @@ -17,5 +17,5 @@ nativeInitIdGenerator(length, fingerprint); * Ref: https://github.com/paralleldrive/cuid2#parameterized-length */ export function genId(date?: Date): string { - return nativeCreateId(BigInt((date ?? new Date()).getTime())); + return nativeCreateId((date ?? new Date()).getTime()); } diff --git a/packages/backend/src/misc/hard-limits.ts b/packages/backend/src/misc/hard-limits.ts index f1a58a4259..5ce3e0ac9a 100644 --- a/packages/backend/src/misc/hard-limits.ts +++ b/packages/backend/src/misc/hard-limits.ts @@ -3,9 +3,13 @@ /** * Maximum note text length that can be stored in DB. * Surrogate pairs count as one - * DEPRECARTED: use const/MAX_NOTE_TEXT_LENGTH instead + * + * NOTE: this can hypothetically be pushed further + * (up to 250000000), but will likely cause truncations + * and incompatibilities with other servers, + * as well as potential performance issues. */ -// export const DB_MAX_NOTE_TEXT_LENGTH = 8192; +export const DB_MAX_NOTE_TEXT_LENGTH = 100000; /** * Maximum image description length that can be stored in DB. diff --git a/packages/backend/src/misc/process-masto-notes.ts b/packages/backend/src/misc/process-masto-notes.ts new file mode 100644 index 0000000000..b8c1c426d5 --- /dev/null +++ b/packages/backend/src/misc/process-masto-notes.ts @@ -0,0 +1,136 @@ +import * as fs from "node:fs"; +import Logger from "@/services/logger.js"; +import { createTemp, createTempDir } from "./create-temp.js"; +import { downloadUrl } from "./download-url.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { Users } from "@/models/index.js"; +import * as tar from "tar-stream"; +import gunzip from "gunzip-maybe"; +import decompress from "decompress"; +import * as Path from "node:path"; + +const logger = new Logger("process-masto-notes"); + +export async function processMastoNotes( + fn: string, + url: string, + uid: string, +): Promise { + // Create temp file + const [path, cleanup] = await createTemp(); + + const [unzipPath, unzipCleanup] = await createTempDir(); + + logger.info(`Temp file is ${path}`); + + try { + // write content at URL to temp file + await downloadUrl(url, path); + return await processMastoFile(fn, path, unzipPath, uid); + } finally { + cleanup(); + //unzipCleanup(); + } +} + +function processMastoFile(fn: string, path: string, dir: string, uid: string) { + return new Promise(async (resolve, reject) => { + const user = await Users.findOneBy({ id: uid }); + try { + logger.info(`Start unzip ${path}`); + fn.endsWith("tar.gz") + ? await unzipTarGz(path, dir) + : await unzipZip(path, dir); + logger.info(`Unzip to ${dir}`); + const outbox = JSON.parse(fs.readFileSync(`${dir}/outbox.json`)); + for (const note of outbox.orderedItems) { + for (const attachment of note.object.attachment) { + const url = attachment.url.replaceAll("..", ""); + if (url.indexOf('\0') !== -1) { + logger.error(`Found Poison Null Bytes Attack: ${url}`); + reject(); + return; + } + try { + const fpath = Path.resolve(`${dir}${url}`); + if (!fpath.startsWith(dir)) { + logger.error(`Found Path Attack: ${url}`); + reject(); + return; + } + logger.info(fpath); + const driveFile = await addFile({ user: user, path: fpath }); + attachment.driveFile = driveFile; + } catch (e) { + logger.error(`Skipped adding file to drive: ${url}`); + } + } + } + resolve(outbox); + } catch (e) { + logger.error(`Error on extract masto note package: ${fn}`); + reject(e); + } + }); +} + +function createFileDir(fn: string) { + if (!fs.existsSync(fn)) { + fs.mkdirSync(fn, { recursive: true }); + fs.rmdirSync(fn); + } +} + +function unzipZip(fn: string, dir: string) { + return new Promise(async (resolve, reject) => { + try { + decompress(fn, dir).then((files: any) => { + resolve(files); + }); + } catch (e) { + reject(); + } + }); +} + +function unzipTarGz(fn: string, dir: string) { + return new Promise(async (resolve, reject) => { + const onErr = (err: any) => { + logger.error(`pipe broken: ${err}`); + reject(); + }; + try { + const extract = tar.extract().on("error", onErr); + dir = dir.endsWith("/") ? dir : dir + "/"; + const ls: string[] = []; + extract.on("entry", function (header: any, stream: any, next: any) { + try { + ls.push(dir + header.name); + createFileDir(dir + header.name); + stream + .on("error", onErr) + .pipe(fs.createWriteStream(dir + header.name)) + .on("error", onErr); + next(); + } catch (e) { + logger.error(`create dir error:${e}`); + reject(); + } + }); + + extract.on("finish", function () { + resolve(ls); + }); + + fs.createReadStream(fn) + .on("error", onErr) + .pipe(gunzip()) + .on("error", onErr) + .pipe(extract) + .on("error", onErr); + } catch (e) { + logger.error(`unzipTarGz error: ${e}`); + reject(); + } + }); +} diff --git a/packages/backend/src/models/entities/antenna-note.ts b/packages/backend/src/models/entities/antenna-note.ts deleted file mode 100644 index fe982c19ee..0000000000 --- a/packages/backend/src/models/entities/antenna-note.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - Entity, - Index, - JoinColumn, - Column, - ManyToOne, - PrimaryColumn, -} from "typeorm"; -import { Note } from "./note.js"; -import { Antenna } from "./antenna.js"; -import { id } from "../id.js"; - -@Entity() -@Index(["noteId", "antennaId"], { unique: true }) -export class AntennaNote { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: "The note ID.", - }) - public noteId: Note["id"]; - - @ManyToOne((type) => Note, { - onDelete: "CASCADE", - }) - @JoinColumn() - public note: Note | null; - - @Index() - @Column({ - ...id(), - comment: "The antenna ID.", - }) - public antennaId: Antenna["id"]; - - @ManyToOne((type) => Antenna, { - onDelete: "CASCADE", - }) - @JoinColumn() - public antenna: Antenna | null; - - @Index() - @Column("boolean", { - default: false, - }) - public read: boolean; -} diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index d3de7ba53b..e90a5269a1 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/meta.ts @@ -326,13 +326,13 @@ export class Meta { public smtpPort: number | null; @Column("varchar", { - length: 128, + length: 1024, nullable: true, }) public smtpUser: string | null; @Column("varchar", { - length: 128, + length: 1024, nullable: true, }) public smtpPass: string | null; diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts index 119eecdc73..002247d3a3 100644 --- a/packages/backend/src/models/entities/user-profile.ts +++ b/packages/backend/src/models/entities/user-profile.ts @@ -51,6 +51,7 @@ export class UserProfile { public fields: { name: string; value: string; + verified?: boolean; }[]; @Column("varchar", { diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index cfc3b01c55..8782d57408 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -51,7 +51,6 @@ import { UsedUsername } from "./entities/used-username.js"; import { ClipRepository } from "./repositories/clip.js"; import { ClipNote } from "./entities/clip-note.js"; import { AntennaRepository } from "./repositories/antenna.js"; -import { AntennaNote } from "./entities/antenna-note.js"; import { PromoNote } from "./entities/promo-note.js"; import { PromoRead } from "./entities/promo-read.js"; import { EmojiRepository } from "./repositories/emoji.js"; @@ -123,7 +122,6 @@ export const ModerationLogs = ModerationLogRepository; export const Clips = ClipRepository; export const ClipNotes = db.getRepository(ClipNote); export const Antennas = AntennaRepository; -export const AntennaNotes = db.getRepository(AntennaNote); export const PromoNotes = db.getRepository(PromoNote); export const PromoReads = db.getRepository(PromoRead); export const Relays = RelayRepository; diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index e4aab896d0..e7c4b6f008 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -18,7 +18,6 @@ import { createPerson } from "@/remote/activitypub/models/person.js"; import { AnnouncementReads, Announcements, - AntennaNotes, Blockings, ChannelFollowings, DriveFiles, @@ -258,23 +257,24 @@ export const UserRepository = db.getRepository(User).extend({ }, async getHasUnreadAntenna(userId: User["id"]): Promise { - try { - const myAntennas = (await getAntennas()).filter( - (a) => a.userId === userId, - ); + // try { + // const myAntennas = (await getAntennas()).filter( + // (a) => a.userId === userId, + // ); - const unread = - myAntennas.length > 0 - ? await AntennaNotes.findOneBy({ - antennaId: In(myAntennas.map((x) => x.id)), - read: false, - }) - : null; + // const unread = + // myAntennas.length > 0 + // ? await AntennaNotes.findOneBy({ + // antennaId: In(myAntennas.map((x) => x.id)), + // read: false, + // }) + // : null; - return unread != null; - } catch (e) { - return false; - } + // return unread != null; + // } catch (e) { + // return false; + // } + return false; // TODO }, async getHasUnreadChannel(userId: User["id"]): Promise { diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts index d7580a4f62..93aed7cb8b 100644 --- a/packages/backend/src/queue/index.ts +++ b/packages/backend/src/queue/index.ts @@ -576,6 +576,16 @@ export default function () { { removeOnComplete: true, removeOnFail: true }, ); + systemQueue.add( + "verifyLinks", + {}, + { + repeat: { cron: "0 0 * * 0" }, + removeOnComplete: true, + removeOnFail: true, + }, + ); + processSystemQueue(systemQueue); } diff --git a/packages/backend/src/queue/processors/db/export-notes.ts b/packages/backend/src/queue/processors/db/export-notes.ts index de8fac05b4..bf53f83603 100644 --- a/packages/backend/src/queue/processors/db/export-notes.ts +++ b/packages/backend/src/queue/processors/db/export-notes.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs"; import { queueLogger } from "../../logger.js"; import { addFile } from "@/services/drive/add-file.js"; import { format as dateFormat } from "date-fns"; -import { Users, Notes, Polls } from "@/models/index.js"; +import { Users, Notes, Polls, DriveFiles } from "@/models/index.js"; import { MoreThan } from "typeorm"; import type { Note } from "@/models/entities/note.js"; import type { Poll } from "@/models/entities/poll.js"; @@ -75,7 +75,7 @@ export async function exportNotes( if (note.hasPoll) { poll = await Polls.findOneByOrFail({ noteId: note.id }); } - const content = JSON.stringify(serialize(note, poll)); + const content = JSON.stringify(await serialize(note, poll)); const isFirst = exportedNotesCount === 0; await write(isFirst ? content : ",\n" + content); exportedNotesCount++; @@ -112,15 +112,16 @@ export async function exportNotes( done(); } -function serialize( +async function serialize( note: Note, poll: Poll | null = null, -): Record { +): Promise> { return { id: note.id, text: note.text, createdAt: note.createdAt, fileIds: note.fileIds, + files: await DriveFiles.packMany(note.fileIds), replyId: note.replyId, renoteId: note.renoteId, poll: poll, diff --git a/packages/backend/src/queue/processors/db/import-calckey-post.ts b/packages/backend/src/queue/processors/db/import-calckey-post.ts index 28e794aa0c..945c50d637 100644 --- a/packages/backend/src/queue/processors/db/import-calckey-post.ts +++ b/packages/backend/src/queue/processors/db/import-calckey-post.ts @@ -3,6 +3,8 @@ import create from "@/services/note/create.js"; import { Users } from "@/models/index.js"; import type { DbUserImportMastoPostJobData } from "@/queue/types.js"; import { queueLogger } from "../../logger.js"; +import { uploadFromUrl } from "@/services/drive/upload-from-url.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; import type Bull from "bull"; const logger = queueLogger.createSubLogger("import-calckey-post"); @@ -29,10 +31,25 @@ export async function importCkPost( done(); return; } + const urls = (post.files || []) + .map((x: any) => x.url) + .filter((x: String) => x.startsWith("http")); + const files: DriveFile[] = []; + for (const url of urls) { + try { + const file = await uploadFromUrl({ + url: url, + user: user, + }); + files.push(file); + } catch (e) { + logger.error(`Skipped adding file to drive: ${url}`); + } + } const { text, cw, localOnly, createdAt } = Post.parse(post); const note = await create(user, { createdAt: createdAt, - files: undefined, + files: files.length == 0 ? undefined : files, poll: undefined, text: text || undefined, reply: null, diff --git a/packages/backend/src/queue/processors/db/import-masto-post.ts b/packages/backend/src/queue/processors/db/import-masto-post.ts index efa4adf3f3..1d18008a0f 100644 --- a/packages/backend/src/queue/processors/db/import-masto-post.ts +++ b/packages/backend/src/queue/processors/db/import-masto-post.ts @@ -6,6 +6,8 @@ import type Bull from "bull"; import { htmlToMfm } from "@/remote/activitypub/misc/html-to-mfm.js"; import { resolveNote } from "@/remote/activitypub/models/note.js"; import { Note } from "@/models/entities/note.js"; +import { uploadFromUrl } from "@/services/drive/upload-from-url.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; const logger = queueLogger.createSubLogger("import-masto-post"); @@ -43,9 +45,32 @@ export async function importMastoPost( throw e; } job.progress(80); + + let files: DriveFile[] = (post.object.attachment || []) + .map((x: any) => x?.driveFile) + .filter((x: any) => x); + + if (files.length == 0) { + const urls = post.object.attachment + .map((x: any) => x.url) + .filter((x: String) => x.startsWith("http")); + files = []; + for (const url of urls) { + try { + const file = await uploadFromUrl({ + url: url, + user: user, + }); + files.push(file); + } catch (e) { + logger.error(`Skipped adding file to drive: ${url}`); + } + } + } + const note = await create(user, { createdAt: new Date(post.object.published), - files: undefined, + files: files.length == 0 ? undefined : files, poll: undefined, text: text || undefined, reply, diff --git a/packages/backend/src/queue/processors/db/import-posts.ts b/packages/backend/src/queue/processors/db/import-posts.ts index f92a5f710a..9bde7479ed 100644 --- a/packages/backend/src/queue/processors/db/import-posts.ts +++ b/packages/backend/src/queue/processors/db/import-posts.ts @@ -1,4 +1,5 @@ import { downloadTextFile } from "@/misc/download-text-file.js"; +import { processMastoNotes } from "@/misc/process-masto-notes.js"; import { Users, DriveFiles } from "@/models/index.js"; import type { DbUserImportPostsJobData } from "@/queue/types.js"; import { queueLogger } from "../../logger.js"; @@ -30,6 +31,26 @@ export async function importPosts( return; } + if (file.name.endsWith("tar.gz") || file.name.endsWith("zip")) { + try { + logger.info("Reading Mastodon archive"); + const outbox = await processMastoNotes( + file.name, + file.url, + job.data.user.id, + ); + for (const post of outbox.orderedItems) { + createImportMastoPostJob(job.data.user, post, job.data.signatureCheck); + } + } catch (e) { + // handle error + logger.warn(`Failed reading Mastodon archive: ${e}`); + } + logger.succ("Mastodon archive imported"); + done(); + return; + } + const json = await downloadTextFile(file.url); try { diff --git a/packages/backend/src/queue/processors/system/index.ts b/packages/backend/src/queue/processors/system/index.ts index 53321de5f9..697d24d067 100644 --- a/packages/backend/src/queue/processors/system/index.ts +++ b/packages/backend/src/queue/processors/system/index.ts @@ -5,6 +5,7 @@ import { cleanCharts } from "./clean-charts.js"; import { checkExpiredMutings } from "./check-expired-mutings.js"; import { clean } from "./clean.js"; import { setLocalEmojiSizes } from "./local-emoji-size.js"; +import { verifyLinks } from "./verify-links.js"; const jobs = { tickCharts, @@ -13,6 +14,7 @@ const jobs = { checkExpiredMutings, clean, setLocalEmojiSizes, + verifyLinks, } as Record< string, | Bull.ProcessCallbackFunction> diff --git a/packages/backend/src/queue/processors/system/verify-links.ts b/packages/backend/src/queue/processors/system/verify-links.ts new file mode 100644 index 0000000000..3ddda9baf4 --- /dev/null +++ b/packages/backend/src/queue/processors/system/verify-links.ts @@ -0,0 +1,44 @@ +import type Bull from "bull"; + +import { UserProfiles } from "@/models/index.js"; +import { Not } from "typeorm"; +import { queueLogger } from "../../logger.js"; +import { verifyLink } from "@/services/fetch-rel-me.js"; +import config from "@/config/index.js"; + +const logger = queueLogger.createSubLogger("verify-links"); + +export async function verifyLinks( + job: Bull.Job>, + done: any, +): Promise { + logger.info("Verifying links..."); + + const usersToVerify = await UserProfiles.findBy({ + fields: Not(null), + userHost: "", + }); + for (const user of usersToVerify) { + for (const field of user.fields) { + if (!field || field.name === "" || field.value === "") { + continue; + } + if (field.value.startsWith("http") && user.user?.username) { + field.verified = await verifyLink(field.value, user.user.username); + } + } + if (user.fields.length > 0) { + try { + await UserProfiles.update(user.userId, { + fields: user.fields, + }); + } catch (e) { + logger.error(`Failed to update user ${user.userId} ${e}`); + done(e); + } + } + } + + logger.succ("All links successfully verified."); + done(); +} diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index c2117d51a5..ed16450120 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -30,7 +30,7 @@ export const meta = { id: "c3a5a51e-04d4-11ee-be56-0242ac120002", }, noKeywords: { - message: "No keywords", + message: "No keywords.", code: "NO_KEYWORDS", id: "aa975b74-1ddb-11ee-be56-0242ac120002", }, diff --git a/packages/backend/src/server/api/endpoints/antennas/markread.ts b/packages/backend/src/server/api/endpoints/antennas/markread.ts index e29e13bbbb..db8e683e49 100644 --- a/packages/backend/src/server/api/endpoints/antennas/markread.ts +++ b/packages/backend/src/server/api/endpoints/antennas/markread.ts @@ -1,7 +1,6 @@ import define from "../../define.js"; -import { Antennas, AntennaNotes } from "@/models/index.js"; +import { Antennas } from "@/models/index.js"; import { FindOptionsWhere } from "typeorm"; -import { AntennaNote } from "@/models/entities/antenna-note.js"; export const meta = { tags: ["antennas", "account"], @@ -29,15 +28,15 @@ export default define(meta, paramDef, async (ps, me) => { return null; } - await AntennaNotes.update( - { - antennaId: antenna.id, - read: false, - }, - { - read: true, - }, - ); + // await AntennaNotes.update( + // { + // antennaId: antenna.id, + // read: false, + // }, + // { + // read: true, + // }, + // ); return true; }); diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 29e2e085ad..6ca71c08e2 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -1,6 +1,8 @@ import define from "../../define.js"; import readNote from "@/services/note/read.js"; -import { Antennas, Notes, AntennaNotes } from "@/models/index.js"; +import { Antennas, Notes } from "@/models/index.js"; +import { redisClient } from "@/db/redis.js"; +import { genId } from "@/misc/gen-id.js"; import { makePaginationQuery } from "../../common/make-pagination-query.js"; import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; @@ -58,6 +60,26 @@ export default define(meta, paramDef, async (ps, user) => { throw new ApiError(meta.errors.noSuchAntenna); } + const noteIdsRes = await redisClient.xrevrange( + `antennaTimeline:${antenna.id}`, + ps.untilDate || "+", + "-", + "COUNT", + ps.limit + 1, + ); // untilIdに指定したものも含まれるため+1 + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes + .map((x) => x[1][1]) + .filter((x) => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + const query = makePaginationQuery( Notes.createQueryBuilder("note"), ps.sinceId, @@ -65,11 +87,7 @@ export default define(meta, paramDef, async (ps, user) => { ps.sinceDate, ps.untilDate, ) - .innerJoin( - AntennaNotes.metadata.targetName, - "antennaNote", - "antennaNote.noteId = note.id", - ) + .where("note.id IN (:...noteIds)", { noteIds: noteIds }) .innerJoinAndSelect("note.user", "user") .leftJoinAndSelect("user.avatar", "avatar") .leftJoinAndSelect("user.banner", "banner") @@ -81,7 +99,6 @@ export default define(meta, paramDef, async (ps, user) => { .leftJoinAndSelect("renote.user", "renoteUser") .leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar") .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner") - .andWhere("antennaNote.antennaId = :antennaId", { antennaId: antenna.id }) .andWhere("note.visibility != 'home'"); generateVisibilityQuery(query, user); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 0637251a6b..6d3bde2b8d 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -12,7 +12,9 @@ import type { UserProfile } from "@/models/entities/user-profile.js"; import { notificationTypes } from "@/types.js"; import { normalizeForSearch } from "@/misc/normalize-for-search.js"; import { langmap } from "@/misc/langmap.js"; +import { verifyLink } from "@/services/fetch-rel-me.js"; import { ApiError } from "../../error.js"; +import config from "@/config/index.js"; import define from "../../define.js"; export const meta = { @@ -58,6 +60,18 @@ export const meta = { code: "INVALID_REGEXP", id: "0d786918-10df-41cd-8f33-8dec7d9a89a5", }, + + invalidFieldName: { + message: "Invalid field name.", + code: "INVALID_FIELD_NAME", + id: "8f81972e-8b53-4d30-b0d2-efb026dda673", + }, + + invalidFieldValue: { + message: "Invalid field value.", + code: "INVALID_FIELD_VALUE", + id: "aede7444-244b-11ee-be56-0242ac120002", + }, }, res: { @@ -234,16 +248,29 @@ export default define(meta, paramDef, async (ps, _user, token) => { } if (ps.fields) { + for (const field of ps.fields) { + if (!field || field.name === "" || field.value === "") { + continue; + } + if (typeof field.name !== "string" || field.name === "") { + throw new ApiError(meta.errors.invalidFieldName); + } + if (typeof field.value !== "string" || field.value === "") { + throw new ApiError(meta.errors.invalidFieldValue); + } + if (field.value.startsWith("http")) { + field.verified = await verifyLink(field.value, user.username); + } + } + profileUpdates.fields = ps.fields - .filter( - (x) => - typeof x.name === "string" && - x.name !== "" && - typeof x.value === "string" && - x.value !== "", - ) + .filter((x) => Object.keys(x).length !== 0) .map((x) => { - return { name: x.name, value: x.value }; + return { + name: x.name, + value: x.value, + verified: x.verified, + }; }); } diff --git a/packages/backend/src/server/proxy/proxy-media.ts b/packages/backend/src/server/proxy/proxy-media.ts index a9c257bfeb..b3bb031244 100644 --- a/packages/backend/src/server/proxy/proxy-media.ts +++ b/packages/backend/src/server/proxy/proxy-media.ts @@ -1,4 +1,6 @@ import * as fs from "node:fs"; +import net from "node:net"; +import { promises } from "node:dns"; import type Koa from "koa"; import sharp from "sharp"; import type { IImage } from "@/services/drive/image-processor.js"; @@ -19,6 +21,40 @@ export async function proxyMedia(ctx: Koa.Context) { return; } + const { hostname } = new URL(url); + let resolvedIps; + try { + resolvedIps = await promises.resolve(hostname); + } catch (error) { + ctx.status = 400; + ctx.body = { message: "Invalid URL" }; + return; + } + + const isSSRF = resolvedIps.some((ip) => { + if (net.isIPv4(ip)) { + const parts = ip.split(".").map(Number); + return ( + parts[0] === 10 || + (parts[0] === 172 && parts[1] >= 16 && parts[1] < 32) || + (parts[0] === 192 && parts[1] === 168) || + parts[0] === 127 || + parts[0] === 0 + ); + } else if (net.isIPv6(ip)) { + return ( + ip.startsWith("::") || ip.startsWith("fc00:") || ip.startsWith("fe80:") + ); + } + return false; + }); + + if (isSSRF) { + ctx.status = 400; + ctx.body = { message: "Access to this URL is not allowed" }; + return; + } + // Create temp file const [path, cleanup] = await createTemp(); diff --git a/packages/backend/src/services/add-note-to-antenna.ts b/packages/backend/src/services/add-note-to-antenna.ts index 38979acb40..8d6d3e84d0 100644 --- a/packages/backend/src/services/add-note-to-antenna.ts +++ b/packages/backend/src/services/add-note-to-antenna.ts @@ -1,61 +1,24 @@ import type { Antenna } from "@/models/entities/antenna.js"; import type { Note } from "@/models/entities/note.js"; -import { AntennaNotes, Mutings, Notes } from "@/models/index.js"; import { genId } from "@/misc/gen-id.js"; -import { isUserRelated } from "@/misc/is-user-related.js"; -import { publishAntennaStream, publishMainStream } from "@/services/stream.js"; +import { redisClient } from "@/db/redis.js"; +import { publishAntennaStream } from "@/services/stream.js"; import type { User } from "@/models/entities/user.js"; export async function addNoteToAntenna( antenna: Antenna, note: Note, - noteUser: { id: User["id"] }, + _noteUser: { id: User["id"] }, ) { - // 通知しない設定になっているか、自分自身の投稿なら既読にする - const read = !antenna.notify || antenna.userId === noteUser.id; - - AntennaNotes.insert({ - id: genId(), - antennaId: antenna.id, - noteId: note.id, - read: read, - }); + redisClient.xadd( + `antennaTimeline:${antenna.id}`, + "MAXLEN", + "~", + "200", + "*", + "note", + note.id, + ); publishAntennaStream(antenna.id, "note", note); - - if (!read) { - const mutings = await Mutings.find({ - where: { - muterId: antenna.userId, - }, - select: ["muteeId"], - }); - - // Copy - const _note: Note = { - ...note, - }; - - if (note.replyId != null) { - _note.reply = await Notes.findOneByOrFail({ id: note.replyId }); - } - if (note.renoteId != null) { - _note.renote = await Notes.findOneByOrFail({ id: note.renoteId }); - } - - if (isUserRelated(_note, new Set(mutings.map((x) => x.muteeId)))) { - return; - } - - // 2秒経っても既読にならなかったら通知 - setTimeout(async () => { - const unread = await AntennaNotes.findOneBy({ - antennaId: antenna.id, - read: false, - }); - if (unread) { - publishMainStream(antenna.userId, "unreadAntenna", antenna); - } - }, 2000); - } } diff --git a/packages/backend/src/services/fetch-rel-me.ts b/packages/backend/src/services/fetch-rel-me.ts new file mode 100644 index 0000000000..7b450c229c --- /dev/null +++ b/packages/backend/src/services/fetch-rel-me.ts @@ -0,0 +1,33 @@ +import { getHtml } from "@/misc/fetch.js"; +import { JSDOM } from "jsdom"; +import config from "@/config/index.js"; + +async function getRelMeLinks(url: string): Promise { + try { + const html = await getHtml(url); + const dom = new JSDOM(html); + const relMeLinks = [ + ...dom.window.document.querySelectorAll("a[rel='me']"), + ...dom.window.document.querySelectorAll("link[rel='me']"), + ].map((a) => (a as HTMLAnchorElement | HTMLLinkElement).href); + return relMeLinks; + } catch { + return []; + } +} + +export async function verifyLink(link: string, username: string): Promise { + let verified = false; + if (link.startsWith("http")) { + const relMeLinks = await getRelMeLinks(link); + verified = relMeLinks.some((href) => + new RegExp( + `^https?:\/\/${config.host.replace( + /[.*+\-?^${}()|[\]\\]/g, + "\\$&", + )}\/@${username.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&")}$`, + ).test(href), + ); + } + return verified; +} diff --git a/packages/backend/src/services/note/read.ts b/packages/backend/src/services/note/read.ts index 53188f15f7..5e61fe95d8 100644 --- a/packages/backend/src/services/note/read.ts +++ b/packages/backend/src/services/note/read.ts @@ -3,7 +3,6 @@ import type { Note } from "@/models/entities/note.js"; import type { User } from "@/models/entities/user.js"; import { NoteUnreads, - AntennaNotes, Users, Followings, ChannelFollowings, @@ -51,11 +50,11 @@ export default async function ( ).map((x) => x.followeeId), ); - const myAntennas = (await getAntennas()).filter((a) => a.userId === userId); + // const myAntennas = (await getAntennas()).filter((a) => a.userId === userId); const readMentions: (Note | Packed<"Note">)[] = []; const readSpecifiedNotes: (Note | Packed<"Note">)[] = []; const readChannelNotes: (Note | Packed<"Note">)[] = []; - const readAntennaNotes: (Note | Packed<"Note">)[] = []; + // const readAntennaNotes: (Note | Packed<"Note">)[] = []; for (const note of notes) { if (note.mentions?.includes(userId)) { @@ -68,22 +67,22 @@ export default async function ( readChannelNotes.push(note); } - if (note.user != null) { - // たぶんnullになることは無いはずだけど一応 - for (const antenna of myAntennas) { - if ( - await checkHitAntenna( - antenna, - note, - note.user, - undefined, - Array.from(following), - ) - ) { - readAntennaNotes.push(note); - } - } - } + // if (note.user != null) { + // // たぶんnullになることは無いはずだけど一応 + // for (const antenna of myAntennas) { + // if ( + // await checkHitAntenna( + // antenna, + // note, + // note.user, + // undefined, + // Array.from(following), + // ) + // ) { + // readAntennaNotes.push(note); + // } + // } + // } } if ( @@ -141,33 +140,33 @@ export default async function ( }); } - if (readAntennaNotes.length > 0) { - await AntennaNotes.update( - { - antennaId: In(myAntennas.map((a) => a.id)), - noteId: In(readAntennaNotes.map((n) => n.id)), - }, - { - read: true, - }, - ); + // if (readAntennaNotes.length > 0) { + // await AntennaNotes.update( + // { + // antennaId: In(myAntennas.map((a) => a.id)), + // noteId: In(readAntennaNotes.map((n) => n.id)), + // }, + // { + // read: true, + // }, + // ); - // TODO: まとめてクエリしたい - for (const antenna of myAntennas) { - const count = await AntennaNotes.countBy({ - antennaId: antenna.id, - read: false, - }); + // // TODO: まとめてクエリしたい + // for (const antenna of myAntennas) { + // const count = await AntennaNotes.countBy({ + // antennaId: antenna.id, + // read: false, + // }); - if (count === 0) { - publishMainStream(userId, "readAntenna", antenna); - } - } + // if (count === 0) { + // publishMainStream(userId, "readAntenna", antenna); + // } + // } - Users.getHasUnreadAntenna(userId).then((unread) => { - if (!unread) { - publishMainStream(userId, "readAllAntennas"); - } - }); - } + // Users.getHasUnreadAntenna(userId).then((unread) => { + // if (!unread) { + // publishMainStream(userId, "readAllAntennas"); + // } + // }); + // } } diff --git a/packages/calckey-js/src/entities.ts b/packages/calckey-js/src/entities.ts index 5a581a54cd..346830e0f7 100644 --- a/packages/calckey-js/src/entities.ts +++ b/packages/calckey-js/src/entities.ts @@ -38,7 +38,11 @@ export type UserDetailed = UserLite & { createdAt: DateString; description: string | null; ffVisibility: "public" | "followers" | "private"; - fields: { name: string; value: string }[]; + fields: { + name: string; + value: string; + verified?: boolean; + }[]; followersCount: number; followingCount: number; hasPendingFollowRequestFromYou: boolean; diff --git a/packages/client/.eslintrc.json b/packages/client/.eslintrc.json new file mode 100644 index 0000000000..fd4718003d --- /dev/null +++ b/packages/client/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["@eslint-sets/vue3", "@eslint-sets/vue3-ts"], + "plugins": ["file-progress", "prettier"], + "rules": { + "file-progress/activate": 1 + } +} diff --git a/packages/client/package.json b/packages/client/package.json index 95fb9f9b2a..3fe101b811 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -4,11 +4,14 @@ "scripts": { "watch": "pnpm vite build --watch --mode development", "build": "pnpm vite build", - "lint": "pnpm rome check \"src/**/*.{ts,vue}\"", - "format": "pnpm rome format * --write && pnpm prettier --write '**/*.{scss,vue}'" + "lint": "pnpm rome check **/*.ts --apply && pnpm run lint:vue", + "lint:vue": "pnpm paralint --ext .vue --fix '**/*.vue' --cache", + "format": "pnpm rome format * --write && pnpm prettier --write '**/*.{scss,vue}' --cache --cache-strategy metadata" }, "devDependencies": { "@discordapp/twemoji": "14.1.2", + "@eslint-sets/eslint-config-vue3": "^5.6.1", + "@eslint-sets/eslint-config-vue3-ts": "^3.3.0", "@phosphor-icons/web": "^2.0.3", "@rollup/plugin-alias": "3.1.9", "@rollup/plugin-json": "4.1.0", @@ -46,6 +49,8 @@ "date-fns": "2.30.0", "emojilib": "github:thatonecalculator/emojilib", "escape-regexp": "0.0.1", + "eslint-config-prettier": "^8.6.0", + "eslint-plugin-file-progress": "^1.3.0", "eventemitter3": "5.0.1", "fast-blurhash": "^1.1.2", "focus-trap": "^7.5.2", @@ -57,6 +62,7 @@ "katex": "0.16.8", "matter-js": "0.18.0", "mfm-js": "0.23.3", + "paralint": "^1.2.1", "photoswipe": "5.3.8", "prettier": "3.0.0", "prettier-plugin-vue": "1.1.6", diff --git a/packages/client/src/components/MkAbuseReport.vue b/packages/client/src/components/MkAbuseReport.vue index ccb85d7222..f92d10ba77 100644 --- a/packages/client/src/components/MkAbuseReport.vue +++ b/packages/client/src/components/MkAbuseReport.vue @@ -80,11 +80,11 @@ const emit = defineEmits<{ (ev: "resolved", reportId: string): void; }>(); -let forward = $ref(props.report.forwarded); +const forward = $ref(props.report.forwarded); function resolve() { os.apiWithDialog("admin/resolve-abuse-user-report", { - forward: forward, + forward, reportId: props.report.id, }).then(() => { emit("resolved", props.report.id); diff --git a/packages/client/src/components/MkAbuseReportWindow.vue b/packages/client/src/components/MkAbuseReportWindow.vue index 6fdf3b9e0b..fc80cd66f5 100644 --- a/packages/client/src/components/MkAbuseReportWindow.vue +++ b/packages/client/src/components/MkAbuseReportWindow.vue @@ -41,7 +41,7 @@ diff --git a/packages/client/src/components/MkButton.vue b/packages/client/src/components/MkButton.vue index ab02819086..3a6b8f321a 100644 --- a/packages/client/src/components/MkButton.vue +++ b/packages/client/src/components/MkButton.vue @@ -49,8 +49,8 @@ const emit = defineEmits<{ (ev: "click", payload: MouseEvent): void; }>(); -let el = $ref(null); -let ripples = $ref(null); +const el = $ref(null); +const ripples = $ref(null); onMounted(() => { if (props.autofocus) { diff --git a/packages/client/src/components/MkCaptcha.vue b/packages/client/src/components/MkCaptcha.vue index 71d8c29964..146c512fb8 100644 --- a/packages/client/src/components/MkCaptcha.vue +++ b/packages/client/src/components/MkCaptcha.vue @@ -6,11 +6,11 @@