* 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:
		
							parent
							
								
									eb783f827c
								
							
						
					
					
						commit
						5343b005df
					
				
					 10 changed files with 163 additions and 18 deletions
				
			
		|  | @ -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: "私は(プリンよりむしろ)寿司が好き" | ||||||
|  |  | ||||||
|  | @ -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); | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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) { | ||||||
|  |  | ||||||
|  | @ -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 }); } | ||||||
|  |  | ||||||
|  | @ -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); | ||||||
|  |  | ||||||
|  | @ -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) { | ||||||
|  |  | ||||||
|  | @ -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 = { | ||||||
|  |  | ||||||
							
								
								
									
										50
									
								
								src/misc/format-time-string.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/misc/format-time-string.ts
									
										
									
									
									
										Normal 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; | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | @ -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) { | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								test/api.ts
									
										
									
									
									
								
							
							
						
						
									
										14
									
								
								test/api.ts
									
										
									
									
									
								
							|  | @ -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' }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue