feat: improve follow export

This commit is contained in:
syuilo 2021-12-10 01:22:35 +09:00
parent 94c717375b
commit faacf3ae68
7 changed files with 156 additions and 84 deletions

View File

@ -11,6 +11,8 @@
### Improvements ### Improvements
- Added a user-level instance mute in user settings - Added a user-level instance mute in user settings
- フォローエクスポートでミュートしているユーザーを含めないオプションを追加
- フォローエクスポートで使われていないアカウントを含めないオプションを追加
### Bugfixes ### Bugfixes
- クライアント: タッチ機能付きディスプレイを使っていてマウス操作をしている場合に一部機能が動作しない問題を修正 - クライアント: タッチ機能付きディスプレイを使っていてマウス操作をしている場合に一部機能が動作しない問題を修正

View File

@ -1318,6 +1318,8 @@ _exportOrImport:
muteList: "ミュート" muteList: "ミュート"
blockingList: "ブロック" blockingList: "ブロック"
userLists: "リスト" userLists: "リスト"
excludeMutingUsers: "ミュートしているユーザーを除外"
excludeInactiveUsers: "使われていないアカウントを除外"
_charts: _charts:
federationInstancesIncDec: "連合の増減" federationInstancesIncDec: "連合の増減"

View File

@ -126,9 +126,11 @@ export function createExportNotesJob(user: ThinUser) {
}); });
} }
export function createExportFollowingJob(user: ThinUser) { export function createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) {
return dbQueue.add('exportFollowing', { return dbQueue.add('exportFollowing', {
user: user, user: user,
excludeMuting,
excludeInactive,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,

View File

@ -6,13 +6,14 @@ import { queueLogger } from '../../logger';
import addFile from '@/services/drive/add-file'; import addFile from '@/services/drive/add-file';
import * as dateFormat from 'dateformat'; import * as dateFormat from 'dateformat';
import { getFullApAccount } from '@/misc/convert-host'; import { getFullApAccount } from '@/misc/convert-host';
import { Users, Followings } from '@/models/index'; import { Users, Followings, Mutings } from '@/models/index';
import { MoreThan } from 'typeorm'; import { In, MoreThan, Not } from 'typeorm';
import { DbUserJobData } from '@/queue/types'; import { DbUserJobData } from '@/queue/types';
import { Following } from '@/models/entities/following';
const logger = queueLogger.createSubLogger('export-following'); const logger = queueLogger.createSubLogger('export-following');
export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: any): Promise<void> { export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
logger.info(`Exporting following of ${job.data.user.id} ...`); logger.info(`Exporting following of ${job.data.user.id} ...`);
const user = await Users.findOne(job.data.user.id); const user = await Users.findOne(job.data.user.id);
@ -22,7 +23,7 @@ export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: any):
} }
// Create temp file // Create temp file
const [path, cleanup] = await new Promise<[string, any]>((res, rej) => { const [path, cleanup] = await new Promise<[string, () => void]>((res, rej) => {
tmp.file((e, path, fd, cleanup) => { tmp.file((e, path, fd, cleanup) => {
if (e) return rej(e); if (e) return rej(e);
res([path, cleanup]); res([path, cleanup]);
@ -33,13 +34,17 @@ export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: any):
const stream = fs.createWriteStream(path, { flags: 'a' }); const stream = fs.createWriteStream(path, { flags: 'a' });
let exportedCount = 0; let cursor: Following['id'] | null = null;
let cursor: any = null;
const mutings = job.data.excludeMuting ? await Mutings.find({
muterId: user.id,
}) : [];
while (true) { while (true) {
const followings = await Followings.find({ const followings = await Followings.find({
where: { where: {
followerId: user.id, followerId: user.id,
...(mutings.length > 0 ? { followeeId: Not(In(mutings.map(x => x.muteeId))) } : {}),
...(cursor ? { id: MoreThan(cursor) } : {}), ...(cursor ? { id: MoreThan(cursor) } : {}),
}, },
take: 100, take: 100,
@ -49,7 +54,6 @@ export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: any):
}); });
if (followings.length === 0) { if (followings.length === 0) {
job.progress(100);
break; break;
} }
@ -58,7 +62,11 @@ export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: any):
for (const following of followings) { for (const following of followings) {
const u = await Users.findOne({ id: following.followeeId }); const u = await Users.findOne({ id: following.followeeId });
if (u == null) { if (u == null) {
exportedCount++; continue; continue;
}
if (job.data.excludeInactive && u.updatedAt && (Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90)) {
continue;
} }
const content = getFullApAccount(u.username, u.host); const content = getFullApAccount(u.username, u.host);
@ -72,14 +80,7 @@ export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: any):
} }
}); });
}); });
exportedCount++;
} }
const total = await Followings.count({
followerId: user.id,
});
job.progress(exportedCount / total);
} }
stream.end(); stream.end();

