Merge tag '2024.3.1-io.4' into host

This commit is contained in:
まっちゃとーにゅ 2024-03-29 19:16:44 +09:00
commit 1d7f763ed9
No known key found for this signature in database
GPG key ID: 6AFBBF529601C1DB
34 changed files with 2806 additions and 1929 deletions

View file

@ -18,6 +18,7 @@
- 実装の都合により、プラグインは1つエラーを起こした時に即時停止するようになりました
- Enhance: ページのデザインを変更
- Enhance: 2要素認証ワンタイムパスワードの入力欄を改善
- Enhance: 「今日誕生日のフォロー中ユーザー」ウィジェットを手動でリロードできるように
- Fix: 一部のページ内リンクが正しく動作しない問題を修正
- Fix: 周年の実績が閏年を考慮しない問題を修正
- Fix: ローカルURLのプレビューポップアップが左上に表示される
@ -27,6 +28,7 @@
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/528)
- Fix: コードブロックのシンタックスハイライトで使用される定義ファイルをCDNから取得するように #13177
- CDNから取得せずMisskey本体にバンドルする場合は`pacakges/frontend/vite.config.ts`を修正してください。
- Fix: タイムゾーンによっては、「今日誕生日のフォロー中ユーザー」ウィジェットが正しく動作しない問題を修正
### Server
- Enhance: エンドポイント`antennas/update`の必須項目を`antennaId`のみに

View file

@ -2153,6 +2153,7 @@ _widgets:
chooseList: "Select a list"
clicker: "Clicker"
birthdayFollowings: "Users who celebrate their birthday today"
birthdaySoon: "Users who will celebrate their birthday soon"
_cw:
hide: "Hide"
show: "Show content"
@ -2574,4 +2575,18 @@ _reversi:
_offlineScreen:
title: "Offline - cannot connect to the server"
header: "Unable to connect to the server"
_skebStatus:
_genres:
art: "Artwork"
comic: "Comic"
voice: "Voice"
novel: "Text"
video: "Movie"
music: "Music"
correction: "Advice"
seeking: "Seeking"
stopped: "Stopped"
client: "Client"
yenX: "JPY {x}"
nWorks: "Delivered {n} works"
nRequests: "Requested {n} times"

62
locales/index.d.ts vendored
View file

@ -4865,7 +4865,7 @@ export interface Locale extends ILocale {
*/
"wellKnownWebsites": string;
/**
* AND指定になりOR指定になります
* AND指定になりOR指定になります
*/
"wellKnownWebsitesDescription": string;
/**
@ -8396,6 +8396,10 @@ export interface Locale extends ILocale {
*
*/
"birthdayFollowings": string;
/**
*
*/
"birthdaySoon": string;
};
"_cw": {
/**
@ -10109,6 +10113,62 @@ export interface Locale extends ILocale {
*/
"summaryProxyDescription2": string;
};
"_skebStatus": {
"_genres": {
/**
*
*/
"art": string;
/**
*
*/
"comic": string;
/**
*
*/
"voice": string;
/**
*
*/
"novel": string;
/**
*
*/
"video": string;
/**
*
*/
"music": string;
/**
*
*/
"correction": string;
};
/**
*
*/
"seeking": string;
/**
*
*/
"stopped": string;
/**
*
*/
"client": string;
/**
* {x}
*/
"yenX": ParameterizedString<"x">;
/**
* {n}
*/
"nWorks": ParameterizedString<"n">;
/**
* {n}
*/
"nRequests": ParameterizedString<"n">;
};
}
declare const locales: {
[lang: string]: Locale;

View file

@ -2207,6 +2207,7 @@ _widgets:
chooseList: "リストを選択"
clicker: "クリッカー"
birthdayFollowings: "今日誕生日のユーザー"
birthdaySoon: "もうすぐ誕生日のユーザー"
_cw:
hide: "隠す"
@ -2692,3 +2693,19 @@ _urlPreviewSetting:
summaryProxy: "プレビューを生成するプロキシのエンドポイント"
summaryProxyDescription: "Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。"
summaryProxyDescription2: "プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。"
_skebStatus:
_genres:
art: "イラスト"
comic: "コミック"
voice: "ボイス"
novel: "テキスト"
video: "ムービー"
music: "ミュージック"
correction: "アドバイス"
seeking: "募集中"
stopped: "停止中"
client: "クライアント"
yenX: "{x}円"
nWorks: "納品実績 {n}件"
nRequests: "取引実績 {n}件"

View file

@ -2131,6 +2131,7 @@ _widgets:
chooseList: "리스트 선택"
clicker: "클리커"
birthdayFollowings: "오늘이 생일인 사용자"
birthdaySoon: "곧 생일인 사용자"
_cw:
hide: "숨기기"
show: "더 보기"
@ -2548,4 +2549,18 @@ _reversi:
_offlineScreen:
title: "오프라인 - 서버에 접속할 수 없습니다"
header: "서버에 접속할 수 없습니다"
_skebStatus:
_genres:
art: "작품"
comic: "만화"
voice: "음성"
novel: "텍스트"
video: "동영상"
music: "음악"
correction: "조언"
seeking: "모집 중"
stopped: "정지 중"
client: "클라이언트"
yenX: "JPY {x}"
nWorks: "납품 실적 {n}건"
nRequests: "거래 실적 {n}건"

View file

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2024.3.1-host.3",
"version": "2024.3.1-host.4",
"codename": "nasubi",
"repository": {
"type": "git",

View file

@ -0,0 +1,15 @@
export class BirthdayIndex1711478468155 {
name = 'BirthdayIndex1711478468155'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
await queryRunner.query(`CREATE OR REPLACE FUNCTION get_birthday_date(birthday TEXT) RETURNS SMALLINT AS $$ BEGIN RETURN CAST((SUBSTR(birthday, 6, 2) || SUBSTR(birthday, 9, 2)) AS SMALLINT); END; $$ LANGUAGE plpgsql IMMUTABLE;`);
await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`);
}
async down(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`);
await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`);
await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`);
}
}

View file

@ -81,6 +81,14 @@ type Source = {
}
};
skebStatus?: {
method: string;
endpoint: string;
headers: { [x: string]: string };
parameters: { [x: string]: string };
userIdParameterName: string;
}
proxy?: string;
proxySmtp?: string;
proxyBypassHosts?: string[];
@ -115,6 +123,7 @@ type Source = {
bypassRateLimit?: { header: string; value: string }[];
remapDriveFileUrlForActivityPub?: { target: string; replacement: string }[];
signToActivityPubGet?: boolean;
perChannelMaxNoteCacheCount?: number;
@ -169,6 +178,13 @@ export type Config = {
useProxy?: boolean;
}
} | undefined;
skebStatus: {
method: string;
endpoint: string;
headers: { [x: string]: string };
parameters: { [x: string]: string };
userIdParameterName: string;
} | undefined;
proxy: string | undefined;
proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined;
@ -190,6 +206,7 @@ export type Config = {
deliverJobMaxAttempts: number | undefined;
inboxJobMaxAttempts: number | undefined;
proxyRemoteFiles: boolean | undefined;
remapDriveFileUrlForActivityPub: { target: string; replacement: string }[] | undefined;
signToActivityPubGet: boolean | undefined;
version: string;
@ -295,6 +312,7 @@ export function loadConfig(): Config {
redisForObjectStorageQueue: config.redisForObjectStorageQueue ? convertRedisOptions(config.redisForObjectStorageQueue, host) : redisForJobQueue,
redisForWebhookDeliverQueue: config.redisForWebhookDeliverQueue ? convertRedisOptions(config.redisForWebhookDeliverQueue, host) : redisForJobQueue,
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
skebStatus: config.skebStatus,
id: config.id,
proxy: config.proxy,
proxySmtp: config.proxySmtp,
@ -316,6 +334,7 @@ export function loadConfig(): Config {
deliverJobMaxAttempts: config.deliverJobMaxAttempts,
inboxJobMaxAttempts: config.inboxJobMaxAttempts,
proxyRemoteFiles: config.proxyRemoteFiles,
remapDriveFileUrlForActivityPub: config.remapDriveFileUrlForActivityPub,
signToActivityPubGet: config.signToActivityPubGet,
mediaProxy: externalMediaProxy ?? internalMediaProxy,
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,

View file

@ -56,8 +56,10 @@ export class HashtagService {
public async updateHashtag(user: { id: MiUser['id']; host: MiUser['host']; }, tag: string, isUserAttached = false, inc = true) {
tag = normalizeForSearch(tag);
if (this.userEntityService.isLocalUser(user)) {
// TODO: サンプリング
this.updateHashtagsRanking(tag, user.id);
}
const index = await this.hashtagsRepository.findOneBy({ name: tag });

View file

@ -164,13 +164,20 @@ export class ApRendererService {
return {
type: 'Document',
mediaType: file.webpublicType ?? file.type,
url: this.driveFileEntityService.getPublicUrl(file),
url: this.driveFileEntityService.getPublicUrl(file, { remapActivityPub: true }),
name: file.comment,
};
}
@bindThis
public renderEmoji(emoji: MiEmoji): IApEmoji {
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
let url = emoji.publicUrl || emoji.originalUrl;
this.config.remapDriveFileUrlForActivityPub?.forEach(({ target, replacement }) => {
url = url.replace(target, replacement);
});
return {
id: `${this.config.url}/emojis/${emoji.name}`,
type: 'Emoji',
@ -179,8 +186,7 @@ export class ApRendererService {
icon: {
type: 'Image',
mediaType: emoji.type ?? 'image/png',
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl,
url: url,
},
};
}
@ -243,7 +249,7 @@ export class ApRendererService {
public renderImage(file: MiDriveFile): IApImage {
return {
type: 'Image',
url: this.driveFileEntityService.getPublicUrl(file),
url: this.driveFileEntityService.getPublicUrl(file, { remapActivityPub: true }),
sensitive: file.isSensitive,
name: file.comment,
};

View file

@ -267,12 +267,12 @@ export class ApPersonService implements OnModuleInit {
return {
...( avatar ? {
avatarId: avatar.id,
avatarUrl: avatar.url ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null,
avatarUrl: avatar.url ? this.driveFileEntityService.getPublicUrl(avatar, { mode: 'avatar', remapActivityPub: true }) : null,
avatarBlurhash: avatar.blurhash,
} : {}),
...( banner ? {
bannerId: banner.id,
bannerUrl: banner.url ? this.driveFileEntityService.getPublicUrl(banner) : null,
bannerUrl: banner.url ? this.driveFileEntityService.getPublicUrl(banner, { remapActivityPub: true }) : null,
bannerBlurhash: banner.blurhash,
} : {}),
};

View file

@ -109,10 +109,10 @@ export class DriveFileEntityService {
}
@bindThis
public getPublicUrl(file: MiDriveFile, mode?: 'avatar'): string { // static = thumbnail
public getPublicUrl(file: MiDriveFile, option?: { mode?: 'avatar', remapActivityPub?: boolean }): string { // static = thumbnail
// リモートかつメディアプロキシ
if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
return this.getProxiedUrl(file.uri, mode);
return this.getProxiedUrl(file.uri, option?.mode);
}
// リモートかつ期限切れはローカルプロキシを試みる
@ -121,17 +121,23 @@ export class DriveFileEntityService {
if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外
const url = `${this.config.url}/files/${key}`;
if (mode === 'avatar') return this.getProxiedUrl(file.uri, 'avatar');
if (option?.mode === 'avatar') return this.getProxiedUrl(file.uri, 'avatar');
return url;
}
}
const url = file.webpublicUrl ?? file.url;
if (mode === 'avatar') {
return this.getProxiedUrl(url, 'avatar');
let publicUrl = file.webpublicUrl ?? file.url;
if (option?.remapActivityPub) {
this.config.remapDriveFileUrlForActivityPub?.forEach(({ target, replacement }) => {
publicUrl = publicUrl.replace(target, replacement);
});
}
return url;
const url = new URL(publicUrl);
if (file.isSensitive) url.searchParams.set('sensitive', 'true');
if (option?.mode === 'avatar') return this.getProxiedUrl(url.href, 'avatar');
else return url.href;
}
@bindThis

View file

@ -112,6 +112,7 @@ export class MetaEntityService {
mediaProxy: this.config.mediaProxy,
enableUrlPreview: instance.urlPreviewEnabled,
enableSkebStatus: !!this.config.skebStatus,
};
}

View file

@ -642,8 +642,8 @@ export class UserEntityService implements OnModuleInit {
// -- 特に前提条件のない値群を取得
const profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) })
.then(profiles => new Map(profiles.map(p => [p.userId, p])));
const profilesMap = (options?.schema !== 'UserLite') ? await this.userProfilesRepository.findBy({ userId: In(_userIds) })
.then(profiles => new Map(profiles.map(p => [p.userId, p]))) : undefined;
// -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得
@ -680,7 +680,7 @@ export class UserEntityService implements OnModuleInit {
}
}
return (await Promise.allSettled(_users.map(u => this.pack(u, me, { ...options, userProfile: profilesMap.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes }))))
return (await Promise.allSettled(_users.map(u => this.pack(u, me, { ...options, userProfile: profilesMap?.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes }))))
.filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<Packed<S>>).value);
}

View file

@ -216,6 +216,10 @@ export const packedMetaLiteSchema = {
type: 'boolean',
optional: false, nullable: false,
},
enableSkebStatus: {
type: 'boolean',
optional: false, nullable: false,
},
backgroundImageUrl: {
type: 'string',
optional: false, nullable: true,

View file

@ -348,7 +348,9 @@ import * as ep___users_clips from './endpoints/users/clips.js';
import * as ep___users_followers from './endpoints/users/followers.js';
import * as ep___users_following from './endpoints/users/following.js';
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
import * as ep___users_getFollowingBirthdayUsers from './endpoints/users/get-following-birthday-users.js';
import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
import * as ep___users_getSkebStatus from './endpoints/users/get-skeb-status.js';
import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
import * as ep___users_lists_create from './endpoints/users/lists/create.js';
import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
@ -733,7 +735,9 @@ const $users_clips: Provider = { provide: 'ep:users/clips', useClass: ep___users
const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep___users_followers.default };
const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default };
const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default };
const $users_getFollowingBirthdayUsers: Provider = { provide: 'ep:users/get-following-birthday-users', useClass: ep___users_getFollowingBirthdayUsers.default };
const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default };
const $users_getSkebStatus: Provider = { provide: 'ep:users/get-skeb-status', useClass: ep___users_getSkebStatus.default };
const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default };
const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default };
const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default };
@ -1122,7 +1126,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$users_followers,
$users_following,
$users_gallery_posts,
$users_getFollowingBirthdayUsers,
$users_getFrequentlyRepliedUsers,
$users_getSkebStatus,
$users_featuredNotes,
$users_lists_create,
$users_lists_delete,
@ -1503,7 +1509,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$users_followers,
$users_following,
$users_gallery_posts,
$users_getFollowingBirthdayUsers,
$users_getFrequentlyRepliedUsers,
$users_getSkebStatus,
$users_featuredNotes,
$users_lists_create,
$users_lists_delete,

