Improve paste uploading Resolve #3023 (#4542)

* resolve #3023

* fix

* fix

* better description

* widget

* fix text

* Update post-form.vue

* Fix enter-file-name dialog title text

* Fix type

* On messaging room

* Replace moment.js to original one

* Fix formatDateTimeString
This commit is contained in:
tamaina 2019-07-08 13:46:31 +09:00 committed by syuilo
parent eb783f827c
commit 5343b005df
10 changed files with 163 additions and 18 deletions

View file

@ -129,6 +129,7 @@ common:
add-visible-user: "ユーザーを追加" add-visible-user: "ユーザーを追加"
cw-placeholder: "内容への注釈 (オプション)" cw-placeholder: "内容への注釈 (オプション)"
username-prompt: "ユーザー名を入力してください" username-prompt: "ユーザー名を入力してください"
enter-file-name: "ファイル名を編集"
weekday-short: weekday-short:
sunday: "日" sunday: "日"
@ -201,6 +202,11 @@ common:
remember-note-visibility: "投稿の公開範囲を記憶する" remember-note-visibility: "投稿の公開範囲を記憶する"
web-search-engine: "ウェブ検索エンジン" web-search-engine: "ウェブ検索エンジン"
web-search-engine-desc: "例: https://www.google.com/?#q={{query}}" web-search-engine-desc: "例: https://www.google.com/?#q={{query}}"
paste: "ペースト"
pasted-file-name: "ペーストされたファイル名のテンプレート"
pasted-file-name-desc: "例: \"yyyy-MM-dd HH-mm-ss [{{number}}]\" → \"2018-03-20 21-30-24 1\""
paste-dialog: "ペースト時にファイル名を編集"
paste-dialog-desc: "ペースト時にファイル名を編集するダイアログを表示するようにします。"
keep-cw: "CW保持" keep-cw: "CW保持"
keep-cw-desc: "投稿にリプライする際、リプライ元の投稿にCWが設定されていたとき、デフォルトで同じCWを設定するようにします。" keep-cw-desc: "投稿にリプライする際、リプライ元の投稿にCWが設定されていたとき、デフォルトで同じCWを設定するようにします。"
i-like-sushi: "私は(プリンよりむしろ)寿司が好き" i-like-sushi: "私は(プリンよりむしろ)寿司が好き"

View file

@ -8,6 +8,7 @@ import { host, url } from '../../config';
import i18n from '../../i18n'; import i18n from '../../i18n';
import { erase, unique } from '../../../../prelude/array'; import { erase, unique } from '../../../../prelude/array';
import extractMentions from '../../../../misc/extract-mentions'; import extractMentions from '../../../../misc/extract-mentions';
import { formatTimeString } from '../../../../misc/format-time-string';
export default (opts) => ({ export default (opts) => ({
i18n: i18n(), i18n: i18n(),
@ -244,8 +245,8 @@ export default (opts) => ({
for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); for (const x of Array.from((this.$refs.file as any).files)) this.upload(x);
}, },
upload(file) { upload(file: File, name?: string) {
(this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder); (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name);
}, },
onChangeUploadings(uploads) { onChangeUploadings(uploads) {
@ -334,10 +335,23 @@ export default (opts) => ({
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
}, },
async onPaste(e) { async onPaste(e: ClipboardEvent) {
for (const item of Array.from(e.clipboardData.items)) { for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
if (item.kind == 'file') { if (item.kind == 'file') {
this.upload(item.getAsFile()); const file = item.getAsFile();
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
const name = this.$store.state.settings.pasteDialog
? await this.$root.dialog({
title: this.$t('@.post-form.enter-file-name'),
input: {
default: formatted
},
allowEmpty: false
}).then(({ canceled, result }) => canceled ? false : result)
: formatted;
if (name) this.upload(file, name);
} }
} }

View file

@ -30,6 +30,7 @@
import Vue from 'vue'; import Vue from 'vue';
import i18n from '../../../i18n'; import i18n from '../../../i18n';
import * as autosize from 'autosize'; import * as autosize from 'autosize';
import { formatTimeString } from '../../../../../misc/format-time-string';
export default Vue.extend({ export default Vue.extend({
i18n: i18n('common/views/components/messaging-room.form.vue'), i18n: i18n('common/views/components/messaging-room.form.vue'),
@ -84,13 +85,26 @@ export default Vue.extend({
} }
}, },
methods: { methods: {
onPaste(e) { async onPaste(e: ClipboardEvent) {
const data = e.clipboardData; const data = e.clipboardData;
const items = data.items; const items = data.items;
if (items.length == 1) { if (items.length == 1) {
if (items[0].kind == 'file') { if (items[0].kind == 'file') {
this.upload(items[0].getAsFile()); const file = items[0].getAsFile();
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, '1')}${ext}`;
const name = this.$store.state.settings.pasteDialog
? await this.$root.dialog({
title: this.$t('@.post-form.enter-file-name'),
input: {
default: formatted
},
allowEmpty: false
}).then(({ canceled, result }) => canceled ? false : result)
: formatted;
if (name) this.upload(file, name);
} }
} else { } else {
if (items[0].kind == 'file') { if (items[0].kind == 'file') {
@ -157,8 +171,8 @@ export default Vue.extend({
this.upload((this.$refs.file as any).files[0]); this.upload((this.$refs.file as any).files[0]);
}, },
upload(file) { upload(file: File, name?: string) {
(this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder); (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name);
}, },
onUploaded(file) { onUploaded(file) {

View file

@ -140,7 +140,19 @@
<section> <section>
<header>{{ $t('@._settings.web-search-engine') }}</header> <header>{{ $t('@._settings.web-search-engine') }}</header>
<ui-input v-model="webSearchEngine">{{ $t('@._settings.web-search-engine') }}<template #desc>{{ $t('@._settings.web-search-engine-desc') }}</template></ui-input> <ui-input v-model="webSearchEngine">{{ $t('@._settings.web-search-engine') }}
<template #desc>{{ $t('@._settings.web-search-engine-desc') }}</template>
</ui-input>
</section>
<section v-if="!$root.isMobile">
<header>{{ $t('@._settings.paste') }}</header>
<ui-input v-model="pastedFileName">{{ $t('@._settings.pasted-file-name') }}
<template #desc>{{ $t('@._settings.pasted-file-name-desc') }}</template>
</ui-input>
<ui-switch v-model="pasteDialog">{{ $t('@._settings.paste-dialog') }}
<template #desc>{{ $t('@._settings.paste-dialog-desc') }}</template>
</ui-switch>
</section> </section>
</ui-card> </ui-card>
@ -412,6 +424,16 @@ export default Vue.extend({
set(value) { this.$store.dispatch('settings/set', { key: 'webSearchEngine', value }); } set(value) { this.$store.dispatch('settings/set', { key: 'webSearchEngine', value }); }
}, },
pastedFileName: {
get() { return this.$store.state.settings.pastedFileName; },
set(value) { this.$store.dispatch('settings/set', { key: 'pastedFileName', value }); }
},
pasteDialog: {
get() { return this.$store.state.settings.pasteDialog; },
set(value) { this.$store.dispatch('settings/set', { key: 'pasteDialog', value }); }
},
showReplyTarget: { showReplyTarget: {
get() { return this.$store.state.settings.showReplyTarget; }, get() { return this.$store.state.settings.showReplyTarget; },
set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); }

View file

@ -46,7 +46,7 @@ export default Vue.extend({
}); });
}, },
upload(file: File, folder: any) { upload(file: File, folder: any, name?: string) {
if (folder && typeof folder == 'object') folder = folder.id; if (folder && typeof folder == 'object') folder = folder.id;
const id = Math.random(); const id = Math.random();
@ -61,7 +61,7 @@ export default Vue.extend({
const ctx = { const ctx = {
id: id, id: id,
name: file.name || 'untitled', name: name || file.name || 'untitled',
progress: undefined, progress: undefined,
img: window.URL.createObjectURL(file) img: window.URL.createObjectURL(file)
}; };
@ -75,6 +75,7 @@ export default Vue.extend({
data.append('file', file); data.append('file', file);
if (folder) data.append('folderId', folder); if (folder) data.append('folderId', folder);
if (name) data.append('name', name);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', apiUrl + '/drive/files/create', true); xhr.open('POST', apiUrl + '/drive/files/create', true);

View file

@ -38,6 +38,7 @@
import define from '../../../common/define-widget'; import define from '../../../common/define-widget';
import i18n from '../../../i18n'; import i18n from '../../../i18n';
import insertTextAtCursor from 'insert-text-at-cursor'; import insertTextAtCursor from 'insert-text-at-cursor';
import { formatTimeString } from '../../../../../misc/format-time-string';
export default define({ export default define({
name: 'post-form', name: 'post-form',
@ -109,10 +110,23 @@ export default define({
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && !this.posting && this.text) this.post(); if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && !this.posting && this.text) this.post();
}, },
onPaste(e) { async onPaste(e: ClipboardEvent) {
for (const item of Array.from(e.clipboardData.items)) { for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
if (item.kind == 'file') { if (item.kind == 'file') {
this.upload(item.getAsFile()); const file = item.getAsFile();
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
const name = this.$store.state.settings.pasteDialog
? await this.$root.dialog({
title: this.$t('@.post-form.enter-file-name'),
input: {
default: formatted
},
allowEmpty: false
}).then(({ canceled, result }) => canceled ? false : result)
: formatted;
if (name) this.upload(file, name);
} }
} }
}, },
@ -121,8 +135,8 @@ export default define({
for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); for (const x of Array.from((this.$refs.file as any).files)) this.upload(x);
}, },
upload(file) { upload(file: File, name?: string) {
(this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder); (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name);
}, },
onDragover(e) { onDragover(e) {

View file

@ -39,6 +39,8 @@ const defaultSettings = {
mobileHomeProfiles: {}, mobileHomeProfiles: {},
deckProfiles: {}, deckProfiles: {},
uploadFolder: null, uploadFolder: null,
pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]',
pasteDialog: false,
}; };
const defaultDeviceSettings = { const defaultDeviceSettings = {

View file

@ -0,0 +1,50 @@
const defaultLocaleStringFormats: {[index: string]: string} = {
'weekday': 'narrow',
'era': 'narrow',
'year': 'numeric',
'month': 'numeric',
'day': 'numeric',
'hour': 'numeric',
'minute': 'numeric',
'second': 'numeric',
'timeZoneName': 'short'
};
function formatLocaleString(date: Date, format: string): string {
return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => {
if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) {
return date.toLocaleString(window.navigator.language, {[kind]: option ? option : defaultLocaleStringFormats[kind]});
} else {
return match;
}
});
}
function formatDateTimeString(date: Date, format: string): string {
return format
.replace(/yyyy/g, date.getFullYear().toString())
.replace(/yy/g, date.getFullYear().toString().slice(-2))
.replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long'}))
.replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short'}))
.replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2))
.replace(/M/g, (date.getMonth() + 1).toString())
.replace(/dd/g, (`0${date.getDate()}`).slice(-2))
.replace(/d/g, date.getDate().toString())
.replace(/HH/g, (`0${date.getHours()}`).slice(-2))
.replace(/H/g, date.getHours().toString())
.replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2))
.replace(/h/g, ((date.getHours() % 12) || 12).toString())
.replace(/mm/g, (`0${date.getMinutes()}`).slice(-2))
.replace(/m/g, date.getMinutes().toString())
.replace(/ss/g, (`0${date.getSeconds()}`).slice(-2))
.replace(/s/g, date.getSeconds().toString())
.replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM');
}
export function formatTimeString(date: Date, format: string): string {
return format.replace(/\[(([^\[]|\[\])*)\]|([yMdHhmst]{1,4})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => {
if (localeformat) return formatLocaleString(date, localeformat);
if (datetimeformat) return formatDateTimeString(date, datetimeformat);
return match;
});
}

View file

@ -35,6 +35,14 @@ export const meta = {
} }
}, },
name: {
validator: $.optional.nullable.str,
default: null as any,
desc: {
'ja-JP': 'ファイル名(拡張子があるなら含めて)'
}
},
isSensitive: { isSensitive: {
validator: $.optional.either($.bool, $.str), validator: $.optional.either($.bool, $.str),
default: false, default: false,
@ -72,7 +80,7 @@ export const meta = {
export default define(meta, async (ps, user, app, file, cleanup) => { export default define(meta, async (ps, user, app, file, cleanup) => {
// Get 'name' parameter // Get 'name' parameter
let name = file.originalname; let name = ps.name || file.originalname;
if (name !== undefined && name !== null) { if (name !== undefined && name !== null) {
name = name.trim(); name = name.trim();
if (name.length === 0) { if (name.length === 0) {

View file

@ -474,6 +474,20 @@ describe('API', () => {
assert.strictEqual(res.body.name, 'Lenna.png'); assert.strictEqual(res.body.name, 'Lenna.png');
})); }));
it('ファイルに名前を付けられる', async(async () => {
const alice = await signup({ username: 'alice' });
const res = await assert.request(server)
.post('/drive/files/create')
.field('i', alice.token)
.field('name', 'Belmond.png')
.attach('file', fs.readFileSync(__dirname + '/resources/Lenna.png'), 'Lenna.png');
expect(res).have.status(200);
expect(res.body).be.a('object');
expect(res.body).have.property('name').eql('Belmond.png');
}));
it('ファイル無しで怒られる', async(async () => { it('ファイル無しで怒られる', async(async () => {
const alice = await signup({ username: 'alice' }); const alice = await signup({ username: 'alice' });