View File

@ -21,6 +21,8 @@ export type DbJobData = DbUserJobData | DbUserImportJobData | DbUserDeleteJobDat
export type DbUserJobData = { export type DbUserJobData = {
user: ThinUser; user: ThinUser;
excludeMuting: boolean;
excludeInactive: boolean;
}; };
export type DbUserDeleteJobData = { export type DbUserDeleteJobData = {

View File

@ -1,3 +1,4 @@
import $ from 'cafy';
import define from '../../define'; import define from '../../define';
import { createExportFollowingJob } from '@/queue/index'; import { createExportFollowingJob } from '@/queue/index';
import ms from 'ms'; import ms from 'ms';
@ -9,8 +10,18 @@ export const meta = {
duration: ms('1hour'), duration: ms('1hour'),
max: 1, max: 1,
}, },
params: {
excludeMuting: {
validator: $.optional.bool,
default: false,
},
excludeInactive: {
validator: $.optional.bool,
default: false,
},
},
}; };
export default define(meta, async (ps, user) => { export default define(meta, async (ps, user) => {
createExportFollowingJob(user); createExportFollowingJob(user, ps.excludeMuting, ps.excludeInactive);
}); });

View File

@ -2,106 +2,158 @@
<div class="_formRoot"> <div class="_formRoot">
<FormSection> <FormSection>
<template #label>{{ $ts._exportOrImport.allNotes }}</template> <template #label>{{ $ts._exportOrImport.allNotes }}</template>
<MkButton :class="$style.button" inline @click="doExport('notes')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> <MkButton :class="$style.button" inline @click="exportNotes()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
</FormSection> </FormSection>
<FormSection> <FormSection>
<template #label>{{ $ts._exportOrImport.followingList }}</template> <template #label>{{ $ts._exportOrImport.followingList }}</template>
<MkButton :class="$style.button" inline @click="doExport('following')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> <FormGroup>
<MkButton :class="$style.button" inline @click="doImport('following', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> <FormSwitch v-model="excludeMutingUsers" class="_formBlock">
{{ $ts._exportOrImport.excludeMutingUsers }}
</FormSwitch>
<FormSwitch v-model="excludeInactiveUsers" class="_formBlock">
{{ $ts._exportOrImport.excludeInactiveUsers }}
</FormSwitch>
<MkButton :class="$style.button" inline @click="exportFollowing()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
</FormGroup>
<FormGroup>
<MkButton :class="$style.button" inline @click="importFollowing($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
</FormGroup>
</FormSection> </FormSection>
<FormSection> <FormSection>
<template #label>{{ $ts._exportOrImport.userLists }}</template> <template #label>{{ $ts._exportOrImport.userLists }}</template>
<MkButton :class="$style.button" inline @click="doExport('user-lists')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> <MkButton :class="$style.button" inline @click="exportUserLists()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
<MkButton :class="$style.button" inline @click="doImport('user-lists', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> <MkButton :class="$style.button" inline @click="importUserLists($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
</FormSection> </FormSection>
<FormSection> <FormSection>
<template #label>{{ $ts._exportOrImport.muteList }}</template> <template #label>{{ $ts._exportOrImport.muteList }}</template>
<MkButton :class="$style.button" inline @click="doExport('muting')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> <MkButton :class="$style.button" inline @click="exportMuting()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
<MkButton :class="$style.button" inline @click="doImport('muting', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> <MkButton :class="$style.button" inline @click="importMuting($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
</FormSection> </FormSection>
<FormSection> <FormSection>
<template #label>{{ $ts._exportOrImport.blockingList }}</template> <template #label>{{ $ts._exportOrImport.blockingList }}</template>
<MkButton :class="$style.button" inline @click="doExport('blocking')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> <MkButton :class="$style.button" inline @click="exportBlocking()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
<MkButton :class="$style.button" inline @click="doImport('blocking', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> <MkButton :class="$style.button" inline @click="importBlocking($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
</FormSection> </FormSection>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent, onMounted, ref } from 'vue';
import MkButton from '@/components/ui/button.vue'; import MkButton from '@/components/ui/button.vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
import FormGroup from '@/components/form/group.vue';
import FormSwitch from '@/components/form/switch.vue';
import * as os from '@/os'; import * as os from '@/os';
import { selectFile } from '@/scripts/select-file'; import { selectFile } from '@/scripts/select-file';
import * as symbols from '@/symbols'; import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
export default defineComponent({ export default defineComponent({
components: { components: {
FormSection, FormSection,
FormGroup,
FormSwitch,
MkButton, MkButton,
}, },
emits: ['info'], emits: ['info'],
data() { setup(props, context) {
return { const INFO = {
[symbols.PAGE_INFO]: { title: i18n.locale.importAndExport,
title: this.$ts.importAndExport,
icon: 'fas fa-boxes', icon: 'fas fa-boxes',
bg: 'var(--bg)', bg: 'var(--bg)',
}, };
}
},
mounted() { const excludeMutingUsers = ref(false);
this.$emit('info', this[symbols.PAGE_INFO]); const excludeInactiveUsers = ref(false);
},
methods: { const onExportSuccess = () => {
doExport(target) {
os.api(
target === 'notes' ? 'i/export-notes' :
target === 'following' ? 'i/export-following' :
target === 'blocking' ? 'i/export-blocking' :
target === 'user-lists' ? 'i/export-user-lists' :
target === 'muting' ? 'i/export-mute' :
null, {})
.then(() => {
os.alert({ os.alert({
type: 'info', type: 'info',
text: this.$ts.exportRequested text: i18n.locale.exportRequested,
}); });
}).catch((e: any) => { };
os.alert({
type: 'error',
text: e.message
});
});
},
async doImport(target, e) { const onImportSuccess = () => {
const file = await selectFile(e.currentTarget || e.target);
os.api(
target === 'following' ? 'i/import-following' :
target === 'user-lists' ? 'i/import-user-lists' :
target === 'muting' ? 'i/import-muting' :
target === 'blocking' ? 'i/import-blocking' :
null, {
fileId: file.id
}).then(() => {
os.alert({ os.alert({
type: 'info', type: 'info',
text: this.$ts.importRequested text: i18n.locale.importRequested,
}); });
}).catch((e: any) => { };
const onError = (e) => {
os.alert({ os.alert({
type: 'error', type: 'error',
text: e.message text: e.message,
}); });
};
const exportNotes = () => {
os.api('i/export-notes', {}).then(onExportSuccess).catch(onError);
};
const exportFollowing = () => {
os.api('i/export-following', {
excludeMuting: excludeMutingUsers.value,
excludeInactive: excludeInactiveUsers.value,
})
.then(onExportSuccess).catch(onError);
};
const exportBlocking = () => {
os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError);
};
const exportUserLists = () => {
os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError);
};
const exportMuting = () => {
os.api('i/export-mute', {}).then(onExportSuccess).catch(onError);
};
const importFollowing = async (ev) => {
const file = await selectFile(ev.currentTarget || ev.target);
os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError);
};
const importUserLists = async (ev) => {
const file = await selectFile(ev.currentTarget || ev.target);
os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError);
};
const importMuting = async (ev) => {
const file = await selectFile(ev.currentTarget || ev.target);
os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError);
};
const importBlocking = async (ev) => {
const file = await selectFile(ev.currentTarget || ev.target);
os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
};
onMounted(() => {
context.emit('info', INFO);
}); });
return {
[symbols.PAGE_INFO]: INFO,
excludeMutingUsers,
excludeInactiveUsers,
exportNotes,
exportFollowing,
exportBlocking,
exportUserLists,
exportMuting,
importFollowing,
importUserLists,
importMuting,
importBlocking,
};
}, },
}
}); });
</script> </script>