View file

@ -348,7 +348,9 @@ import * as ep___users_clips from './endpoints/users/clips.js';
import * as ep___users_followers from './endpoints/users/followers.js';
import * as ep___users_following from './endpoints/users/following.js';
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
import * as ep___users_getFollowingBirthdayUsers from './endpoints/users/get-following-birthday-users.js';
import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
import * as ep___users_getSkebStatus from './endpoints/users/get-skeb-status.js';
import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
import * as ep___users_lists_create from './endpoints/users/lists/create.js';
import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
@ -731,7 +733,9 @@ const eps = [
['users/followers', ep___users_followers],
['users/following', ep___users_following],
['users/gallery/posts', ep___users_gallery_posts],
['users/get-following-birthday-users', ep___users_getFollowingBirthdayUsers],
['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers],
['users/get-skeb-status', ep___users_getSkebStatus],
['users/featured-notes', ep___users_featuredNotes],
['users/lists/create', ep___users_lists_create],
['users/lists/delete', ep___users_lists_delete],

View file

@ -314,7 +314,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage);
updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, { mode: 'avatar' });
updates.avatarBlurhash = avatar.blurhash;
} else if (ps.avatarId === null) {
updates.avatarId = null;

View file

@ -6,6 +6,7 @@
import { IsNull } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository, UserProfilesRepository } from '@/models/_.js';
import { birthdaySchema } from '@/models/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js';
@ -66,7 +67,10 @@ export const paramDef = {
description: 'The local host is represented with `null`.',
},
birthday: { type: 'string', nullable: true },
birthday: {
...birthdaySchema, nullable: true,
description: '@deprecated use get-following-birthday-users instead.',
},
},
anyOf: [
{ required: ['userId'] },
@ -125,16 +129,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere('following.followerId = :userId', { userId: user.id })
.innerJoinAndSelect('following.followee', 'followee');
// @deprecated use get-following-birthday-users instead.
if (ps.birthday) {
try {
const d = new Date(ps.birthday);
d.setHours(0, 0, 0, 0);
const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
birthdayUserQuery.select('user_profile.userId')
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
try {
const birthday = ps.birthday.split('-');
birthday.shift(); // 年の部分を削除
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) });
} catch (err) {
throw new ApiError(meta.errors.birthdayInvalid);
}

