Add folder dropping, fix rate limits

- Add folder drag and drop support
- Do expodential retrying if rate limited, allowing upload of very large drive folders
- Do not import deleted files
- Move the upload buttons to their own line
- Notify when no TJA files have been found
- Add more translations
This commit is contained in:
LoveEevee 2020-11-04 03:12:46 +03:00
parent 180ec58adb
commit 5094b0bc70
11 changed files with 345 additions and 66 deletions

View File

@ -108,6 +108,14 @@ kbd{
.left-buttons .taibtn{ .left-buttons .taibtn{
margin-right: 0.4em; margin-right: 0.4em;
} }
.center-buttons{
display: flex;
justify-content: center;
margin: 1.5em 0;
}
.center-buttons .taibtn{
margin: 0 0.2em;
}
.diag-txt textarea, .diag-txt textarea,
.diag-txt iframe{ .diag-txt iframe{
width: 100%; width: 100%;
@ -217,7 +225,8 @@ kbd{
z-index: 1; z-index: 1;
} }
#settings-gamepad, #settings-gamepad,
#settings-latency{ #settings-latency,
#customsongs-error{
display: none; display: none;
} }
#settings-gamepad .view{ #settings-gamepad .view{
@ -289,7 +298,8 @@ kbd{
.latency-buttons span:active{ .latency-buttons span:active{
background-color: #946013; background-color: #946013;
} }
.left-buttons .taibtn{ .left-buttons .taibtn,
.center-buttons .taibtn{
z-index: 1; z-index: 1;
} }
.accountpass-form, .accountpass-form,
@ -403,3 +413,19 @@ kbd{
font-size: 1em; font-size: 1em;
padding: 0.2em; padding: 0.2em;
} }
#customsongs-error .view,
#dropzone .view{
width: 600px;
}
#dropzone{
pointer-events: none;
opacity: 0;
transition: opacity 0.5s;
}
#dropzone .view-content{
font-size: 2em;
text-align: center;
}
#dropzone.dragover{
opacity: 1;
}

View File

@ -38,9 +38,9 @@ class RemoteFile{
} }
} }
class LocalFile{ class LocalFile{
constructor(file){ constructor(file, path){
this.file = file this.file = file
this.path = file.webkitRelativePath this.path = path || file.webkitRelativePath
this.url = this.path this.url = this.path
this.name = file.name this.name = file.name
} }

View File

@ -165,7 +165,6 @@ class AutoScore {
GetMaxCombo() { GetMaxCombo() {
var combo = 0; var combo = 0;
for (var circle of this.circles) { for (var circle of this.circles) {
//alert(this.IsCommonCircle(circle));
if (this.IsCommonCircle(circle) && (!circle.branch || circle.branch.name === "master")) { if (this.IsCommonCircle(circle) && (!circle.branch || circle.branch.name === "master")) {
combo++; combo++;
} }

View File

@ -252,8 +252,8 @@ class Controller{
})) }))
} }
if(songObj.lyricsFile){ if(songObj.lyricsFile){
promises.push(songObj.lyricsFile.read().then(event => { promises.push(songObj.lyricsFile.read().then(result => {
songObj.lyricsData = event.target.result songObj.lyricsData = result
}, () => Promise.resolve()), songObj.lyricsFile.path) }, () => Promise.resolve()), songObj.lyricsFile.path)
} }
Promise.all(promises).then(resolve) Promise.all(promises).then(resolve)

View File

