diff --git a/packages/backend/src/migration/1706827327619-add-language-selector.ts b/packages/backend/src/migration/1706827327619-add-language-selector.ts new file mode 100644 index 000000000..d2c3baeaa --- /dev/null +++ b/packages/backend/src/migration/1706827327619-add-language-selector.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddLanguageSelector1706827327619 implements MigrationInterface { + async up(queryRunner: QueryRunner) { + await queryRunner.query( + `ALTER TABLE "note" ADD COLUMN IF NOT EXISTS "lang" character varying(10)`, + ); + } + + async down(queryRunner: QueryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN IF EXISTS "lang"`); + } +} diff --git a/packages/backend/src/misc/langmap.ts b/packages/backend/src/misc/langmap.ts index 106130d3c..c4d4f0beb 100644 --- a/packages/backend/src/misc/langmap.ts +++ b/packages/backend/src/misc/langmap.ts @@ -1,217 +1,71 @@ -// TODO: sharedに置いてフロントエンドのと統合したい -export const langmap = { - ach: { - nativeName: "Lwo", - }, - ady: { - nativeName: "Адыгэбзэ", - }, +export const iso639Langs1 = { af: { nativeName: "Afrikaans", }, - "af-NA": { - nativeName: "Afrikaans (Namibia)", - }, - "af-ZA": { - nativeName: "Afrikaans (South Africa)", - }, ak: { nativeName: "Tɕɥi", }, ar: { nativeName: "العربية", + rtl: true, }, - "ar-AR": { - nativeName: "العربية", - }, - "ar-MA": { - nativeName: "العربية", - }, - "ar-SA": { - nativeName: "العربية (السعودية)", - }, - "ay-BO": { + ay: { nativeName: "Aymar aru", }, az: { nativeName: "Azərbaycan dili", }, - "az-AZ": { - nativeName: "Azərbaycan dili", - }, - "be-BY": { + be: { nativeName: "Беларуская", }, bg: { nativeName: "Български", }, - "bg-BG": { - nativeName: "Български", - }, bn: { nativeName: "বাংলা", }, - "bn-IN": { - nativeName: "বাংলা (ভারত)", - }, - "bn-BD": { - nativeName: "বাংলা(বাংলাদেশ)", - }, br: { nativeName: "Brezhoneg", }, - "bs-BA": { + bs: { nativeName: "Bosanski", }, ca: { nativeName: "Català", }, - "ca-ES": { - nativeName: "Català", - }, - cak: { - nativeName: "Maya Kaqchikel", - }, - "ck-US": { - nativeName: "ᏣᎳᎩ (tsalagi)", - }, cs: { nativeName: "Čeština", }, - "cs-CZ": { - nativeName: "Čeština", - }, cy: { nativeName: "Cymraeg", }, - "cy-GB": { - nativeName: "Cymraeg", - }, da: { nativeName: "Dansk", }, - "da-DK": { - nativeName: "Dansk", - }, de: { nativeName: "Deutsch", }, - "de-AT": { - nativeName: "Deutsch (Österreich)", - }, - "de-DE": { - nativeName: "Deutsch (Deutschland)", - }, - "de-CH": { - nativeName: "Deutsch (Schweiz)", - }, - dsb: { - nativeName: "Dolnoserbšćina", - }, el: { nativeName: "Ελληνικά", }, - "el-GR": { - nativeName: "Ελληνικά", - }, en: { nativeName: "English", }, - "en-GB": { - nativeName: "English (UK)", - }, - "en-AU": { - nativeName: "English (Australia)", - }, - "en-CA": { - nativeName: "English (Canada)", - }, - "en-IE": { - nativeName: "English (Ireland)", - }, - "en-IN": { - nativeName: "English (India)", - }, - "en-PI": { - nativeName: "English (Pirate)", - }, - "en-SG": { - nativeName: "English (Singapore)", - }, - "en-UD": { - nativeName: "English (Upside Down)", - }, - "en-US": { - nativeName: "English (US)", - }, - "en-ZA": { - nativeName: "English (South Africa)", - }, - "en@pirate": { - nativeName: "English (Pirate)", - }, eo: { nativeName: "Esperanto", }, - "eo-EO": { - nativeName: "Esperanto", - }, es: { nativeName: "Español", }, - "es-AR": { - nativeName: "Español (Argentine)", - }, - "es-419": { - nativeName: "Español (Latinoamérica)", - }, - "es-CL": { - nativeName: "Español (Chile)", - }, - "es-CO": { - nativeName: "Español (Colombia)", - }, - "es-EC": { - nativeName: "Español (Ecuador)", - }, - "es-ES": { - nativeName: "Español (España)", - }, - "es-LA": { - nativeName: "Español (Latinoamérica)", - }, - "es-NI": { - nativeName: "Español (Nicaragua)", - }, - "es-MX": { - nativeName: "Español (México)", - }, - "es-US": { - nativeName: "Español (Estados Unidos)", - }, - "es-VE": { - nativeName: "Español (Venezuela)", - }, et: { nativeName: "eesti keel", }, - "et-EE": { - nativeName: "Eesti (Estonia)", - }, eu: { nativeName: "Euskara", }, - "eu-ES": { - nativeName: "Euskara", - }, fa: { nativeName: "فارسی", - }, - "fa-IR": { - nativeName: "فارسی", - }, - "fb-LT": { - nativeName: "Leet Speak", + rtl: true, }, ff: { nativeName: "Fulah", @@ -219,154 +73,86 @@ export const langmap = { fi: { nativeName: "Suomi", }, - "fi-FI": { - nativeName: "Suomi", - }, fo: { nativeName: "Føroyskt", }, - "fo-FO": { - nativeName: "Føroyskt (Færeyjar)", - }, fr: { nativeName: "Français", }, - "fr-CA": { - nativeName: "Français (Canada)", - }, - "fr-FR": { - nativeName: "Français (France)", - }, - "fr-BE": { - nativeName: "Français (Belgique)", - }, - "fr-CH": { - nativeName: "Français (Suisse)", - }, - "fy-NL": { + fy: { nativeName: "Frysk", }, ga: { nativeName: "Gaeilge", }, - "ga-IE": { - nativeName: "Gaeilge", - }, gd: { nativeName: "Gàidhlig", }, gl: { nativeName: "Galego", }, - "gl-ES": { - nativeName: "Galego", - }, - "gn-PY": { + gn: { nativeName: "Avañe'ẽ", }, - "gu-IN": { + gu: { nativeName: "ગુજરાતી", }, gv: { nativeName: "Gaelg", }, - "gx-GR": { - nativeName: "Ἑλληνική ἀρχαία", - }, he: { nativeName: "עברית‏", - }, - "he-IL": { - nativeName: "עברית‏", + rtl: true, }, hi: { nativeName: "हिन्दी", }, - "hi-IN": { - nativeName: "हिन्दी", - }, hr: { nativeName: "Hrvatski", }, - "hr-HR": { - nativeName: "Hrvatski", - }, - hsb: { - nativeName: "Hornjoserbšćina", - }, ht: { nativeName: "Kreyòl", }, hu: { nativeName: "Magyar", }, - "hu-HU": { - nativeName: "Magyar", - }, hy: { nativeName: "Հայերեն", }, - "hy-AM": { - nativeName: "Հայերեն (Հայաստան)", - }, id: { nativeName: "Bahasa Indonesia", }, - "id-ID": { - nativeName: "Bahasa Indonesia", - }, is: { nativeName: "Íslenska", }, - "is-IS": { - nativeName: "Íslenska (Iceland)", - }, it: { nativeName: "Italiano", }, - "it-IT": { - nativeName: "Italiano", - }, ja: { nativeName: "日本語", }, - "ja-JP": { - nativeName: "日本語 (日本)", - }, - "jv-ID": { + jv: { nativeName: "Basa Jawa", }, - "ka-GE": { + ka: { nativeName: "ქართული", }, - "kk-KZ": { + kk: { nativeName: "Қазақша", }, - km: { - nativeName: "ភាសាខ្មែរ", - }, kl: { nativeName: "kalaallisut", }, - "km-KH": { + km: { nativeName: "ភាសាខ្មែរ", }, - kab: { - nativeName: "Taqbaylit", - }, kn: { nativeName: "ಕನ್ನಡ", }, - "kn-IN": { - nativeName: "ಕನ್ನಡ (India)", - }, ko: { nativeName: "한국어", }, - "ko-KR": { - nativeName: "한국어 (한국)", - }, - "ku-TR": { + ku: { nativeName: "Kurdî", }, kw: { @@ -375,66 +161,39 @@ export const langmap = { la: { nativeName: "Latin", }, - "la-VA": { - nativeName: "Latin", - }, lb: { nativeName: "Lëtzebuergesch", }, - "li-NL": { + li: { nativeName: "Lèmbörgs", }, lt: { nativeName: "Lietuvių", }, - "lt-LT": { - nativeName: "Lietuvių", - }, lv: { nativeName: "Latviešu", }, - "lv-LV": { - nativeName: "Latviešu", - }, - mai: { - nativeName: "मैथिली, মৈথিলী", - }, - "mg-MG": { + mg: { nativeName: "Malagasy", }, mk: { nativeName: "Македонски", }, - "mk-MK": { - nativeName: "Македонски (Македонски)", - }, ml: { nativeName: "മലയാളം", }, - "ml-IN": { - nativeName: "മലയാളം", - }, - "mn-MN": { + mn: { nativeName: "Монгол", }, mr: { nativeName: "मराठी", }, - "mr-IN": { - nativeName: "मराठी", - }, ms: { nativeName: "Bahasa Melayu", }, - "ms-MY": { - nativeName: "Bahasa Melayu", - }, mt: { nativeName: "Malti", }, - "mt-MT": { - nativeName: "Malti", - }, my: { nativeName: "ဗမာစကာ", }, @@ -444,223 +203,179 @@ export const langmap = { nb: { nativeName: "Norsk (bokmål)", }, - "nb-NO": { - nativeName: "Norsk (bokmål)", - }, ne: { nativeName: "नेपाली", }, - "ne-NP": { - nativeName: "नेपाली", - }, nl: { nativeName: "Nederlands", }, - "nl-BE": { - nativeName: "Nederlands (België)", - }, - "nl-NL": { - nativeName: "Nederlands (Nederland)", - }, - "nn-NO": { + nn: { nativeName: "Norsk (nynorsk)", }, oc: { nativeName: "Occitan", }, - "or-IN": { + or: { nativeName: "ଓଡ଼ିଆ", }, pa: { nativeName: "ਪੰਜਾਬੀ", }, - "pa-IN": { - nativeName: "ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)", - }, pl: { nativeName: "Polski", }, - "pl-PL": { - nativeName: "Polski", - }, - "ps-AF": { + ps: { nativeName: "پښتو", + rtl: true, }, pt: { nativeName: "Português", }, - "pt-BR": { - nativeName: "Português (Brasil)", - }, - "pt-PT": { - nativeName: "Português (Portugal)", - }, - "qu-PE": { + qu: { nativeName: "Qhichwa", }, - "rm-CH": { + rm: { nativeName: "Rumantsch", }, ro: { nativeName: "Română", }, - "ro-RO": { - nativeName: "Română", - }, ru: { nativeName: "Русский", }, - "ru-RU": { - nativeName: "Русский", - }, - "sa-IN": { + sa: { nativeName: "संस्कृतम्", }, - "se-NO": { + se: { nativeName: "Davvisámegiella", }, sh: { nativeName: "српскохрватски", }, - "si-LK": { + si: { nativeName: "සිංහල", }, sk: { nativeName: "Slovenčina", }, - "sk-SK": { - nativeName: "Slovenčina (Slovakia)", - }, sl: { nativeName: "Slovenščina", }, - "sl-SI": { - nativeName: "Slovenščina", - }, - "so-SO": { + so: { nativeName: "Soomaaliga", }, sq: { nativeName: "Shqip", }, - "sq-AL": { - nativeName: "Shqip", - }, sr: { nativeName: "Српски", }, - "sr-RS": { - nativeName: "Српски (Serbia)", - }, su: { nativeName: "Basa Sunda", }, sv: { nativeName: "Svenska", }, - "sv-SE": { - nativeName: "Svenska", - }, sw: { nativeName: "Kiswahili", }, - "sw-KE": { - nativeName: "Kiswahili", - }, ta: { nativeName: "தமிழ்", }, - "ta-IN": { - nativeName: "தமிழ்", - }, te: { nativeName: "తెలుగు", }, - "te-IN": { - nativeName: "తెలుగు", - }, tg: { nativeName: "забо́ни тоҷикӣ́", }, - "tg-TJ": { - nativeName: "тоҷикӣ", - }, th: { nativeName: "ภาษาไทย", }, - "th-TH": { - nativeName: "ภาษาไทย (ประเทศไทย)", - }, - fil: { - nativeName: "Filipino", - }, - tlh: { - nativeName: "tlhIngan-Hol", - }, tr: { nativeName: "Türkçe", }, - "tr-TR": { - nativeName: "Türkçe", - }, - "tt-RU": { + tt: { nativeName: "татарча", }, uk: { nativeName: "Українська", }, - "uk-UA": { - nativeName: "Українська", - }, ur: { nativeName: "اردو", - }, - "ur-PK": { - nativeName: "اردو", + rtl: true, }, uz: { nativeName: "O'zbek", }, - "uz-UZ": { - nativeName: "O'zbek", - }, vi: { nativeName: "Tiếng Việt", }, - "vi-VN": { - nativeName: "Tiếng Việt", - }, - "xh-ZA": { + xh: { nativeName: "isiXhosa", }, yi: { nativeName: "ייִדיש", - }, - "yi-DE": { - nativeName: "ייִדיש (German)", + rtl: true, }, zh: { nativeName: "中文", }, - "zh-Hans": { - nativeName: "中文简体", - }, - "zh-Hant": { - nativeName: "中文繁體", - }, - "zh-CN": { - nativeName: "中文(中国大陆)", - }, - "zh-HK": { - nativeName: "中文(香港)", - }, - "zh-SG": { - nativeName: "中文(新加坡)", - }, - "zh-TW": { - nativeName: "中文(台灣)", - }, - "zu-ZA": { + zu: { nativeName: "isiZulu", }, }; + +export const iso639Langs3 = { + ach: { + nativeName: "Lwo", + }, + ady: { + nativeName: "Адыгэбзэ", + }, + cak: { + nativeName: "Maya Kaqchikel", + }, + chr: { + nativeName: "ᏣᎳᎩ (tsalagi)", + }, + dsb: { + nativeName: "Dolnoserbšćina", + }, + fil: { + nativeName: "Filipino", + }, + hsb: { + nativeName: "Hornjoserbšćina", + }, + kab: { + nativeName: "Taqbaylit", + }, + mai: { + nativeName: "मैथिली, মৈথিলী", + }, + tlh: { + nativeName: "tlhIngan-Hol", + }, + tok: { + nativeName: "Toki Pona", + }, + yue: { + nativeName: "粵語", + }, + nan: { + nativeName: "閩南語", + }, +}; + +export const langmapNoRegion = Object.assign({}, iso639Langs1, iso639Langs3); + +export const iso639Regional = { + "zh-hans": { + nativeName: "中文(简体)", + }, + "zh-hant": { + nativeName: "中文(繁體)", + }, +}; + +export const langmap = Object.assign({}, langmapNoRegion, iso639Regional); diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts index 8062cac51..eff477500 100644 --- a/packages/backend/src/models/entities/note.ts +++ b/packages/backend/src/models/entities/note.ts @@ -64,12 +64,18 @@ export class Note { }) public threadId: string | null; - @Index('note_text_fts_idx', { synchronize: false }) + @Index("note_text_fts_idx", { synchronize: false }) @Column("text", { nullable: true, }) public text: string | null; + @Column("varchar", { + length: 10, + nullable: true, + }) + public lang: string | null; + @Column("varchar", { length: 256, nullable: true, @@ -123,7 +129,7 @@ export class Note { * specified ... visibleUserIds で指定したユーザーのみ */ @Column("enum", { enum: noteVisibilities }) - public visibility: typeof noteVisibilities[number]; + public visibility: (typeof noteVisibilities)[number]; @Index({ unique: true }) @Column("varchar", { diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts index 7eda8d858..3b03c236b 100644 --- a/packages/backend/src/models/repositories/note.ts +++ b/packages/backend/src/models/repositories/note.ts @@ -228,6 +228,7 @@ export const NoteRepository = db.getRepository(Note).extend({ detail: false, }), text: text, + lang: note.lang, cw: note.cw, visibility: note.visibility, localOnly: note.localOnly || undefined, @@ -262,7 +263,6 @@ export const NoteRepository = db.getRepository(Note).extend({ isFiltered: isFiltered(note, me), } : {}), - ...(opts.detail ? { reply: note.replyId diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts index be41da2ce..e594972ec 100644 --- a/packages/backend/src/models/schema/note.ts +++ b/packages/backend/src/models/schema/note.ts @@ -1,3 +1,5 @@ +import { langmap } from "@/misc/langmap.js"; + export const packedNoteSchema = { type: "object", properties: { @@ -19,6 +21,11 @@ export const packedNoteSchema = { optional: false, nullable: true, }, + lang: { + type: "string", + enum: [...Object.keys(langmap)], + nullable: true, + }, cw: { type: "string", optional: true, diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts index ad88170fb..2c8752f55 100644 --- a/packages/backend/src/remote/activitypub/models/note.ts +++ b/packages/backend/src/remote/activitypub/models/note.ts @@ -46,7 +46,10 @@ import { extractApMentions } from "./mention.js"; import DbResolver from "../db-resolver.js"; import { StatusError } from "@/misc/fetch.js"; import { shouldBlockInstance } from "@/misc/should-block-instance.js"; -import { publishNoteStream, publishNoteUpdatesStream } from "@/services/stream.js"; +import { + publishNoteStream, + publishNoteUpdatesStream, +} from "@/services/stream.js"; import { extractHashtags } from "@/misc/extract-hashtags.js"; import { UserProfiles } from "@/models/index.js"; import { In } from "typeorm"; @@ -55,6 +58,7 @@ import { truncate } from "@/misc/truncate.js"; import { type Size, getEmojiSize } from "@/misc/emoji-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js"; import { RecursionLimiter } from "@/models/repositories/user-profile.js"; +import { langmap } from "@/misc/langmap.js"; const logger = apLogger; @@ -110,7 +114,7 @@ export async function createNote( value: string | IObject, resolver?: Resolver, silent = false, - limiter: RecursionLimiter = new RecursionLimiter() + limiter: RecursionLimiter = new RecursionLimiter(), ): Promise { if (resolver == null) resolver = new Resolver(); @@ -166,7 +170,7 @@ export async function createNote( const actor = (await resolvePerson( getOneApId(note.attributedTo), resolver, - limiter + limiter, )) as CacheableRemoteUser; // Skip if author is suspended. @@ -177,7 +181,13 @@ export async function createNote( return null; } - const noteAudience = await parseAudience(actor, note.to, note.cc, undefined, limiter); + const noteAudience = await parseAudience( + actor, + note.to, + note.cc, + undefined, + limiter, + ); let visibility = noteAudience.visibility; const visibleUsers = noteAudience.visibleUsers; @@ -204,8 +214,8 @@ export async function createNote( note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment - ? [note.attachment] - : []; + ? [note.attachment] + : []; const files = note.attachment.map( (attach) => (attach.sensitive = note.sensitive), ) @@ -309,11 +319,25 @@ export async function createNote( // Text parsing let text: string | null = null; + let lang: string | null = null; if ( note.source?.mediaType === "text/x.misskeymarkdown" && typeof note.source?.content === "string" ) { text = note.source.content; + if (note.contentMap) { + const key = Object.keys(note.contentMap).shift()?.toLowerCase(); + if (key) { + lang = Object.keys(langmap).includes(key) ? key : null; + } + } + } else if (note.contentMap) { + const entry = Object.entries(note.contentMap).shift(); + if (entry) { + const key = entry[0].toLowerCase(); + lang = Object.keys(langmap).includes(key) ? key : null; + text = await htmlToMfm(entry[1], note.tag); + } } else if (typeof note._misskey_content !== "undefined") { text = note._misskey_content; } else if (typeof note.content === "string") { @@ -384,6 +408,7 @@ export async function createNote( name: note.name, cw, text, + lang, localOnly: false, visibility, visibleUsers, @@ -395,7 +420,7 @@ export async function createNote( url: url, }, silent, - limiter + limiter, ); } @@ -408,7 +433,7 @@ export async function createNote( export async function resolveNote( value: string | IObject, resolver?: Resolver, - limiter: RecursionLimiter = new RecursionLimiter() + limiter: RecursionLimiter = new RecursionLimiter(), ): Promise { const uri = typeof value === "string" ? value : value.id; if (uri == null) throw new Error("missing uri"); @@ -573,11 +598,25 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) { // Text parsing let text: string | null = null; + let lang: string | null = null; if ( post.source?.mediaType === "text/x.misskeymarkdown" && typeof post.source?.content === "string" ) { text = post.source.content; + if (post.contentMap) { + const key = Object.keys(post.contentMap).shift()?.toLowerCase(); + if (key) { + lang = Object.keys(langmap).includes(key) ? key : null; + } + } + } else if (post.contentMap) { + const entry = Object.entries(post.contentMap).shift(); + if (entry) { + const key = entry[0].toLowerCase(); + lang = Object.keys(langmap).includes(key) ? key : null; + text = await htmlToMfm(entry[1], post.tag); + } } else if (typeof post._misskey_content !== "undefined") { text = post._misskey_content; } else if (typeof post.content === "string") { @@ -672,6 +711,9 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) { if (text && text !== note.text) { update.text = text; } + if (lang && lang !== note.lang) { + update.lang = lang + } if (cw !== note.cw) { update.cw = cw ? cw : null; } @@ -712,7 +754,8 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) { }); updating = true; } else { - const choicesChanged = JSON.stringify(dbPoll.choices) !== JSON.stringify(poll.choices); + const choicesChanged = + JSON.stringify(dbPoll.choices) !== JSON.stringify(poll.choices); if ( dbPoll.multiple !== poll.multiple || @@ -778,7 +821,7 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) { const updatedNote = { ...note, - ...update + ...update, }; publishNoteUpdatesStream("updated", updatedNote); diff --git a/packages/backend/src/remote/activitypub/renderer/note.ts b/packages/backend/src/remote/activitypub/renderer/note.ts index f6ea3c892..efa1380c1 100644 --- a/packages/backend/src/remote/activitypub/renderer/note.ts +++ b/packages/backend/src/remote/activitypub/renderer/note.ts @@ -10,6 +10,7 @@ import renderEmoji from "./emoji.js"; import renderMention from "./mention.js"; import renderHashtag from "./hashtag.js"; import renderDocument from "./document.js"; +import type { IPost } from "@/remote/activitypub/type.js"; export default async function renderNote( note: Note, @@ -114,6 +115,13 @@ export default async function renderNote( }), ); + let contentMap: IPost["contentMap"] = undefined; + if (note.lang) { + contentMap = { + [note.lang]: content ?? "" + } + } + const emojis = await getEmojis(note.emojis); const apemojis = emojis.map((emoji) => renderEmoji(emoji)); @@ -152,6 +160,7 @@ export default async function renderNote( attributedTo, summary, content, + contentMap, _misskey_content: text, source: { content: text, diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts index 5b9610098..654461d9c 100644 --- a/packages/backend/src/remote/activitypub/resolver.ts +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -72,7 +72,7 @@ export default class Resolver { if (typeof value.id !== "undefined") { const host = extractDbHost(getApId(value)); if (await shouldBlockInstance(host)) { - throw new Error("instance is blocked"); + throw new Error(`instance ${host} is blocked`); } } apLogger.debug("Returning existing object:"); @@ -104,7 +104,7 @@ export default class Resolver { const meta = await fetchMeta(); if (await shouldBlockInstance(host, meta)) { - throw new Error("Instance is blocked"); + throw new Error(`Instance ${host} is blocked`); } if ( @@ -113,7 +113,7 @@ export default class Resolver { config.domain !== host && !meta.allowedHosts.includes(host) ) { - throw new Error("Instance is not allowed"); + throw new Error(`Instance ${host} is not allowed`); } if (!this.user) { diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts index d7ba1bdc1..1b9762e08 100644 --- a/packages/backend/src/remote/activitypub/type.ts +++ b/packages/backend/src/remote/activitypub/type.ts @@ -134,6 +134,7 @@ export interface IPost extends IObject { content: string; mediaType: string; }; + contentMap?: Record, _misskey_quote?: string; quoteUrl?: string; quoteUri?: string; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 31af07cfb..20ccfd5e8 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -91,7 +91,7 @@ export const paramDef = { birthday: { ...Users.birthdaySchema, nullable: true }, lang: { type: "string", - enum: [null, ...Object.keys(langmap)], + enum: Object.keys(langmap), nullable: true, }, avatarId: { type: "string", format: "misskey:id", nullable: true }, @@ -159,7 +159,7 @@ export default define(meta, paramDef, async (ps, _user, token) => { if (ps.name !== undefined) updates.name = ps.name; if (ps.description !== undefined) profileUpdates.description = ps.description; - if (ps.lang !== undefined) profileUpdates.lang = ps.lang; + if (typeof ps.lang === "string") profileUpdates.lang = ps.lang; if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.ffVisibility !== undefined) diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index e15221046..eb52d805a 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -17,6 +17,7 @@ import { ApiError } from "../../error.js"; import define from "../../define.js"; import { HOUR } from "@/const.js"; import { getNote } from "../../common/getters.js"; +import { langmap } from "@/misc/langmap.js"; export const meta = { tags: ["notes"], @@ -108,7 +109,12 @@ export const paramDef = { }, }, text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, - cw: { type: "string", nullable: true, maxLength: 100 }, + lang: { + type: "string", + enum: Object.keys(langmap), + nullable: true, + }, + cw: { type: "string", nullable: true, maxLength: 256 }, localOnly: { type: "boolean", default: false }, noExtractMentions: { type: "boolean", default: false }, noExtractHashtags: { type: "boolean", default: false }, @@ -294,6 +300,7 @@ export default define(meta, paramDef, async (ps, user) => { } : undefined, text: ps.text || undefined, + lang: ps.lang, reply, renote, cw: ps.cw, diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index f5e07c830..7d9be95ec 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -6,8 +6,9 @@ import { ApiError } from "../../error.js"; import define from "../../define.js"; import { HOUR } from "@/const.js"; // import { deliverQuestionUpdate } from "@/services/note/polls/update.js"; -import editNote from "@/services/note/edit.js" +import editNote from "@/services/note/edit.js"; import { Packed } from "@/misc/schema.js"; +import { langmap } from "@/misc/langmap.js"; export const meta = { tags: ["notes"], @@ -25,7 +26,7 @@ export const meta = { type: "object", optional: false, nullable: false, - ref: "Note" + ref: "Note", }, errors: { @@ -64,7 +65,7 @@ export const meta = { code: "NOT_LOCAL_USER", id: "b907f407-2aa0-4283-800b-a2c56290b822", }, - } + }, } as const; export const paramDef = { @@ -72,6 +73,11 @@ export const paramDef = { properties: { editId: { type: "string", format: "misskey:id" }, text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, + lang: { + type: "string", + enum: Object.keys(langmap), + nullable: true, + }, cw: { type: "string", nullable: true, maxLength: 250 }, fileIds: { type: "array", @@ -125,58 +131,66 @@ export const paramDef = { ], } as const; -export default define(meta, paramDef, async (ps, user): Promise> => { - if (user.movedToUri != null) throw new ApiError(meta.errors.accountLocked); +export default define( + meta, + paramDef, + async (ps, user): Promise> => { + if (user.movedToUri != null) throw new ApiError(meta.errors.accountLocked); - if (!Users.isLocalUser(user)) { - throw new ApiError(meta.errors.notLocalUser); - } + if (!Users.isLocalUser(user)) { + throw new ApiError(meta.errors.notLocalUser); + } - if (!ps.editId) { - throw new ApiError(meta.errors.needsEditId); - } + if (!ps.editId) { + throw new ApiError(meta.errors.needsEditId); + } - let note = await Notes.findOneBy({ - id: ps.editId, - }); + let note = await Notes.findOneBy({ + id: ps.editId, + }); - if (!note) { - throw new ApiError(meta.errors.noSuchNote); - } + if (!note) { + throw new ApiError(meta.errors.noSuchNote); + } - if (note.userId !== user.id) { - throw new ApiError(meta.errors.youAreNotTheAuthor); - } + if (note.userId !== user.id) { + throw new ApiError(meta.errors.youAreNotTheAuthor); + } - if (ps.poll?.expiresAt && new Date(ps.poll.expiresAt).getTime() < new Date().getTime()) { - throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); - } + if ( + ps.poll?.expiresAt && + new Date(ps.poll.expiresAt).getTime() < new Date().getTime() + ) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } - let files: DriveFile[] = []; - const fileIds = ps.fileIds ?? null; - if (fileIds != null) { - files = await DriveFiles.createQueryBuilder("file") - .where("file.userId = :userId AND file.id IN (:...fileIds)", { - userId: user.id, - fileIds, - }) - .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') - .setParameters({ fileIds }) - .getMany(); - } + let files: DriveFile[] = []; + const fileIds = ps.fileIds ?? null; + if (fileIds != null) { + files = await DriveFiles.createQueryBuilder("file") + .where("file.userId = :userId AND file.id IN (:...fileIds)", { + userId: user.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + } - note = await editNote(user, note, { - text: ps.text, - cw: ps.cw, - poll: ps.poll - ? { - choices: ps.poll.choices, - multiple: ps.poll.multiple, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, - } - : undefined, - files: files - }); + note = await editNote(user, note, { + text: ps.text, + lang: ps.lang, + cw: ps.cw, + poll: ps.poll + ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } + : undefined, + files: files, + }); - return Notes.pack(note, user); -}); + return Notes.pack(note, user); + }, +); diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 87dc63a48..959d5d841 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -62,6 +62,7 @@ import { redisClient } from "@/db/redis.js"; import { Mutex } from "redis-semaphore"; import { RecursionLimiter } from "@/models/repositories/user-profile.js"; import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { langmap } from "@/misc/langmap.js"; type NotificationType = "reply" | "renote" | "quote" | "mention"; @@ -130,6 +131,7 @@ type Option = { createdAt?: Date | null; name?: string | null; text?: string | null; + lang?: string | null; reply?: Note | null; renote?: Note | null; files?: DriveFile[] | null; @@ -160,7 +162,7 @@ export default async ( silent = false, limiter: RecursionLimiter = new RecursionLimiter() ) => - // rome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME + // biome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME new Promise(async (res, rej) => { const dontFederateInitially = data.visibility === "hidden"; @@ -265,6 +267,15 @@ export default async ( data.text = null; } + if (data.lang) { + if (!Object.keys(langmap).includes(data.lang.toLowerCase())) { + throw new Error("invalid lang param"); + } + data.lang = data.lang.toLowerCase(); + } else { + data.lang = null; + } + let tags = data.apHashtags; let emojis = data.apEmojis; let mentionedUsers = data.apMentions; @@ -670,6 +681,7 @@ async function insertNote( : null, name: data.name, text: data.text, + lang: data.lang, hasPoll: data.poll != null, cw: data.cw == null ? null : data.cw, tags: tags.map((tag) => normalizeForSearch(tag)), diff --git a/packages/backend/src/services/note/edit.ts b/packages/backend/src/services/note/edit.ts index c100148cb..d11cf8a92 100644 --- a/packages/backend/src/services/note/edit.ts +++ b/packages/backend/src/services/note/edit.ts @@ -27,9 +27,11 @@ import renderUpdate from "@/remote/activitypub/renderer/update.js"; import { extractMentionedUsers } from "@/services/note/create.js"; import { normalizeForSearch } from "@/misc/normalize-for-search.js"; import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { langmap } from "@/misc/langmap.js"; type Option = { text?: string | null; + lang?: string | null; files?: DriveFile[] | null; poll?: IPoll | null; cw?: string | null; @@ -81,10 +83,18 @@ export default async function ( }); let publishing = false; - const update = {} as Partial; + const update: Partial = {}; if (data.text !== null && data.text !== note.text) { update.text = data.text; } + if (data.lang) { + const langLowerCase = data.lang.toLowerCase(); + if (!Object.keys(langmap).includes(langLowerCase)) + throw new Error("invalid param"); + update.lang = langLowerCase; + } else { + update.lang = null; + } if (data.cw !== note.cw) { update.cw = data.cw ?? null; } @@ -239,4 +249,3 @@ export default async function ( function notEmpty(partial: Partial) { return Object.keys(partial).length > 0; } - diff --git a/packages/client/src/components/MkNote.vue b/packages/client/src/components/MkNote.vue index f85db3b9c..e34a3670b 100644 --- a/packages/client/src/components/MkNote.vue +++ b/packages/client/src/components/MkNote.vue @@ -221,6 +221,18 @@ + +