View file

@ -0,0 +1,130 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type {
FollowingsRepository,
UserProfilesRepository,
} from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { Packed } from '@/misc/json-schema.js';
export const meta = {
tags: ['users'],
requireCredential: true,
kind: 'read:account',
description: 'Find users who have a birthday on the specified range.',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
birthday: {
type: 'string', format: 'date-time',
optional: false, nullable: false,
},
user: {
type: 'object',
optional: false, nullable: false,
ref: 'UserLite',
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
birthday: {
type: 'object',
properties: {
month: { type: 'integer', minimum: 1, maximum: 12 },
day: { type: 'integer', minimum: 1, maximum: 31 },
begin: {
type: 'object',
properties: {
month: { type: 'integer', minimum: 1, maximum: 12 },
day: { type: 'integer', minimum: 1, maximum: 31 },
},
required: ['month', 'day'],
},
end: {
type: 'object',
properties: {
month: { type: 'integer', minimum: 1, maximum: 12 },
day: { type: 'integer', minimum: 1, maximum: 31 },
},
required: ['month', 'day'],
},
},
anyOf: [
{ required: ['month', 'day'] },
{ required: ['begin', 'end'] },
],
},
},
required: ['birthday'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.followingsRepository
.createQueryBuilder('following')
.andWhere('following.followerId = :userId', { userId: me.id })
.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) {
const { begin, end } = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; };
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin: begin.month * 100 + begin.day, end: end.month * 100 + end.day });
} else {
const { month, day } = ps.birthday as { month: number; day: number };
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day });
}
query.select('following.followeeId', 'user_id');
query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date');
query.orderBy('birthday_date', 'ASC');
const birthdayUsers = await query
.offset(ps.offset).limit(ps.limit)
.getRawMany<{ birthday_date: number; user_id: string }>();
const users = new Map<string, Packed<'UserLite'>>((
await this.userEntityService.packMany(
birthdayUsers.map(u => u.user_id),
me,
{ schema: 'UserLite' },
)
).map(u => [u.id, u]));
return birthdayUsers
.map(item => {
const birthday = new Date();
birthday.setMonth(Math.floor(item.birthday_date / 100) - 1);
birthday.setDate(item.birthday_date % 100);
birthday.setHours(0, 0, 0, 0);
if (birthday.getTime() < Date.now()) birthday.setFullYear(new Date().getFullYear() + 1);
return { birthday: birthday.toISOString(), user: users.get(item.user_id) };
})
.filter(item => item.user !== undefined)
.map(item => item as { birthday: string; user: Packed<'UserLite'> });
});
}
}

View file

@ -0,0 +1,148 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import type { Config } from '@/config.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['users'],
requireCredential: false,
allowGet: true,
cacheSec: 60 * 5,
res: {
type: 'object',
properties: {
screenName: {
type: 'string',
optional: false, nullable: false,
},
isCreator: {
type: 'boolean',
optional: false, nullable: false,
},
isAcceptable: {
type: 'boolean',
optional: false, nullable: false,
},
creatorRequestCount: {
type: 'integer',
optional: false, nullable: false,
},
clientRequestCount: {
type: 'integer',
optional: false, nullable: false,
},
skills: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
properties: {
amount: {
type: 'integer',
optional: false, nullable: false,
},
genre: {
type: 'string',
optional: false, nullable: false,
enum: ['art', 'comic', 'voice', 'novel', 'video', 'music', 'correction'],
},
},
},
},
},
},
errors: {
skebStatusNotAvailable: {
message: 'Skeb status is not available.',
code: 'SKEB_STATUS_NOT_AVAILABLE',
id: '1dd37c9c-7e97-4c24-be98-227a78b21d80',
httpStatusCode: 403,
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '88d582ae-69d9-45e0-a8b3-13f9945e48bf',
httpStatusCode: 404,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.config)
private config: Config,
private loggerService: LoggerService,
private httpRequestService: HttpRequestService,
) {
super(meta, paramDef, async (ps) => {
if (!this.config.skebStatus) throw new ApiError(meta.errors.skebStatusNotAvailable);
const logger = this.loggerService.getLogger('api:users:get-skeb-status');
const url = new URL(this.config.skebStatus.endpoint);
for (const [key, value] of Object.entries(this.config.skebStatus.parameters)) {
url.searchParams.set(key, value);
}
url.searchParams.set(this.config.skebStatus.userIdParameterName, ps.userId);
logger.info('Requesting Skeb status', { url: url.href, userId: ps.userId });
const res = await this.httpRequestService.send(
url.href,
{
method: this.config.skebStatus.method,
headers: {
...this.config.skebStatus.headers,
Accept: 'application/json',
},
timeout: 5000,
},
{
throwErrorWhenResponseNotOk: false,
},
);
const json = (await res.json()) as {
screen_name: string,
is_creator: boolean,
is_acceptable: boolean,
creator_request_count: number,
client_request_count: number,
skills: { amount: number, genre: 'art' | 'comic' | 'voice' | 'novel' | 'video' | 'music' | 'correction' }[],
ban_reason?: string | null
error?: unknown,
};
if (res.status > 399 || (json.error ?? json.ban_reason)) {
logger.error('Skeb status response error', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, error: json.error ?? json.ban_reason });
throw new ApiError(meta.errors.noSuchUser);
}
logger.info('Skeb status response', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, skebStatus: json });
return {
screenName: json.screen_name,
isCreator: json.is_creator,
isAcceptable: json.is_acceptable,
creatorRequestCount: json.creator_request_count,
clientRequestCount: json.client_request_count,
skills: json.skills,
};
});
}
}

