Merge branch 'feat/import_export' into 'develop'

feat: ports misskey antenna/favorites/clips exports and antenna imports


See merge request firefish/firefish!10686
This commit is contained in:
laozhoubuluo 2024-05-07 23:59:50 +00:00
commit ad2525760e
18 changed files with 889 additions and 4 deletions

4
.gitignore vendored
View File

@ -56,10 +56,6 @@ packages/backend/assets/instance.css
packages/backend/assets/sounds/None.mp3
packages/backend/assets/LICENSE
!/packages/backend/queue/processors/db
!/packages/backend/src/db
!/packages/backend/src/server/api/endpoints/drive/files
packages/megalodon/lib
packages/megalodon/.idea

View File

@ -28,6 +28,14 @@ Breaking changes are indicated by the :warning: icon.
## v20240405
- Added `notes/history` endpoint.
- With the addition of new features, the following endpoints are added:
- export/import antennas
- `i/export-antennas`
- `i/import-antennas`
- export favorites
- `i/export-favorites`
- export clips
- `i/export-clips`
## v20240319

View File

@ -1809,6 +1809,9 @@ _exportOrImport:
muteList: "Muted users"
blockingList: "Blocked users"
userLists: "User lists"
antennas: "Antennas"
favorites: "Favorites"
clips: "Clips"
excludeMutingUsers: "Exclude muted users"
excludeInactiveUsers: "Exclude inactive users"
_charts:

View File

@ -1430,6 +1430,9 @@ _exportOrImport:
muteList: "已静音用户"
blockingList: "已屏蔽用户"
userLists: "列表"
antennas: "天线"
favorites: "收藏"
clips: "便签"
excludeMutingUsers: "排除已静音用户"
excludeInactiveUsers: "排除不活跃用户"
_charts:

View File

@ -1422,6 +1422,9 @@ _exportOrImport:
muteList: "靜音"
blockingList: "封鎖"
userLists: "清單"
antennas: "天線"
favorites: "最愛列表"
clips: "摘錄"
excludeMutingUsers: "排除被靜音的使用者"
excludeInactiveUsers: "排除不活躍帳戶"
_charts:

View File

@ -295,6 +295,45 @@ export function createExportUserListsJob(user: ThinUser) {
);
}
export function createExportAntennasJob(user: ThinUser) {
return dbQueue.add(
"exportAntennas",
{
user: user,
},
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
export function createExportFavoritesJob(user: ThinUser) {
return dbQueue.add(
"exportFavorites",
{
user: user,
},
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
export function createExportClipsJob(user: ThinUser) {
return dbQueue.add(
"exportClips",
{
user: user,
},
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
export function createImportFollowingJob(
user: ThinUser,
fileId: DriveFile["id"],
@ -438,6 +477,23 @@ export function createImportCustomEmojisJob(
);
}
export function createImportAntennasJob(
user: ThinUser,
fileId: DriveFile["id"],
) {
return dbQueue.add(
"importAntennas",
{
user: user,
fileId: fileId,
},
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
export function createDeleteAccountJob(
user: ThinUser,
opts: { soft?: boolean } = {},

View File

@ -0,0 +1,106 @@
import type Bull from "bull";
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 { createTemp } from "@/misc/create-temp.js";
import { Users, UserListJoinings, Antennas } from "@/models/index.js";
import { In } from "typeorm";
import type { DbUserJobData } from "@/queue/types.js";
import type { User } from "@/models/entities/user.js";
import { getFullApAccount } from "@/misc/convert-host.js";
import { inspect } from "node:util";
const logger = queueLogger.createSubLogger("export-antennas");
export async function exportAntennas(
job: Bull.Job<DbUserJobData>,
done: any,
): Promise<void> {
logger.info(`Exporting antennas of ${job.data.user.id} ...`);
const user = await Users.findOneBy({ id: job.data.user.id });
if (user == null) {
done();
return;
}
// Create temp file
const [path, cleanup] = await createTemp();
logger.info(`Temp file is ${path}`);
try {
const stream = fs.createWriteStream(path, { flags: "a" });
const write = (input: string): Promise<void> => {
return new Promise((resolve, reject) => {
stream.write(input, (err) => {
if (err) {
logger.error(inspect(err));
reject();
} else {
resolve();
}
});
});
};
const antennas = await Antennas.findBy({ userId: job.data.user.id });
write("[");
for (const [index, antenna] of antennas.entries()) {
let users: User[] | undefined;
if (antenna.userListId !== null) {
const joinings = await UserListJoinings.findBy({
userListId: antenna.userListId,
});
users = await Users.findBy({
id: In(joinings.map((j) => j.userId)),
});
}
write(
JSON.stringify({
name: antenna.name,
src: antenna.src,
keywords: antenna.keywords,
excludeKeywords: antenna.excludeKeywords,
users: antenna.users,
userListAcct:
typeof users !== "undefined"
? users.map((u) => {
getFullApAccount(u.username, u.host);
})
: null,
caseSensitive: antenna.caseSensitive,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
notify: antenna.notify,
}),
);
if (antennas.length - 1 !== index) {
write(", ");
}
}
write("]");
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = `antennas-${dateFormat(
new Date(),
"yyyy-MM-dd-HH-mm-ss",
)}.json`;
const driveFile = await addFile({
user,
path,
name: fileName,
force: true,
});
logger.succ(`Exported to: ${driveFile.id}`);
} finally {
cleanup();
}
done();
}

View File

@ -0,0 +1,184 @@
import type Bull from "bull";
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 { createTemp } from "@/misc/create-temp.js";
import { Users, Clips, ClipNotes, Polls } from "@/models/index.js";
import { MoreThan } from "typeorm";
import type { DbUserJobData } from "@/queue/types.js";
import type { Clip } from "@/models/entities/clip.js";
import type { ClipNote } from "@/models/entities/clip-note.js";
import type { User } from "@/models/entities/user.js";
import type { Note } from "@/models/entities/note.js";
import type { Poll } from "@/models/entities/poll.js";
import { config } from "@/config.js";
import { inspect } from "node:util";
const logger = queueLogger.createSubLogger("export-clips");
export async function exportClips(
job: Bull.Job<DbUserJobData>,
done: any,
): Promise<void> {
logger.info(`Exporting clips of ${job.data.user.id} ...`);
const user = await Users.findOneBy({ id: job.data.user.id });
if (user == null) {
done();
return;
}
// Create temp file
const [path, cleanup] = await createTemp();
logger.info(`Temp file is ${path}`);
try {
const stream = fs.createWriteStream(path, { flags: "a" });
const write = (text: string): Promise<void> => {
return new Promise<void>((res, rej) => {
stream.write(text, (err) => {
if (err) {
logger.error(inspect(err));
rej(err);
} else {
res();
}
});
});
};
await write("[");
let exportedClipsCount = 0;
let cursor: Clip["id"] | null = null;
while (true) {
const clips = await Clips.find({
where: {
userId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
});
if (clips.length === 0) {
job.progress(100);
break;
}
cursor = clips.at(-1)?.id ?? null;
for (const clip of clips) {
// Stringify but remove the last `]}`
const clip_export = {
id: clip.id,
name: clip.name,
description: clip.description,
clipNotes: [],
};
const content = JSON.stringify(clip_export).slice(0, -2);
const isFirst = exportedClipsCount === 0;
await write(isFirst ? content : `,\n${content}`);
let exportedClipNotesCount = 0;
let cursor: ClipNote["id"] | null = null;
while (true) {
const clipNotes = (await ClipNotes.find({
where: {
clipId: clip.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
relations: ["note", "note.user"],
})) as (ClipNote & { note: Note & { user: User } })[];
if (clipNotes.length === 0) {
break;
}
cursor = clipNotes.at(-1)?.id ?? null;
for (const clipNote of clipNotes) {
let poll: Poll | undefined;
if (clipNote.note.hasPoll) {
poll = await Polls.findOneByOrFail({ noteId: clipNote.note.id });
}
const clipnode_export = {
id: clipNote.id,
createdAt: new Date(clip.createdAt).toISOString(),
note: {
id: clipNote.note.id,
text: clipNote.note.text,
createdAt: new Date(clipNote.note.createdAt).toISOString(),
fileIds: clipNote.note.fileIds,
replyId: clipNote.note.replyId,
renoteId: clipNote.note.renoteId,
poll: poll,
cw: clipNote.note.cw,
visibility: clipNote.note.visibility,
visibleUserIds: clipNote.note.visibleUserIds,
localOnly: clipNote.note.localOnly,
objectUrl: `${config.url}/notes/${clipNote.note.id}`, // add objectUrl for future import, firefish only
uri: clipNote.note.uri,
url: clipNote.note.url,
user: {
id: clipNote.note.user.id,
name: clipNote.note.user.name,
username: clipNote.note.user.username,
host: clipNote.note.user.host,
uri: clipNote.note.user.uri,
},
},
};
const content = JSON.stringify(clipnode_export);
const isFirst = exportedClipNotesCount === 0;
await write(isFirst ? content : `,\n${content}`);
exportedClipNotesCount++;
}
}
await write("]}");
exportedClipsCount++;
}
const total = await Clips.countBy({
userId: user.id,
});
job.progress(exportedClipsCount / total);
}
await write("]");
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = `clips-${dateFormat(
new Date(),
"yyyy-MM-dd-HH-mm-ss",
)}.json`;
const driveFile = await addFile({
user,
path,
name: fileName,
force: true,
});
logger.succ(`Exported to: ${driveFile.id}`);
} finally {
cleanup();
done();
}
}

View File

@ -0,0 +1,144 @@
import type Bull from "bull";
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 { createTemp } from "@/misc/create-temp.js";
import { Users, NoteFavorites, Polls } from "@/models/index.js";
import { MoreThan } from "typeorm";
import type { DbUserJobData } from "@/queue/types.js";
import type { NoteFavorite } from "@/models/entities/note-favorite.js";
import type { Note } from "@/models/entities/note.js";
import type { User } from "@/models/entities/user.js";
import type { Poll } from "@/models/entities/poll.js";
import { config } from "@/config.js";
import { inspect } from "node:util";
const logger = queueLogger.createSubLogger("export-favorites");
export async function exportFavorites(
job: Bull.Job<DbUserJobData>,
done: any,
): Promise<void> {
logger.info(`Exporting favorites of ${job.data.user.id} ...`);
const user = await Users.findOneBy({ id: job.data.user.id });
if (user == null) {
done();
return;
}
// Create temp file
const [path, cleanup] = await createTemp();
logger.info(`Temp file is ${path}`);
try {
const stream = fs.createWriteStream(path, { flags: "a" });
const write = (text: string): Promise<void> => {
return new Promise<void>((res, rej) => {
stream.write(text, (err) => {
if (err) {
logger.error(inspect(err));
rej(err);
} else {
res();
}
});
});
};
await write("[");
let exportedFavoritesCount = 0;
let cursor: NoteFavorite["id"] | null = null;
while (true) {
const favorites = (await NoteFavorites.find({
where: {
userId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
relations: ["note", "note.user"],
})) as (NoteFavorite & { note: Note & { user: User } })[];
if (favorites.length === 0) {
job.progress(100);
break;
}
cursor = favorites.at(-1)?.id ?? null;
for (const favorite of favorites) {
let poll: Poll | undefined;
if (favorite.note.hasPoll) {
poll = await Polls.findOneByOrFail({ noteId: favorite.note.id });
}
const favorite_export = {
id: favorite.id,
createdAt: new Date(favorite.createdAt).toISOString(),
note: {
id: favorite.note.id,
text: favorite.note.text,
createdAt: new Date(favorite.note.createdAt).toISOString(),
fileIds: favorite.note.fileIds,
replyId: favorite.note.replyId,
renoteId: favorite.note.renoteId,
poll: poll,
cw: favorite.note.cw,
visibility: favorite.note.visibility,
visibleUserIds: favorite.note.visibleUserIds,
localOnly: favorite.note.localOnly,
objectUrl: `${config.url}/notes/${favorite.note.id}`, // add objectUrl for future import, firefish only
uri: favorite.note.uri,
url: favorite.note.url,
user: {
id: favorite.note.user.id,
name: favorite.note.user.name,
username: favorite.note.user.username,
host: favorite.note.user.host,
uri: favorite.note.user.uri,
},
},
};
const content = JSON.stringify(favorite_export);
const isFirst = exportedFavoritesCount === 0;
await write(isFirst ? content : `,\n${content}`);
exportedFavoritesCount++;
}
const total = await NoteFavorites.countBy({
userId: user.id,
});
job.progress(exportedFavoritesCount / total);
}
await write("]");
stream.end();
logger.succ(`Exported to: ${path}`);
const fileName = `favorites-${dateFormat(
new Date(),
"yyyy-MM-dd-HH-mm-ss",
)}.json`;
const driveFile = await addFile({
user,
path,
name: fileName,
force: true,
});
logger.succ(`Exported to: ${driveFile.id}`);
} finally {
cleanup();
done();
}
}

View File

@ -0,0 +1,131 @@
import type Bull from "bull";
import Ajv from "ajv";
import { queueLogger } from "../../logger.js";
import { downloadTextFile } from "@/misc/download-text-file.js";
import { DriveFiles, Users, Antennas } from "@/models/index.js";
import { genId } from "@/misc/gen-id.js";
import type { DbUserImportJobData } from "@/queue/types.js";
import { publishInternalEvent } from "@/services/stream.js";
import { inspect } from "node:util";
const logger = queueLogger.createSubLogger("import-antennas");
export async function importAntennas(
job: Bull.Job<DbUserImportJobData>,
done: any,
): Promise<void> {
logger.info(`Importing antennas of ${job.data.user.id} ...`);
const user = await Users.findOneBy({ id: job.data.user.id });
if (user == null) {
done();
return;
}
const file = await DriveFiles.findOneBy({
id: job.data.fileId,
});
if (file == null) {
done();
return;
}
const antennas = JSON.parse(await downloadTextFile(file.url));
const now = new Date();
try {
const validate = new Ajv().compile({
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 100 },
src: { type: "string", enum: ["home", "all", "users", "list"] },
userListAcct: {
type: "array",
items: {
type: "string",
},
nullable: true,
},
keywords: {
type: "array",
items: {
type: "array",
items: {
type: "string",
},
},
},
excludeKeywords: {
type: "array",
items: {
type: "array",
items: {
type: "string",
},
},
},
users: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" },
withFile: { type: "boolean" },
notify: { type: "boolean" },
},
required: [
"name",
"src",
"keywords",
"excludeKeywords",
"users",
"caseSensitive",
"withReplies",
"withFile",
"notify",
],
});
for (const antenna of antennas) {
if (
antenna.keywords.length === 0 ||
antenna.keywords[0].every((x) => x === "")
)
continue;
if (!validate(antenna)) {
logger.warn("Validation Failed");
continue;
}
const result = await Antennas.insert({
id: genId(),
createdAt: now,
userId: job.data.user.id,
name: antenna.name,
src:
antenna.src === "list" && antenna.userListAcct
? "users"
: antenna.src,
userListId: null,
keywords: antenna.keywords,
excludeKeywords: antenna.excludeKeywords,
users: (antenna.src === "list" && antenna.userListAcct !== null
? antenna.userListAcct
: antenna.users
).filter(Boolean),
caseSensitive: antenna.caseSensitive,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
notify: antenna.notify,
}).then((x) => Antennas.findOneByOrFail(x.identifiers[0]));
logger.succ(`Antenna created: ${result.id}`);
publishInternalEvent("antennaCreated", result);
}
} catch (err: any) {
logger.error(inspect(err));
} finally {
logger.succ("Imported");
done();
}
}

View File

@ -7,6 +7,9 @@ import { exportFollowing } from "./export-following.js";
import { exportMute } from "./export-mute.js";
import { exportBlocking } from "./export-blocking.js";
import { exportUserLists } from "./export-user-lists.js";
import { exportAntennas } from "./export-antennas.js";
import { exportFavorites } from "./export-favorites.js";
import { exportClips } from "./export-clips.js";
import { importFollowing } from "./import-following.js";
import { importUserLists } from "./import-user-lists.js";
import { deleteAccount } from "./delete-account.js";
@ -16,6 +19,7 @@ import { importMastoPost } from "./import-masto-post.js";
import { importCkPost } from "./import-firefish-post.js";
import { importBlocking } from "./import-blocking.js";
import { importCustomEmojis } from "./import-custom-emojis.js";
import { importAntennas } from "./import-antennas.js";
const jobs = {
deleteDriveFiles,
@ -25,6 +29,9 @@ const jobs = {
exportMute,
exportBlocking,
exportUserLists,
exportAntennas,
exportFavorites,
exportClips,
importFollowing,
importMuting,
importBlocking,
@ -33,6 +40,7 @@ const jobs = {
importMastoPost,
importCkPost,
importCustomEmojis,
importAntennas,
deleteAccount,
} as Record<
string,

View File

@ -179,6 +179,9 @@ import * as ep___i_exportMute from "./endpoints/i/export-mute.js";
import * as ep___i_exportNotes from "./endpoints/i/export-notes.js";
import * as ep___i_importPosts from "./endpoints/i/import-posts.js";
import * as ep___i_exportUserLists from "./endpoints/i/export-user-lists.js";
import * as ep___i_exportAntennas from "./endpoints/i/export-antennas.js";
import * as ep___i_exportFavorites from "./endpoints/i/export-favorites.js";
import * as ep___i_exportClips from "./endpoints/i/export-clips.js";
import * as ep___i_favorites from "./endpoints/i/favorites.js";
import * as ep___i_gallery_likes from "./endpoints/i/gallery/likes.js";
import * as ep___i_gallery_posts from "./endpoints/i/gallery/posts.js";
@ -187,6 +190,7 @@ import * as ep___i_importBlocking from "./endpoints/i/import-blocking.js";
import * as ep___i_importFollowing from "./endpoints/i/import-following.js";
import * as ep___i_importMuting from "./endpoints/i/import-muting.js";
import * as ep___i_importUserLists from "./endpoints/i/import-user-lists.js";
import * as ep___i_importAntennas from "./endpoints/i/import-antennas.js";
import * as ep___i_notifications from "./endpoints/i/notifications.js";
import * as ep___i_pageLikes from "./endpoints/i/page-likes.js";
import * as ep___i_pages from "./endpoints/i/pages.js";
@ -528,6 +532,9 @@ const eps = [
["i/export-notes", ep___i_exportNotes],
["i/import-posts", ep___i_importPosts],
["i/export-user-lists", ep___i_exportUserLists],
["i/export-antennas", ep___i_exportAntennas],
["i/export-favorites", ep___i_exportFavorites],
["i/export-clips", ep___i_exportClips],
["i/favorites", ep___i_favorites],
["i/gallery/likes", ep___i_gallery_likes],
["i/gallery/posts", ep___i_gallery_posts],
@ -536,6 +543,7 @@ const eps = [
["i/import-following", ep___i_importFollowing],
["i/import-muting", ep___i_importMuting],
["i/import-user-lists", ep___i_importUserLists],
["i/import-antennas", ep___i_importAntennas],
["i/notifications", ep___i_notifications],
["i/page-likes", ep___i_pageLikes],
["i/pages", ep___i_pages],

View File

@ -0,0 +1,22 @@
import define from "@/server/api/define.js";
import { createExportAntennasJob } from "@/queue/index.js";
import { HOUR } from "@/const.js";
export const meta = {
secure: true,
requireCredential: true,
limit: {
duration: HOUR,
max: 1,
},
} as const;
export const paramDef = {
type: "object",
properties: {},
required: [],
} as const;
export default define(meta, paramDef, async (ps, user) => {
createExportAntennasJob(user);
});

View File

@ -0,0 +1,22 @@
import define from "@/server/api/define.js";
import { createExportClipsJob } from "@/queue/index.js";
import { HOUR } from "@/const.js";
export const meta = {
secure: true,
requireCredential: true,
limit: {
duration: HOUR,
max: 1,
},
} as const;
export const paramDef = {
type: "object",
properties: {},
required: [],
} as const;
export default define(meta, paramDef, async (ps, user) => {
createExportClipsJob(user);
});

View File

@ -0,0 +1,22 @@
import define from "@/server/api/define.js";
import { createExportFavoritesJob } from "@/queue/index.js";
import { HOUR } from "@/const.js";
export const meta = {
secure: true,
requireCredential: true,
limit: {
duration: HOUR,
max: 1,
},
} as const;
export const paramDef = {
type: "object",
properties: {},
required: [],
} as const;
export default define(meta, paramDef, async (ps, user) => {
createExportFavoritesJob(user);
});

View File

@ -0,0 +1,81 @@
import define from "@/server/api/define.js";
import { createImportAntennasJob } from "@/queue/index.js";
import { ApiError } from "@/server/api/error.js";
import { DriveFiles, Antennas } from "@/models/index.js";
import { downloadTextFile } from "@/misc/download-text-file.js";
import { HOUR } from "@/const.js";
export const meta = {
secure: true,
requireCredential: true,
limit: {
duratition: HOUR,
max: 1,
},
errors: {
noSuchFile: {
message: "No such file.",
code: "NO_SUCH_FILE",
id: "b98644cf-a5ac-4277-a502-0b8054a709a3",
},
unexpectedFileType: {
message: "Must be a JSON file.",
code: "UNEXPECTED_FILE_TYPE",
id: "660f3599-bce0-4f95-9dde-311fd841c183",
},
tooBigFile: {
message: "That file is too big.",
code: "TOO_BIG_FILE",
id: "dee9d4ed-ad07-43ed-8b34-b2856398bc60",
},
emptyFile: {
message: "That file is empty.",
code: "EMPTY_FILE",
id: "31a1b42c-06f7-42ae-8a38-a661c5c9f691",
},
tooManyAntennas: {
message: "You cannot create antenna any more.",
code: "TOO_MANY_ANTENNAS",
id: "c3a5a51e-04d4-11ee-be56-0242ac120002",
},
},
} as const;
export const paramDef = {
type: "object",
properties: {
fileId: { type: "string", format: "misskey:id" },
},
required: ["fileId"],
} as const;
export default define(meta, paramDef, async (ps, user) => {
const file = await DriveFiles.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError(meta.errors.noSuchFile);
if (file.size > 2_000_000) throw new ApiError(meta.errors.tooBigFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
let antennas;
try {
antennas = JSON.parse(await downloadTextFile(file.url));
} catch (e) {
throw new ApiError(meta.errors.unexpectedFileType);
}
const currentAntennasCount = await Antennas.findBy({
userId: user.id,
});
// In the future, should consider evolving to be configurable. Misskey v13 here is a configuration item.
if (currentAntennasCount + antennas.length > 5 && !user.isAdmin) {
throw new ApiError(meta.errors.tooManyAntennas);
}
createImportAntennasJob(user, file.id);
});

View File

@ -172,6 +172,71 @@
>
</FormFolder>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._exportOrImport.antennas }}</template>
<FormFolder class="_formBlock">
<template #label>{{ i18n.ts.export }}</template>
<template #icon
><i :class="icon('ph-download-simple')"></i
></template>
<MkButton
primary
:class="$style.button"
inline
@click="exportAntennas()"
><i :class="icon('ph-download-simple')"></i>
{{ i18n.ts.export }}</MkButton
>
</FormFolder>
<FormFolder class="_formBlock">
<template #label>{{ i18n.ts.import }}</template>
<template #icon
><i :class="icon('ph-upload-simple')"></i
></template>
<MkButton
primary
:class="$style.button"
inline
@click="importAntennas($event)"
><i :class="icon('ph-upload-simple')"></i>
{{ i18n.ts.import }}</MkButton
>
</FormFolder>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._exportOrImport.favorites }}</template>
<FormFolder class="_formBlock">
<template #label>{{ i18n.ts.export }}</template>
<template #icon
><i :class="icon('ph-download-simple')"></i
></template>
<MkButton
primary
:class="$style.button"
inline
@click="exportFavorites()"
><i :class="icon('ph-download-simple')"></i>
{{ i18n.ts.export }}</MkButton
>
</FormFolder>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._exportOrImport.clips }}</template>
<FormFolder class="_formBlock">
<template #label>{{ i18n.ts.export }}</template>
<template #icon
><i :class="icon('ph-download-simple')"></i
></template>
<MkButton
primary
:class="$style.button"
inline
@click="exportClips()"
><i :class="icon('ph-download-simple')"></i>
{{ i18n.ts.export }}</MkButton
>
</FormFolder>
</FormSection>
</div>
</template>
@ -248,6 +313,18 @@ const exportMuting = () => {
os.api("i/export-mute", {}).then(onExportSuccess).catch(onError);
};
const exportAntennas = () => {
os.api("i/export-antennas", {}).then(onExportSuccess).catch(onError);
};
const exportFavorites = () => {
os.api("i/export-favorites", {}).then(onExportSuccess).catch(onError);
};
const exportClips = () => {
os.api("i/export-clips", {}).then(onExportSuccess).catch(onError);
};
const importFollowing = async (ev) => {
const file = await selectFile(ev.currentTarget ?? ev.target);
os.api("i/import-following", { fileId: file.id })
@ -276,6 +353,13 @@ const importBlocking = async (ev) => {
.catch(onError);
};
const importAntennas = async (ev) => {
const file = await selectFile(ev.currentTarget ?? ev.target);
os.api("i/import-antennas", { fileId: file.id })
.then(onImportSuccess)
.catch(onError);
};
definePageMetadata({
title: i18n.ts.importAndExport,
icon: `${icon("ph-package")}`,

View File

@ -491,6 +491,9 @@ export type Endpoints = {
"i/export-mute": { req: TODO; res: TODO };
"i/export-notes": { req: TODO; res: TODO };
"i/export-user-lists": { req: TODO; res: TODO };
"i/export-antennas": { req: TODO; res: TODO };
"i/export-favorites": { req: TODO; res: TODO };
"i/export-clips": { req: TODO; res: TODO };
"i/favorites": {
req: {
limit?: number;
@ -511,6 +514,7 @@ export type Endpoints = {
"i/get-word-muted-notes-count": { req: TODO; res: TODO };
"i/import-following": { req: TODO; res: TODO };
"i/import-user-lists": { req: TODO; res: TODO };
"i/import-antennas": { req: TODO; res: TODO };
"i/move": { req: TODO; res: TODO };
"i/known-as": { req: TODO; res: TODO };
"i/notifications": {