@ -6,6 +6,7 @@ class CustomSongs{
this.getElement("view-outer").classList.add("touch-enabled") this.getElement("view-outer").classList.add("touch-enabled")
} }
this.locked = false this.locked = false
this.mode = "main"
var tutorialTitle = this.getElement("view-title") var tutorialTitle = this.getElement("view-title")
this.setAltText(tutorialTitle, strings.customSongs.title) this.setAltText(tutorialTitle, strings.customSongs.title)
@ -40,21 +41,55 @@ class CustomSongs{
this.endButton = this.getElement("view-end-button") this.endButton = this.getElement("view-end-button")
this.setAltText(this.endButton, strings.session.cancel) this.setAltText(this.endButton, strings.session.cancel)
pageEvents.add(this.endButton, ["mousedown", "touchstart"], this.onEnd.bind(this)) pageEvents.add(this.endButton, ["mousedown", "touchstart"], event => this.onEnd(event, true))
this.items.push(this.endButton) this.items.push(this.endButton)
this.selected = this.items.length - 1 this.selected = this.items.length - 1
this.loaderDiv = document.createElement("div") this.loaderDiv = document.createElement("div")
this.loaderDiv.innerHTML = assets.pages["loadsong"] this.loaderDiv.innerHTML = assets.pages["loadsong"]
var loadingText = this.loaderDiv.querySelector("#loading-text") var loadingText = this.loaderDiv.querySelector("#loading-text")
loadingText.appendChild(document.createTextNode(strings.loading)) this.setAltText(loadingText, strings.loading)
loadingText.setAttribute("alt", strings.loading)
if(DataTransferItem.prototype.webkitGetAsEntry){
this.dropzone = document.getElementById("dropzone")
var dropContent = this.dropzone.getElementsByClassName("view-content")[0]
dropContent.innerText = strings.customSongs.dropzone
this.dragging = false
pageEvents.add(document, "dragover", event => {
event.preventDefault()
if(!this.locked){
event.dataTransfer.dropEffect = "copy"
this.dropzone.classList.add("dragover")
this.dragging = true
}else{
event.dataTransfer.dropEffect = "none"
}
})
pageEvents.add(document, "dragleave", () => {
this.dropzone.classList.remove("dragover")
this.dragging = false
})
pageEvents.add(document, "drop", this.filesDropped.bind(this))
}
this.errorDiv = document.getElementById("customsongs-error")
pageEvents.add(this.errorDiv, ["mousedown", "touchstart"], event => {
if(event.target === event.currentTarget){
this.hideError()
}
})
var errorTitle = this.errorDiv.getElementsByClassName("view-title")[0]
this.setAltText(errorTitle, strings.customSongs.importError)
this.errorContent = this.errorDiv.getElementsByClassName("view-content")[0]
this.errorEnd = this.errorDiv.getElementsByClassName("view-end-button")[0]
this.setAltText(this.errorEnd, strings.tutorial.ok)
pageEvents.add(this.errorEnd, ["mousedown", "touchstart"], () => this.hideError(true))
this.keyboard = new Keyboard({ this.keyboard = new Keyboard({
confirm: ["enter", "space", "don_l", "don_r"], confirm: ["enter", "space", "don_l", "don_r"],
previous: ["left", "up", "ka_l"], previous: ["left", "up", "ka_l"],
next: ["right", "down", "ka_r"], next: ["right", "down", "ka_r"],
back: ["escape"] backEsc: ["escape"]
}, this.keyPressed.bind(this)) }, this.keyPressed.bind(this))
this.gamepad = new Gamepad({ this.gamepad = new Gamepad({
confirmPad: ["b", "ls", "rs"], confirmPad: ["b", "ls", "rs"],
@ -73,9 +108,17 @@ class CustomSongs{
element.setAttribute("alt", text) element.setAttribute("alt", text)
} }
localFolder(){ localFolder(){
if(this.locked){ if(event){
if(event.type === "touchstart"){
event.preventDefault()
}else if(event.which !== 1){
return return
} }
}
if(this.locked || this.mode !== "main"){
return
}
this.changeSelected(this.linkLocalFolder)
this.browse.click() this.browse.click()
} }
browseChange(event){ browseChange(event){
@ -83,6 +126,47 @@ class CustomSongs{
for(var i = 0; i < event.target.files.length; i++){ for(var i = 0; i < event.target.files.length; i++){
files.push(new LocalFile(event.target.files[i])) files.push(new LocalFile(event.target.files[i]))
} }
this.importLocal(files)
}
filesDropped(event){
event.preventDefault()
this.dropzone.classList.remove("dragover")
this.dragging = false
if(this.locked){
return
}
var files = []
var walk = (entry, path="") => {
return new Promise(resolve => {
if(entry.isFile){
entry.file(file => {
files.push(new LocalFile(file, path + file.name))
return resolve()
}, resolve)
}else if(entry.isDirectory){
var dirReader = entry.createReader()
dirReader.readEntries(entries => {
var dirPromises = []
for(var i = 0; i < entries.length; i++){
dirPromises.push(walk(entries[i], path + entry.name + "/"))
}
return Promise.all(dirPromises).then(resolve)
}, resolve)
}else{
return resolve()
}
})
}
var dropPromises = []
for(var i = 0; i < event.dataTransfer.items.length; i++){
var entry = event.dataTransfer.items[i].webkitGetAsEntry()
if(entry){
dropPromises.push(walk(entry))
}
}
Promise.all(dropPromises).then(() => this.importLocal(files))
}
importLocal(files){
if(!files.length){ if(!files.length){
return return
} }
@ -94,15 +178,25 @@ class CustomSongs{
this.browse.parentNode.reset() this.browse.parentNode.reset()
this.locked = false this.locked = false
this.loading(false) this.loading(false)
if(e !== "cancel"){ if(e === "nosongs"){
this.showError(strings.customSongs.noSongs)
}else if(e !== "cancel"){
return Promise.reject(e) return Promise.reject(e)
} }
}) })
} }
gdriveFolder(){ gdriveFolder(event){
if(this.locked){ if(event){
if(event.type === "touchstart"){
event.preventDefault()
}else if(event.which !== 1){
return return
} }
}
if(this.locked || this.mode !== "main"){
return
}
this.changeSelected(this.linkGdriveFolder)
this.locked = true this.locked = true
this.loading(true) this.loading(true)
var importSongs = new ImportSongs(true) var importSongs = new ImportSongs(true)
@ -117,13 +211,17 @@ class CustomSongs{
return gpicker.browse(locked => { return gpicker.browse(locked => {
this.locked = locked this.locked = locked
this.loading(locked) this.loading(locked)
}, error => {
this.showError(error)
}) })
}).then(files => importSongs.load(files)) }).then(files => importSongs.load(files))
.then(this.songsLoaded.bind(this)) .then(this.songsLoaded.bind(this))
.catch(e => { .catch(e => {
this.locked = false this.locked = false
this.loading(false) this.loading(false)
if(e !== "cancel"){ if(e === "nosongs"){
this.showError(strings.customSongs.noSongs)
}else if(e !== "cancel"){
return Promise.reject(e) return Promise.reject(e)
} }
}) })
@ -154,9 +252,10 @@ class CustomSongs{
return return
} }
var selected = this.items[this.selected] var selected = this.items[this.selected]
if(this.mode === "main"){
if(name === "confirm" || name === "confirmPad"){ if(name === "confirm" || name === "confirmPad"){
if(selected === this.endButton){ if(selected === this.endButton){
this.onEnd() this.onEnd(null, true)
}else if(name !== "confirmPad"){ }else if(name !== "confirmPad"){
if(selected === this.linkLocalFolder){ if(selected === this.linkLocalFolder){
assets.sounds["se_don"].play() assets.sounds["se_don"].play()
@ -171,15 +270,30 @@ class CustomSongs{
this.selected = this.mod(this.items.length, this.selected + (name === "next" ? 1 : -1)) this.selected = this.mod(this.items.length, this.selected + (name === "next" ? 1 : -1))
this.items[this.selected].classList.add("selected") this.items[this.selected].classList.add("selected")
assets.sounds["se_ka"].play() assets.sounds["se_ka"].play()
}else if(name === "back"){ }else if(name === "back" || name === "backEsc"){
if(!this.dragging || name !== "backEsc"){
this.onEnd() this.onEnd()
} }
} }
}else if(this.mode === "error"){
if(name === "confirm" || name === "confirmPad" || name === "back" || name === "backEsc"){
this.hideError(name === "confirm" || name === "confirmPad")
}
}
}
changeSelected(button){
var selected = this.items[this.selected]
if(selected !== button){
selected.classList.remove("selected")
this.selected = this.items.findIndex(item => item === button)
this.items[this.selected].classList.add("selected")
}
}
mod(length, index){ mod(length, index){
return ((index % length) + length) % length return ((index % length) + length) % length
} }
onEnd(event){ onEnd(event, confirm){
if(this.locked){ if(this.locked || this.mode !== "main"){
return return
} }
var touched = false var touched = false
@ -190,13 +304,32 @@ class CustomSongs{
}else if(event.which !== 1){ }else if(event.which !== 1){
return return
} }
}else{
touched = this.touchEnabled
} }
this.clean() this.clean()
assets.sounds["se_don"].play() assets.sounds[confirm ? "se_don" : "se_cancel"].play()
setTimeout(() => { setTimeout(() => {
new SongSelect("customSongs", false, touched) new SongSelect("customSongs", false, touched)
}, 500) }, 500)
} }
showError(text){
if(this.mode === "error"){
return
}
this.mode = "error"
this.errorContent.innerText = text
this.errorDiv.style.display = "flex"
assets.sounds["se_pause"].play()
}
hideError(confirm){
if(this.mode !== "error"){
return
}
this.mode = "main"
this.errorDiv.style.display = ""
assets.sounds[confirm ? "se_don" : "se_cancel"].play()
}
clean(){ clean(){
this.keyboard.clean() this.keyboard.clean()
this.gamepad.clean() this.gamepad.clean()
@ -208,11 +341,20 @@ class CustomSongs{
pageEvents.remove(this.linkGdriveFolder, ["mousedown", "touchstart"]) pageEvents.remove(this.linkGdriveFolder, ["mousedown", "touchstart"])
} }
pageEvents.remove(this.endButton, ["mousedown", "touchstart"]) pageEvents.remove(this.endButton, ["mousedown", "touchstart"])
pageEvents.remove(this.errorDiv, ["mousedown", "touchstart"])
pageEvents.remove(this.errorEnd, ["mousedown", "touchstart"])
if(DataTransferItem.prototype.webkitGetAsEntry){
pageEvents.remove(document, ["dragover", "dragleave", "drop"])
delete this.dropzone
}
delete this.browse delete this.browse
delete this.linkLocalFolder delete this.linkLocalFolder
delete this.linkGdriveFolder delete this.linkGdriveFolder
delete this.endButton delete this.endButton
delete this.items delete this.items
delete this.loaderDiv delete this.loaderDiv
delete this.errorDiv
delete this.errorContent
delete this.errorEnd
} }
} }