View file

@ -27,10 +27,21 @@ export const meta = {
res: {
optional: false, nullable: false,
oneOf: [
{
type: 'object',
ref: 'UserLite',
},
{
type: 'object',
ref: 'UserDetailed',
},
{
type: 'array',
items: {
type: 'object',
ref: 'UserLite',
},
},
{
type: 'array',
items: {
@ -71,6 +82,7 @@ export const paramDef = {
nullable: true,
description: 'The local host is represented with `null`.',
},
detailed: { type: 'boolean', default: true },
},
anyOf: [
{ required: ['userId'] },
@ -117,7 +129,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
return await this.userEntityService.packMany(_users, me, {
schema: 'UserDetailed',
schema: ps.detailed ? 'UserDetailed' : 'UserLite',
});
} else {
// Lookup user
@ -147,7 +159,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
return await this.userEntityService.pack(user, me, {
schema: 'UserDetailed',
schema: ps.detailed ? 'UserDetailed' : 'UserLite',
});
}
});

View file

@ -93,7 +93,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1);
const info = {
operationId: endpoint.name,
operationId: endpoint.name.replaceAll('/', '___'), // NOTE: スラッシュは使えない
summary: endpoint.name,
description: desc,
externalDocs: {

View file

@ -160,19 +160,17 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
/*
test('フォローしているユーザーの visibility: followers な投稿への返信が流れる', async () => {
const note = await api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko);
const note = await post(kyoko, { text: 'foo', visibility: 'followers' });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.body.id }, kyoko), // kyoko posts
() => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.id }, kyoko), // kyoko posts
msg => msg.type === 'note' && msg.body.userId === kyoko.id && msg.body.reply.text === 'foo',
);
assert.strictEqual(fired, true);
});
*/
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => {
const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });

View file

@ -29,6 +29,7 @@ const users = ref<Misskey.entities.UserLite[]>([]);
onMounted(async () => {
users.value = await misskeyApi('users/show', {
userIds: props.userIds,
detailed: false,
}) as unknown as Misskey.entities.UserLite[];
});
</script>

View file

@ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd>
</dl>
</div>
<div v-if="user.fields.length > 0" class="fields">
<div v-if="user.fields.length > 0 || userSkebStatus" class="fields">
<dl v-for="(field, i) in user.fields" :key="i" class="field">
<dt class="name">
<Mfm :text="field.name" :plain="true" :colored="false"/>
@ -104,6 +104,44 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i>
</dd>
</dl>
<dl v-if="userSkebStatus" class="field">
<dt class="name">
<a href="https://skeb.jp/" target="_blank" rel="noopener" style="display: flex; gap: 2px; align-items: center; justify-content: center;">
<!--
*** LICENSE NOTICE ***
* This SVG is derived from the https://skeb.jp/ website, All rights reserved to *Skeb Inc.* https://skeb.co.jp/
* This resource SHOULD NOT be considered as a part of the this project that has licensed under AGPL-3.0-only
-->
<svg class="ti ti-fw" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<linearGradient id="a" x1="14.645309%" x2="85.354691%" y1="14.645309%" y2="85.363492%">
<stop offset=".2" stop-color="#30b396"/>
<stop offset="1" stop-color="#1e5e71"/>
</linearGradient>
<g fill="none">
<circle cx="19.99375" cy="19.98125" fill="url(#a)" r="17.753125"/>
<path d="m18.784375 13.9875-7.35625 21.546875c-.8125-.446875-1.58125-.959375-2.309375-1.525l1.134375-3.26875-2.528125-.86875.859375-2.56875 2.528125.86875 1.728125-5.05625-2.528125-.86875.86875-2.5375 2.528125.86875 1.728125-5.05625-2.528125-.86875.86875-2.5375 2.528125.86875 1.6875-4.99375-2.496875-.909375.440625-1.26875 5.05625 1.728125-.378125 1.1-1.76875 5.184375-.059375.159375zm-12.996875 16.640625c.6625.884375 1.40625 1.703125 2.21875 2.446875l.51875-1.515625zm3.29375-13.01875 3.8 1.296875.865625-2.534375-3.8-1.296875zm-2.590625 7.590625 3.8 1.296875.865625-2.534375-3.8-1.296875zm31.259375-5.21875c0-2.534375-.534375-4.940625-1.490625-7.11875l-13.325-4.559375-9.603125 28.134375c2.059375.834375 4.30625 1.3 6.665625 1.3 9.80625 0 17.753125-7.946875 17.753125-17.753125zm-26.071875-9.971875 3.8 1.296875.865625-2.534375-3.8-1.296875z" fill="#fff"/><path d="m19.99375 2.23125c9.80625 0 17.753125 7.946875 17.753125 17.753125s-7.946875 17.753125-17.753125 17.753125-17.753125-7.946875-17.753125-17.753125 7.946875-17.753125 17.753125-17.753125m0-2.228125c-11.034375 0-19.98125 8.946875-19.98125 19.98125s8.946875 19.98125 19.98125 19.98125 19.98125-8.946875 19.98125-19.98125-8.946875-19.98125-19.98125-19.98125z" fill="#1e5e71"/>
</g>
</svg>
<span>Skeb</span>
<i class="ti ti-external-link" style="font-size: .9em;"></i>
</a>
</dt>
<dd class="value">
<a :href="`https://skeb.jp/@${userSkebStatus.screenName}`" target="_blank" rel="noopener" style="display: flex; gap: 2px; align-items: center;">
<span v-if="userSkebStatus.isAcceptable" :class="$style.skebAcceptable">
{{ i18n.ts._skebStatus.seeking }}
</span>
<span v-else-if="userSkebStatus.isCreator" :class="$style.skebStopped">
{{ i18n.ts._skebStatus.stopped }}
</span>
<span v-else :class="$style.skebClient">
{{ i18n.ts._skebStatus.client }}
</span>
<Mfm :text="buildSkebStatus()" :author="user" :nyaize="false" :colored="false"/>
<i class="ti ti-external-link" style="font-size: .9em;"></i>
</a>
</dd>
</dl>
</div>
<div class="status">
<MkA :to="userPage(user)">
@ -167,10 +205,11 @@ import number from '@/filters/number.js';
import { userPage } from '@/filters/user.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { $i, iAmModerator } from '@/account.js';
import { dateString } from '@/filters/date.js';
import { confetti } from '@/scripts/confetti.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
import { useRouter } from '@/router/supplier.js';
@ -204,6 +243,7 @@ const props = withDefaults(defineProps<{
const router = useRouter();
const user = ref(props.user);
const userSkebStatus = ref<Misskey.Endpoints['users/get-skeb-status']['res'] | null>(null);
const parallaxAnimationId = ref<null | number>(null);
const narrow = ref<null | boolean>(null);
const rootEl = ref<null | HTMLElement>(null);
@ -273,8 +313,44 @@ async function updateMemo() {
isEditingMemo.value = false;
}
async function fetchSkebStatus() {
if (!instance.enableSkebStatus || !props.user.id) {
userSkebStatus.value = null;
return;
}
console.log('fetching skeb status');
userSkebStatus.value = await misskeyApiGet('users/get-skeb-status', { userId: props.user.id });
}
function buildSkebStatus(): string {
if (!userSkebStatus.value) return '';
if (userSkebStatus.value.isCreator) {
let status = '';
if (userSkebStatus.value.isAcceptable) {
status += `${i18n.ts._skebStatus._genres[userSkebStatus.value.skills[0].genre]} ${i18n.tsx._skebStatus.yenX({ x: userSkebStatus.value.skills[0].amount.toLocaleString() })}`;
}
if (userSkebStatus.value.creatorRequestCount > 0) {
if (userSkebStatus.value.isAcceptable) {
status += ' | ';
}
status += i18n.tsx._skebStatus.nWorks({ n: userSkebStatus.value.creatorRequestCount.toLocaleString() });
}
return status;
} else if (userSkebStatus.value.clientRequestCount > 0) {
return i18n.tsx._skebStatus.nRequests({ n: userSkebStatus.value.clientRequestCount.toLocaleString() });
}
return '';
}
watch([props.user], () => {
memoDraft.value = props.user.memo;
fetchSkebStatus();
});
onMounted(() => {
@ -292,6 +368,7 @@ onMounted(() => {
});
}
}
fetchSkebStatus();
nextTick(() => {
adjustMemoTextarea();
});
@ -685,4 +762,30 @@ onUnmounted(() => {
margin-left: 4px;
color: var(--success);
}
.skebAcceptable,
.skebStopped,
.skebClient {
display: inline-flex;
border: solid 1px;
border-radius: 6px;
padding: 2px 6px;
margin-right: 4px;
font-size: 85%;
}
.skebAcceptable {
color: rgb(255, 255, 255);
background-color: rgb(241, 70, 104);
}
.skebStopped {
color: rgb(255, 255, 255);
background-color: rgb(54, 54, 54);
}
.skebClient {
color: rgb(255, 255, 255);
background-color: rgb(54, 54, 54);
}
</style>

View file

@ -4,42 +4,76 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
<MkContainer :style="`height: ${widgetProps.height}px;`" :showHeader="widgetProps.showHeader" :scrollable="true" class="mkw-bdayfollowings">
<template #icon><i class="ti ti-cake"></i></template>
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
<template v-if="widgetProps.period === 'today'" #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
<template v-else #header>{{ i18n.ts._widgets.birthdaySoon }}</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="fetch(true)"><i class="ti ti-refresh"></i></button></template>
<div :class="$style.bdayFRoot">
<MkLoading v-if="fetching"/>
<div v-else-if="users.length > 0" :class="$style.bdayFGrid">
<MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar>
</div>
<div v-else :class="$style.bdayFFallback">
<img :src="infoImageUrl" class="_ghost" :class="$style.bdayFFallbackImage"/>
<MkPagination ref="paginationEl" :pagination="birthdayUsersPagination">
<template #empty>
<div :class="$style.empty" :style="`height: ${widgetProps.showHeader ? widgetProps.height - 38 : widgetProps.height}px;`">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</template>
<template #default="{ items: users }">
<MkDateSeparatedList v-slot="{ item }" :items="toMisskeyEntity(users)" :noGap="true">
<div v-if="item.user" :key="item.id" style="display: flex; gap: 8px; padding-right: 16px">
<MkA :to="userPage(item.user)" style="flex-grow: 1;">
<MkUserCardMini :user="item.user" :withChart="false" style="background: inherit; border-radius: unset;"/>
</MkA>
<button v-tooltip.noDelay="i18n.ts.note" class="_button" :class="$style.post" @click="os.post({initialText: `@${item.user.username}${item.user.host ? `@${item.user.host}` : ''} `})">
<i class="ti-fw ti ti-confetti" :class="$style.postIcon"></i>
</button>
</div>
</MkDateSeparatedList>
</template>
</MkPagination>
</MkContainer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import { GetFormResultType } from '@/scripts/form.js';
import MkContainer from '@/components/MkContainer.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useInterval } from '@/scripts/use-interval.js';
import type { MisskeyEntity } from '@/types/date-separated-list.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { userPage } from '@/filters/user.js';
import { infoImageUrl } from '@/instance.js';
import { $i } from '@/account.js';
import { GetFormResultType } from '@/scripts/form.js';
import { useInterval } from '@/scripts/use-interval.js';
import MkContainer from '@/components/MkContainer.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
const name = i18n.ts._widgets.birthdayFollowings;
const name = i18n.ts._widgets.birthdaySoon;
const widgetPropsDef = {
showHeader: {
type: 'boolean' as const,
default: true,
},
height: {
type: 'number' as const,
default: 300,
},
period: {
type: 'radio' as const,
default: 'today',
options: [{
value: 'today', label: i18n.ts.today,
}, {
value: '3day', label: i18n.tsx.dayX({ day: 3 }),
}, {
value: 'week', label: i18n.ts.oneWeek,
}, {
value: 'month', label: i18n.ts.oneMonth,
}],
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
@ -47,41 +81,74 @@ type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
const { widgetProps, configure } = useWidgetPropsManager(
name,
widgetPropsDef,
props,
emit,
);
const users = ref<Misskey.entities.FollowingFolloweePopulated[]>([]);
const fetching = ref(true);
let lastFetchedAt = '1970-01-01';
const fetch = () => {
if (!$i) {
users.value = [];
fetching.value = false;
return;
const begin = ref<Date>(new Date());
const end = computed(() => {
switch (widgetProps.period) {
case '3day':
return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 3);
case 'week':
return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 7);
case 'month':
return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 30);
default:
return begin.value;
}
const lfAtD = new Date(lastFetchedAt);
lfAtD.setHours(0, 0, 0, 0);
const now = new Date();
now.setHours(0, 0, 0, 0);
if (now > lfAtD) {
misskeyApi('users/following', {
limit: 18,
birthday: now.toISOString(),
userId: $i.id,
}).then(res => {
users.value = res;
fetching.value = false;
});
lastFetchedAt = now.toISOString();
}
const paginationEl = ref<InstanceType<typeof MkPagination>>();
const birthdayUsersPagination = {
endpoint: 'users/get-following-birthday-users' as const,
limit: 18,
offsetMode: true,
params: computed(() => {
if (widgetProps.period === 'today') {
return {
birthday: {
month: begin.value.getMonth() + 1,
day: begin.value.getDate(),
},
};
} else {
return {
birthday: {
begin: {
month: begin.value.getMonth() + 1,
day: begin.value.getDate(),
},
end: {
month: end.value.getMonth() + 1,
day: end.value.getDate(),
},
},
};
}
}),
};
function fetch(force = false) {
const now = new Date();
if (force || now.getDate() !== begin.value.getDate()) {
// computed() paginationEl.value!.reload()
begin.value = now;
}
}
function toMisskeyEntity(items): MisskeyEntity[] {
const r = items.map((item: { userId: string, birthday: string, user: Misskey.entities.UserLite }) => ({
id: item.user.id,
createdAt: item.birthday,
user: item.user,
}));
return [{ id: '_', createdAt: begin.value.toISOString() }, ...r];
}
useInterval(fetch, 1000 * 60, {
immediate: true,
@ -96,32 +163,39 @@ defineExpose<WidgetComponentExpose>({
</script>
<style lang="scss" module>
.bdayFRoot {
overflow: hidden;
min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--margin) * 2));
}
.bdayFGrid {
display: grid;
grid-template-columns: repeat(6, 42px);
grid-template-rows: repeat(3, 42px);
place-content: center;
gap: 8px;
margin: var(--margin) auto;
}
.bdayFFallback {
height: 100%;
.empty {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.bdayFFallbackImage {
> img {
height: 96px;
width: auto;
max-width: 90%;
margin-bottom: 8px;
border-radius: var(--radius);
}
}
.post {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
margin: auto;
aspect-ratio: 1/1;
border-radius: 100%;
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
&:hover, &.active {
&:before {
background: var(--accentLighten);
}
}
}
.postIcon {
color: var(--fgOnAccent);
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -60,13 +60,17 @@ async function generateEndpoints(
// misskey-jsはPOST固定で送っているので、こちらも決め打ちする。別メソッドに対応することがあればこちらも直す必要あり
const paths = openApiDocs.paths ?? {};
const postPathItems = Object.keys(paths)
.map(it => paths[it]?.post)
.map(it => ({
_path_: it.replace(/^\//, ''),
...paths[it]?.post,
}))
.filter(filterUndefined);
for (const operation of postPathItems) {
const path = operation._path_;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const operationId = operation.operationId!;
const endpoint = new Endpoint(operationId);
const endpoint = new Endpoint(path);
endpoints.push(endpoint);
if (isRequestBodyObject(operation.requestBody)) {
@ -76,19 +80,21 @@ async function generateEndpoints(
// いまのところ複数のメディアタイプをとるエンドポイントは無いので決め打ちする
endpoint.request = new OperationTypeAlias(
operationId,
path,
supportMediaTypes[0],
OperationsAliasType.REQUEST,
);
}
}
if (isResponseObject(operation.responses['200']) && operation.responses['200'].content) {
if (operation.responses && isResponseObject(operation.responses['200']) && operation.responses['200'].content) {
const resContent = operation.responses['200'].content;
const supportMediaTypes = Object.keys(resContent);
if (supportMediaTypes.length > 0) {
// いまのところ複数のメディアタイプを返すエンドポイントは無いので決め打ちする
endpoint.response = new OperationTypeAlias(
operationId,
path,
supportMediaTypes[0],
OperationsAliasType.RESPONSE,
);
@ -98,6 +104,8 @@ async function generateEndpoints(
const entitiesOutputLine: string[] = [];
entitiesOutputLine.push('/* eslint @typescript-eslint/naming-convention: 0 */');
entitiesOutputLine.push(`import { operations } from '${toImportPath(typeFileName)}';`);
entitiesOutputLine.push('');
@ -138,12 +146,19 @@ async function generateApiClientJSDoc(
endpointsFileName: string,
warningsOutputPath: string,
) {
const endpoints: { operationId: string; description: string; }[] = [];
const endpoints: {
operationId: string;
path: string;
description: string;
}[] = [];
// misskey-jsはPOST固定で送っているので、こちらも決め打ちする。別メソッドに対応することがあればこちらも直す必要あり
const paths = openApiDocs.paths ?? {};
const postPathItems = Object.keys(paths)
.map(it => paths[it]?.post)
.map(it => ({
_path_: it.replace(/^\//, ''),
...paths[it]?.post,
}))
.filter(filterUndefined);
for (const operation of postPathItems) {
@ -153,6 +168,7 @@ async function generateApiClientJSDoc(
if (operation.description) {
endpoints.push({
operationId: operationId,
path: operation._path_,
description: operation.description,
});
}
@ -173,7 +189,7 @@ async function generateApiClientJSDoc(
' /**',
` * ${endpoint.description.split('\n').join('\n * ')}`,
' */',
` request<E extends '${endpoint.operationId}', P extends Endpoints[E][\'req\']>(`,
` request<E extends '${endpoint.path}', P extends Endpoints[E][\'req\']>(`,
' endpoint: E,',
' params: P,',
' credential?: string | null,',
@ -232,21 +248,24 @@ interface IOperationTypeAlias {
class OperationTypeAlias implements IOperationTypeAlias {
public readonly operationId: string;
public readonly path: string;
public readonly mediaType: string;
public readonly type: OperationsAliasType;
constructor(
operationId: string,
path: string,
mediaType: string,
type: OperationsAliasType,
) {
this.operationId = operationId;
this.path = path;
this.mediaType = mediaType;
this.type = type;
}
generateName(): string {
const nameBase = this.operationId.replace(/\//g, '-');
const nameBase = this.path.replace(/\//g, '-');
return toPascal(nameBase + this.type);
}
@ -279,19 +298,19 @@ const emptyRequest = new EmptyTypeAlias(OperationsAliasType.REQUEST);
const emptyResponse = new EmptyTypeAlias(OperationsAliasType.RESPONSE);
class Endpoint {
public readonly operationId: string;
public readonly path: string;
public request?: IOperationTypeAlias;
public response?: IOperationTypeAlias;
constructor(operationId: string) {
this.operationId = operationId;
constructor(path: string) {
this.path = path;
}
toLine(): string {
const reqName = this.request?.generateName() ?? emptyRequest.generateName();
const resName = this.response?.generateName() ?? emptyResponse.generateName();
return `'${this.operationId}': { req: ${reqName}; res: ${resName} };`;
return `'${this.path}': { req: ${reqName}; res: ${resName} };`;
}
}

View file

@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2024.3.1-host.3",
"version": "2024.3.1-host.4",
"description": "Misskey SDK for JavaScript",
"types": "./built/dts/index.d.ts",
"exports": {

View file

@ -3804,6 +3804,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* Find users who have a birthday on the specified range.
*
* **Credential required**: *Yes* / **Permission**: *read:account*
*/
request<E extends 'users/get-following-birthday-users', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* Get a list of other users that the specified user frequently replies to.
*
@ -3815,6 +3826,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *No*
*/
request<E extends 'users/get-skeb-status', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View file

@ -506,8 +506,12 @@ import type {
UsersFollowingResponse,
UsersGalleryPostsRequest,
UsersGalleryPostsResponse,
UsersGetFollowingBirthdayUsersRequest,
UsersGetFollowingBirthdayUsersResponse,
UsersGetFrequentlyRepliedUsersRequest,
UsersGetFrequentlyRepliedUsersResponse,
UsersGetSkebStatusRequest,
UsersGetSkebStatusResponse,
UsersFeaturedNotesRequest,
UsersFeaturedNotesResponse,
UsersListsCreateRequest,
@ -916,7 +920,9 @@ export type Endpoints = {
'users/followers': { req: UsersFollowersRequest; res: UsersFollowersResponse };
'users/following': { req: UsersFollowingRequest; res: UsersFollowingResponse };
'users/gallery/posts': { req: UsersGalleryPostsRequest; res: UsersGalleryPostsResponse };
'users/get-following-birthday-users': { req: UsersGetFollowingBirthdayUsersRequest; res: UsersGetFollowingBirthdayUsersResponse };
'users/get-frequently-replied-users': { req: UsersGetFrequentlyRepliedUsersRequest; res: UsersGetFrequentlyRepliedUsersResponse };
'users/get-skeb-status': { req: UsersGetSkebStatusRequest; res: UsersGetSkebStatusResponse };
'users/featured-notes': { req: UsersFeaturedNotesRequest; res: UsersFeaturedNotesResponse };
'users/lists/create': { req: UsersListsCreateRequest; res: UsersListsCreateResponse };
'users/lists/delete': { req: UsersListsDeleteRequest; res: EmptyResponse };

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff