From 7b4179b2b6a103247121e37897cf8f13f1e6625c Mon Sep 17 00:00:00 2001 From: tamaina Date: Mon, 8 Jul 2019 13:46:31 +0900 Subject: [PATCH] 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 --- locales/ja-JP.yml | 6 +++ src/client/app/common/scripts/post-form.ts | 24 +++++++-- .../views/components/messaging-room.form.vue | 22 ++++++-- .../views/components/settings/settings.vue | 24 ++++++++- .../app/common/views/components/uploader.vue | 5 +- .../app/common/views/widgets/post-form.vue | 24 +++++++-- src/client/app/store.ts | 2 + src/misc/format-time-string.ts | 50 +++++++++++++++++++ .../api/endpoints/drive/files/create.ts | 10 +++- test/api.ts | 14 ++++++ 10 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 src/misc/format-time-string.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index bf23e4470..d2776c45b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -129,6 +129,7 @@ common: add-visible-user: "ユーザーを追加" cw-placeholder: "内容への注釈 (オプション)" username-prompt: "ユーザー名を入力してください" + enter-file-name: "ファイル名を編集" weekday-short: sunday: "日" @@ -201,6 +202,11 @@ common: remember-note-visibility: "投稿の公開範囲を記憶する" web-search-engine: "ウェブ検索エンジン" 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-desc: "投稿にリプライする際、リプライ元の投稿にCWが設定されていたとき、デフォルトで同じCWを設定するようにします。" i-like-sushi: "私は(プリンよりむしろ)寿司が好き" diff --git a/src/client/app/common/scripts/post-form.ts b/src/client/app/common/scripts/post-form.ts index 1d93b4c26..7cf26f65b 100644 --- a/src/client/app/common/scripts/post-form.ts +++ b/src/client/app/common/scripts/post-form.ts @@ -8,6 +8,7 @@ import { host, url } from '../../config'; import i18n from '../../i18n'; import { erase, unique } from '../../../../prelude/array'; import extractMentions from '../../../../misc/extract-mentions'; +import { formatTimeString } from '../../../../misc/format-time-string'; export default (opts) => ({ i18n: i18n(), @@ -244,8 +245,8 @@ export default (opts) => ({ for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); }, - upload(file) { - (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder); + upload(file: File, name?: string) { + (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); }, onChangeUploadings(uploads) { @@ -334,10 +335,23 @@ export default (opts) => ({ if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); }, - async onPaste(e) { - for (const item of Array.from(e.clipboardData.items)) { + async onPaste(e: ClipboardEvent) { + for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { 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); } } diff --git a/src/client/app/common/views/components/messaging-room.form.vue b/src/client/app/common/views/components/messaging-room.form.vue index 74e30d29e..bd63bab2c 100644 --- a/src/client/app/common/views/components/messaging-room.form.vue +++ b/src/client/app/common/views/components/messaging-room.form.vue @@ -30,6 +30,7 @@ import Vue from 'vue'; import i18n from '../../../i18n'; import * as autosize from 'autosize'; +import { formatTimeString } from '../../../../../misc/format-time-string'; export default Vue.extend({ i18n: i18n('common/views/components/messaging-room.form.vue'), @@ -84,13 +85,26 @@ export default Vue.extend({ } }, methods: { - onPaste(e) { + async onPaste(e: ClipboardEvent) { const data = e.clipboardData; const items = data.items; if (items.length == 1) { 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 { if (items[0].kind == 'file') { @@ -157,8 +171,8 @@ export default Vue.extend({ this.upload((this.$refs.file as any).files[0]); }, - upload(file) { - (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder); + upload(file: File, name?: string) { + (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); }, onUploaded(file) { diff --git a/src/client/app/common/views/components/settings/settings.vue b/src/client/app/common/views/components/settings/settings.vue index 5f370c8be..281524979 100644 --- a/src/client/app/common/views/components/settings/settings.vue +++ b/src/client/app/common/views/components/settings/settings.vue @@ -140,7 +140,19 @@
{{ $t('@._settings.web-search-engine') }}
- {{ $t('@._settings.web-search-engine') }} + {{ $t('@._settings.web-search-engine') }} + + +
+ +
+
{{ $t('@._settings.paste') }}
+ {{ $t('@._settings.pasted-file-name') }} + + + {{ $t('@._settings.paste-dialog') }} + +
@@ -412,6 +424,16 @@ export default Vue.extend({ 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: { get() { return this.$store.state.settings.showReplyTarget; }, set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } diff --git a/src/client/app/common/views/components/uploader.vue b/src/client/app/common/views/components/uploader.vue index 78fbcbf6b..9f02da6c1 100644 --- a/src/client/app/common/views/components/uploader.vue +++ b/src/client/app/common/views/components/uploader.vue @@ -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; const id = Math.random(); @@ -61,7 +61,7 @@ export default Vue.extend({ const ctx = { id: id, - name: file.name || 'untitled', + name: name || file.name || 'untitled', progress: undefined, img: window.URL.createObjectURL(file) }; @@ -75,6 +75,7 @@ export default Vue.extend({ data.append('file', file); if (folder) data.append('folderId', folder); + if (name) data.append('name', name); const xhr = new XMLHttpRequest(); xhr.open('POST', apiUrl + '/drive/files/create', true); diff --git a/src/client/app/common/views/widgets/post-form.vue b/src/client/app/common/views/widgets/post-form.vue index 5e577c9a4..6680a1143 100644 --- a/src/client/app/common/views/widgets/post-form.vue +++ b/src/client/app/common/views/widgets/post-form.vue @@ -38,6 +38,7 @@ import define from '../../../common/define-widget'; import i18n from '../../../i18n'; import insertTextAtCursor from 'insert-text-at-cursor'; +import { formatTimeString } from '../../../../../misc/format-time-string'; export default define({ 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(); }, - onPaste(e) { - for (const item of Array.from(e.clipboardData.items)) { + async onPaste(e: ClipboardEvent) { + for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { 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); }, - upload(file) { - (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder); + upload(file: File, name?: string) { + (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); }, onDragover(e) { diff --git a/src/client/app/store.ts b/src/client/app/store.ts index 252feb398..18137c1ca 100644 --- a/src/client/app/store.ts +++ b/src/client/app/store.ts @@ -39,6 +39,8 @@ const defaultSettings = { mobileHomeProfiles: {}, deckProfiles: {}, uploadFolder: null, + pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]', + pasteDialog: false, }; const defaultDeviceSettings = { diff --git a/src/misc/format-time-string.ts b/src/misc/format-time-string.ts new file mode 100644 index 000000000..4729036e5 --- /dev/null +++ b/src/misc/format-time-string.ts @@ -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; + }); +} diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts index 664a2b87b..61055c5d1 100644 --- a/src/server/api/endpoints/drive/files/create.ts +++ b/src/server/api/endpoints/drive/files/create.ts @@ -35,6 +35,14 @@ export const meta = { } }, + name: { + validator: $.optional.nullable.str, + default: null as any, + desc: { + 'ja-JP': 'ファイル名(拡張子があるなら含めて)' + } + }, + isSensitive: { validator: $.optional.either($.bool, $.str), default: false, @@ -72,7 +80,7 @@ export const meta = { export default define(meta, async (ps, user, app, file, cleanup) => { // Get 'name' parameter - let name = file.originalname; + let name = ps.name || file.originalname; if (name !== undefined && name !== null) { name = name.trim(); if (name.length === 0) { diff --git a/test/api.ts b/test/api.ts index 570ab6833..343112b4a 100644 --- a/test/api.ts +++ b/test/api.ts @@ -474,6 +474,20 @@ describe('API', () => { 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 () => { const alice = await signup({ username: 'alice' });