View File

@ -9,15 +9,17 @@ class Gpicker{
this.resolveQueue = [] this.resolveQueue = []
this.queueActive = false this.queueActive = false
} }
browse(lockedCallback){ browse(lockedCallback, errorCallback){
return this.loadApi() return this.loadApi()
.then(() => this.getToken(lockedCallback)) .then(() => this.getToken(lockedCallback, errorCallback))
.then(() => new Promise((resolve, reject) => { .then(() => new Promise((resolve, reject) => {
this.displayPicker(data => { this.displayPicker(data => {
if(data.action === "picked"){ if(data.action === "picked"){
var file = data.docs[0] var file = data.docs[0]
var folders = []
var rateLimit = -1
var lastBatch = 0
var walk = (files, output=[]) => { var walk = (files, output=[]) => {
var batch = null
for(var i = 0; i < files.length; i++){ for(var i = 0; i < files.length; i++){
var path = files[i].path ? files[i].path + "/" : "" var path = files[i].path ? files[i].path + "/" : ""
var list = files[i].list var list = files[i].list
@ -27,14 +29,9 @@ class Gpicker{
for(var j = 0; j < list.length; j++){ for(var j = 0; j < list.length; j++){
var file = list[j] var file = list[j]
if(file.mimeType === this.folder){ if(file.mimeType === this.folder){
if(!batch){ folders.push({
batch = gapi.client.newBatch() path: path + file.name,
} id: file.id
batch.add(gapi.client.drive.files.list({
q: "'" + file.id + "' in parents",
orderBy: "name_natural"
}), {
id: path + file.name
}) })
}else{ }else{
output.push(new GdriveFile({ output.push(new GdriveFile({
@ -45,14 +42,64 @@ class Gpicker{
} }
} }
} }
if(batch){ var batchList = []
return this.queue() for(var i = 0; i < folders.length && batchList.length < 100; i++){
.then(() => batch.then(responses => { if(!folders[i].listed){
var files = [] folders[i].pos = i
for(var path in responses.result){ folders[i].listed = true
files.push({path: path, list: responses.result[path].result.files}) batchList.push(folders[i])
} }
}
if(batchList.length){
var batch = gapi.client.newBatch()
batchList.forEach(folder => {
var req = {
q: "'" + folder.id + "' in parents and trashed = false",
orderBy: "name_natural"
}
if(folder.pageToken){
req.pageToken = folder.pageToken
}
batch.add(gapi.client.drive.files.list(req), {id: folder.pos})
})
if(lastBatch + batchList.length > 100){
var waitPromise = this.sleep(1000)
}else{
var waitPromise = Promise.resolve()
}
return waitPromise.then(() => this.queue()).then(() => batch.then(responses => {
var files = []
var rateLimited = false
for(var i in responses.result){
var result = responses.result[i].result
if(result.error){
if(result.error.errors[0].domain !== "usageLimits"){
console.warn(result)
}else if(!rateLimited){
rateLimited = true
rateLimit++
folders.push({
path: folders[i].path,
id: folders[i].id,
pageToken: folders[i].pageToken
})
}
}else{
if(result.nextPageToken){
folders.push({
path: folders[i].path,
id: folders[i].id,
pageToken: result.nextPageToken
})
}
files.push({path: folders[i].path, list: result.files})
}
}
if(rateLimited){
return this.sleep(Math.pow(2, rateLimit) * 1000).then(() => walk(files, output))
}else{
return walk(files, output) return walk(files, output)
}
})) }))
}else{ }else{
return output return output
@ -84,7 +131,7 @@ class Gpicker{
gapi.client.load("drive", "v3").then(resolve, reject) gapi.client.load("drive", "v3").then(resolve, reject)
)) ))
} }
getToken(lockedCallback=()=>{}){ getToken(lockedCallback=()=>{}, errorCallback=()=>{}){
if(this.oauthToken){ if(this.oauthToken){
return Promise.resolve() return Promise.resolve()
} }
@ -97,7 +144,7 @@ class Gpicker{
this.auth = gapi.auth2.getAuthInstance() this.auth = gapi.auth2.getAuthInstance()
}, e => { }, e => {
if(e.details){ if(e.details){
alert(strings.gpicker.authError.replace("%s", e.details)) errorCallback(strings.gpicker.authError.replace("%s", e.details))
} }
return Promise.reject(e) return Promise.reject(e)
}) })
@ -132,6 +179,7 @@ class Gpicker{
.setDeveloperKey(this.apiKey) .setDeveloperKey(this.apiKey)
.setAppId(this.projectNumber) .setAppId(this.projectNumber)
.setOAuthToken(this.oauthToken) .setOAuthToken(this.oauthToken)
.setLocale(strings.gpicker.locale)
.hideTitleBar() .hideTitleBar()
.addView(new picker.DocsView("folders") .addView(new picker.DocsView("folders")
.setLabel(strings.gpicker.myDrive) .setLabel(strings.gpicker.myDrive)
@ -184,6 +232,9 @@ class Gpicker{
}) })
) )
} }
sleep(time){
return new Promise(resolve => setTimeout(resolve, time))
}
queue(){ queue(){
return new Promise(resolve => { return new Promise(resolve => {
this.resolveQueue.push(resolve) this.resolveQueue.push(resolve)

View File

@ -417,10 +417,13 @@
} }
})) }))
image.id = name image.id = name
image.src = URL.createObjectURL(file.blob()) promises.push(file.blob().then(blob => {
image.src = URL.createObjectURL(blob)
}))
loader.assetsDiv.appendChild(image) loader.assetsDiv.appendChild(image)
var oldImage = assets.image[id] var oldImage = assets.image[id]
if(oldImage && oldImage.parentNode){ if(oldImage && oldImage.parentNode){
URL.revokeObjectURL(oldImage.src)
oldImage.parentNode.removeChild(oldImage) oldImage.parentNode.removeChild(oldImage)
} }
assets.image[id] = image assets.image[id] = image
@ -543,7 +546,7 @@
}else if(Object.keys(this.assetFiles).length){ }else if(Object.keys(this.assetFiles).length){
return Promise.resolve() return Promise.resolve()
}else{ }else{
return Promise.reject("cancel") return Promise.reject("nosongs")
} }
this.clean() this.clean()
} }

View File

@ -586,7 +586,7 @@ class SongSelect{
}) })
} }
}else if(this.state.locked !== 1 || fromP2){ }else if(this.state.locked !== 1 || fromP2){
if(this.songs[this.selectedSong].courses && (this.state.locked === 0 || fromP2)){ if(this.songs[this.selectedSong].courses && !this.songs[this.selectedSong].unloaded && (this.state.locked === 0 || fromP2)){
this.state.moveMS = ms this.state.moveMS = ms
}else{ }else{
this.state.moveMS = ms - this.songSelecting.speed * this.songSelecting.resize this.state.moveMS = ms - this.songSelecting.speed * this.songSelecting.resize
@ -2222,7 +2222,7 @@ class SongSelect{
] ]
this.draw.layeredText({ this.draw.layeredText({
ctx: ctx, ctx: ctx,
text: strings.ok, text: strings.tutorial.ok,
x: _x, x: _x,
y: _y + 18, y: _y + 18,
width: _w, width: _w,

View File

@ -1060,7 +1060,11 @@ var translations = {
}, },
customSongs: { customSongs: {
title: { title: {
ja: "カスタム曲リスト",
en: "Custom Song List", en: "Custom Song List",
cn: "自定义歌曲列表",
tw: "自定義歌曲列表",
ko: "맞춤 노래 목록"
}, },
default: { default: {
ja: "デフォルト曲リスト", ja: "デフォルト曲リスト",
@ -1075,21 +1079,61 @@ var translations = {
] ]
}, },
localFolder: { localFolder: {
en: "Local Folder..." ja: "ローカルフォルダ...",
en: "Local Folder...",
cn: "本地文件夹...",
tw: "本地文件夾...",
ko: "로컬 폴더..."
}, },
gdriveFolder: { gdriveFolder: {
en: "Google Drive..." ja: "Google ドライブ...",
en: "Google Drive...",
cn: "Google云端硬盘...",
tw: "Google雲端硬碟...",
ko: "구글 드라이브..."
},
dropzone: {
ja: "ここにファイルをドロップ",
en: "Drop files here",
cn: "将文件拖至此处",
tw: "將文件拖至此處",
ko: "파일을 여기에 드롭"
},
importError: {
en: "Import Error"
},
noSongs: {
en: "No Taiko chart files have been found in the provided folder."
} }
}, },
gpicker: { gpicker: {
locale: {
ja: "ja",
en: "en-GB",
cn: "zh-CN",
tw: "zh-TW",
ko: "ko"
},
myDrive: { myDrive: {
en: "My Drive" ja: "マイドライブ",
en: "My Drive",
cn: "我的云端硬盘",
tw: "我的雲端硬碟",
ko: "내 드라이브"
}, },
starred: { starred: {
en: "Starred" ja: "スター付き",
en: "Starred",
cn: "已加星标",
tw: "已加星號",
ko: "중요 문서함"
}, },
sharedWithMe: { sharedWithMe: {
en: "Shared with me" ja: "共有アイテム",
en: "Shared with me",
cn: "与我共享",
tw: "與我共用",
ko: "공유 문서함"
}, },
authError: { authError: {
en: "Auth error: %s" en: "Auth error: %s"

View File

@ -175,7 +175,9 @@ class ViewAssets{
}) })
} }
clean(){ clean(){
if(this.don){
this.don.clean() this.don.clean()
}
delete this.ctx delete this.ctx
delete this.don delete this.don
delete this.fire delete this.fire

View File

@ -1,12 +1,24 @@
<div class="view-outer"> <div class="view-outer">
<div class="view"> <div class="view drag-bg">
<div class="view-title stroke-sub"></div> <div class="view-title stroke-sub"></div>
<div class="view-content"></div> <div class="view-content"></div>
<div class="left-buttons"> <div class="center-buttons">
<div id="link-localfolder" class="taibtn stroke-sub link-btn"></div> <div id="link-localfolder" class="taibtn stroke-sub link-btn"></div>
<div id="link-gdrivefolder" class="taibtn stroke-sub link-btn"></div> <div id="link-gdrivefolder" class="taibtn stroke-sub link-btn"></div>
</div> </div>
<div class="view-end-button taibtn stroke-sub selected"></div> <div class="view-end-button taibtn stroke-sub selected"></div>
<div class="view-outer shadow-outer" id="dropzone">
<div class="view">
<div class="view-content"></div>
</div>
</div>
<div class="view-outer shadow-outer" id="customsongs-error">
<div class="view">
<div class="view-title stroke-sub"></div>
<div class="view-content"></div>
<div class="view-end-button taibtn stroke-sub selected"></div>
</div>
</div>
</div> </div>
<form><input id="browse" type="file" webkitdirectory multiple></form> <form><input id="browse" type="file" webkitdirectory multiple></form>
</div> </div>