feat: supports importing hosts from CSV (#291)

This commit is contained in:
hstyi
2025-02-22 12:23:31 +08:00
committed by GitHub
parent 1f392c52a1
commit adabaf8f2d
16 changed files with 225 additions and 54 deletions

View File

@@ -17,7 +17,11 @@ class HostManager private constructor() {
fun addHost(host: Host) {
assertEventDispatchThread()
database.addHost(host)
setHost(host)
if (host.deleted) {
hosts.entries.removeIf { it.value.id == host.id || it.value.parentId == host.id }
} else {
hosts[host.id] = host
}
}
/**
@@ -39,12 +43,4 @@ class HostManager private constructor() {
return hosts[id]
}
/**
* 仅修改缓存中的
*/
fun setHost(host: Host) {
assertEventDispatchThread()
hosts[host.id] = host
}
}

View File

@@ -1,18 +1,26 @@
package app.termora
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.TreeNode
class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
companion object {
private val hostManager get() = HostManager.getInstance()
}
/**
* 如果要重新赋值,记得修改 [Host.updateDate] 否则下次取出时可能时缓存的
*/
var host: Host
get() = hostManager.getHost((userObject as Host).id) ?: userObject as Host
set(value) {
setUserObject(value)
hostManager.setHost(value)
get() {
val cacheHost = hostManager.getHost((userObject as Host).id)
val myHost = userObject as Host
if (cacheHost == null) {
return myHost
}
return if (cacheHost.updateDate > myHost.updateDate) cacheHost else myHost
}
set(value) = setUserObject(value)
val folderCount
get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false }
@@ -32,6 +40,29 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
return children
}
fun childrenNode(): List<HostTreeNode> {
return children?.map { it as HostTreeNode } ?: emptyList()
}
/**
* 深度克隆
* @param scopes 克隆的范围
*/
fun clone(scopes: Set<Protocol> = emptySet()): HostTreeNode {
val newNode = clone() as HostTreeNode
deepClone(newNode, this, scopes)
return newNode
}
private fun deepClone(newNode: HostTreeNode, oldNode: HostTreeNode, scopes: Set<Protocol> = emptySet()) {
for (child in oldNode.childrenNode()) {
if (scopes.isNotEmpty() && !scopes.contains(child.host.protocol)) continue
val newChildNode = child.clone() as HostTreeNode
deepClone(newChildNode, child, scopes)
newNode.add(newChildNode)
}
}
override fun clone(): Any {
val newNode = HostTreeNode(host)
@@ -40,6 +71,17 @@ class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
return newNode
}
override fun isNodeChild(aNode: TreeNode?): Boolean {
if (aNode is HostTreeNode) {
for (node in childrenNode()) {
if (node.host == aNode.host) {
return true
}
}
}
return super.isNodeChild(aNode)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

View File

@@ -11,12 +11,16 @@ import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVParser
import org.apache.commons.csv.CSVPrinter
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.jdesktop.swingx.JXTree
import org.jdesktop.swingx.action.ActionManager
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
import org.slf4j.LoggerFactory
import java.awt.Component
import java.awt.Dimension
import java.awt.datatransfer.DataFlavor
@@ -26,7 +30,7 @@ import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.io.File
import java.io.*
import java.util.*
import java.util.function.Function
import javax.swing.*
@@ -41,6 +45,11 @@ import kotlin.math.min
class NewHostTree : JXTree() {
companion object {
private val log = LoggerFactory.getLogger(NewHostTree::class.java)
private val CSV_HEADERS = arrayOf("Folders", "Label", "Hostname", "Port", "Username", "Protocol")
}
private val tree = this
private val editor = OutlineTextField(64)
private val hostManager get() = HostManager.getInstance()
@@ -207,7 +216,7 @@ class NewHostTree : JXTree() {
if (lastHost !is HostTreeNode || editor.text.isBlank() || editor.text == lastHost.host.name) {
return
}
lastHost.host = lastHost.host.copy(name = editor.text)
lastHost.host = lastHost.host.copy(name = editor.text, updateDate = System.currentTimeMillis())
hostManager.addHost(lastHost.host)
}
@@ -298,7 +307,7 @@ class NewHostTree : JXTree() {
// 转移
for (e in nodes) {
model.removeNodeFromParent(e)
e.host = e.host.copy(parentId = node.host.id)
e.host = e.host.copy(parentId = node.host.id, updateDate = System.currentTimeMillis())
hostManager.addHost(e.host)
if (dropLocation.childIndex == -1) {
@@ -347,6 +356,7 @@ class NewHostTree : JXTree() {
val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder"))
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host"))
val importMenu = JMenu(I18n.getString("termora.welcome.contextmenu.import"))
val csvMenu = importMenu.add("CSV")
val windTermMenu = importMenu.add("WindTerm")
val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect"))
@@ -375,6 +385,9 @@ class NewHostTree : JXTree() {
popupMenu.add(showMoreInfo)
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
csvMenu.addActionListener {
importHosts(lastNode, ImportType.CSV)
}
windTermMenu.addActionListener { importHosts(lastNode, ImportType.WindTerm) }
open.addActionListener { openHosts(it, false) }
openInNewWindow.addActionListener { openHosts(it, true) }
@@ -604,6 +617,17 @@ class NewHostTree : JXTree() {
}
private fun importHosts(folder: HostTreeNode, type: ImportType) {
try {
doImportHosts(folder, type)
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
OptionPane.showMessageDialog(owner, ExceptionUtils.getMessage(e), messageType = JOptionPane.ERROR_MESSAGE)
}
}
private fun doImportHosts(folder: HostTreeNode, type: ImportType) {
val chooser = JFileChooser()
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
chooser.isAcceptAllFileFilterUsed = false
@@ -611,6 +635,8 @@ class NewHostTree : JXTree() {
if (type == ImportType.WindTerm) {
chooser.fileFilter = FileNameExtensionFilter("WindTerm(*.sessions)", "sessions")
} else if (type == ImportType.CSV) {
chooser.fileFilter = FileNameExtensionFilter("CSV(*.csv)", "csv")
}
val dir = properties.getString("NewHostTree.ImportHosts.defaultDir", StringUtils.EMPTY)
@@ -621,7 +647,47 @@ class NewHostTree : JXTree() {
}
}
// csv template
if (type == ImportType.CSV) {
val code = OptionPane.showConfirmDialog(
owner,
I18n.getString("termora.welcome.contextmenu.import.csv.download-template"),
optionType = JOptionPane.YES_NO_OPTION,
messageType = JOptionPane.QUESTION_MESSAGE,
options = arrayOf(
I18n.getString("termora.welcome.contextmenu.import"),
I18n.getString("termora.welcome.contextmenu.download")
),
initialValue = I18n.getString("termora.welcome.contextmenu.import")
)
if (code == JOptionPane.DEFAULT_OPTION) {
return
} else if (code != JOptionPane.YES_OPTION) {
chooser.setSelectedFile(File("termora_import.csv"))
if (chooser.showSaveDialog(owner) == JFileChooser.APPROVE_OPTION) {
CSVPrinter(
FileWriter(chooser.selectedFile, Charsets.UTF_8),
CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()
).use { printer ->
printer.printRecord("Projects/Dev", "Web Server", "192.168.1.1", "22", "root", "SSH")
printer.printRecord("Projects/Prod", "Web Server", "serverhost.com", "2222", "root", "SSH")
printer.printRecord(StringUtils.EMPTY, "Web Server", "serverhost.com", "2222", "user", "SSH")
}
OptionPane.openFileInFolder(
owner,
chooser.selectedFile,
I18n.getString("termora.welcome.contextmenu.import.csv.download-template-done-open-folder"),
I18n.getString("termora.welcome.contextmenu.import.csv.download-template-done")
)
}
return
}
}
// 选择文件
val code = chooser.showOpenDialog(owner)
// 记住目录
properties.putString("NewHostTree.ImportHosts.defaultDir", chooser.currentDirectory.absolutePath)
if (code != JFileChooser.APPROVE_OPTION) {
@@ -630,15 +696,16 @@ class NewHostTree : JXTree() {
val file = chooser.selectedFile
val nodes = if (type == ImportType.WindTerm) {
parseFromWindTerm(file)
} else {
emptyList()
val nodes = when (type) {
ImportType.WindTerm -> parseFromWindTerm(folder, file)
ImportType.CSV -> file.bufferedReader().use { parseFromCSV(folder, it) }
}
for (node in nodes) {
node.host = node.host.copy(parentId = folder.host.id)
node.host = node.host.copy(parentId = folder.host.id, updateDate = System.currentTimeMillis())
if (folder.getIndex(node) != -1) {
continue
}
model.insertNodeInto(
node,
folder,
@@ -650,36 +717,73 @@ class NewHostTree : JXTree() {
hostManager.addHost(node.host)
node.getAllChildren().forEach { hostManager.addHost(it.host) }
}
// 重新加载
model.reload(folder)
}
private fun parseFromWindTerm(file: File): List<HostTreeNode> {
private fun parseFromWindTerm(folder: HostTreeNode, file: File): List<HostTreeNode> {
val sessions = ohMyJson.runCatching { ohMyJson.parseToJsonElement(file.readText()).jsonArray }
.onFailure { OptionPane.showMessageDialog(owner, ExceptionUtils.getMessage(it)) }
.getOrNull() ?: return emptyList()
val nodes = mutableListOf<HostTreeNode>()
for (i in 0 until sessions.size) {
val json = sessions[i].jsonObject
val protocol = json["session.protocol"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
if (protocol != "SSH") continue
val label = json["session.label"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val target = json["session.target"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val port = json["session.port"]?.jsonPrimitive?.intOrNull ?: 22
val group = json["session.group"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val groups = group.split(">")
val sw = StringWriter()
CSVPrinter(sw, CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).get()).use { printer ->
for (i in 0 until sessions.size) {
val json = sessions[i].jsonObject
val protocol = json["session.protocol"]?.jsonPrimitive?.content ?: "SSH"
if (!StringUtils.equalsIgnoreCase("SSH", protocol)) continue
val label = json["session.label"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val target = json["session.target"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val port = json["session.port"]?.jsonPrimitive?.intOrNull ?: 22
val group = json["session.group"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
val groups = group.split(">")
printer.printRecord(groups.joinToString("/"), label, target, port, StringUtils.EMPTY, "SSH")
}
}
return parseFromCSV(folder, StringReader(sw.toString()))
}
private fun parseFromCSV(folderNode: HostTreeNode, sr: Reader): List<HostTreeNode> {
val records = CSVParser.builder()
.setFormat(CSVFormat.EXCEL.builder().setHeader(*CSV_HEADERS).setSkipHeaderRecord(true).get())
.setCharset(Charsets.UTF_8)
.setReader(sr)
.get()
.use { it.records }
// 把现有目录提取出来,避免重复创建
val nodes = folderNode.clone(setOf(Protocol.Folder))
.childrenNode().filter { it.host.protocol == Protocol.Folder }
.toMutableList()
for (record in records) {
val map = mutableMapOf<String, String>()
for (e in record.parser.headerMap.keys) {
map[e] = record.get(e)
}
val folder = map["Folders"] ?: StringUtils.EMPTY
val label = map["Label"] ?: StringUtils.EMPTY
val hostname = map["Hostname"] ?: StringUtils.EMPTY
val port = map["Port"]?.toIntOrNull() ?: 22
val username = map["Username"] ?: StringUtils.EMPTY
val protocol = map["Protocol"] ?: "SSH"
if (!StringUtils.equalsIgnoreCase(protocol, "SSH")) continue
if (StringUtils.isAllBlank(hostname, label)) continue
var p: HostTreeNode? = null
if (group.isNotBlank()) {
for (j in groups.indices) {
if (folder.isNotBlank()) {
for ((j, name) in folder.split("/").withIndex()) {
val folders = if (j == 0 || p == null) nodes
else p.children().toList().filterIsInstance<HostTreeNode>()
val n = HostTreeNode(
Host(
name = groups[j], protocol = Protocol.Folder,
name = name, protocol = Protocol.Folder,
parentId = p?.host?.id ?: StringUtils.EMPTY
)
)
val cp = folders.find { it.host.protocol == Protocol.Folder && it.host.name == groups[j] }
val cp = folders.find { it.host.protocol == Protocol.Folder && it.host.name == name }
if (cp != null) {
p = cp
continue
@@ -696,9 +800,10 @@ class NewHostTree : JXTree() {
val n = HostTreeNode(
Host(
name = StringUtils.defaultIfBlank(label, target),
host = target,
name = StringUtils.defaultIfBlank(label, hostname),
host = hostname,
port = port,
username = username,
protocol = Protocol.SSH,
parentId = p?.host?.id ?: StringUtils.EMPTY,
)
@@ -716,7 +821,8 @@ class NewHostTree : JXTree() {
private enum class ImportType {
WindTerm
WindTerm,
CSV,
}
private class MoveHostTransferable(val nodes: List<HostTreeNode>) : Transferable {

View File

@@ -73,7 +73,7 @@ class NewHostTreeModel : DefaultTreeModel(
for ((i, c) in parent.children().toList().filterIsInstance<HostTreeNode>().withIndex()) {
val sort = i.toLong()
if (c.host.sort == sort) continue
c.host = c.host.copy(sort = sort)
c.host = c.host.copy(sort = sort, updateDate = System.currentTimeMillis())
hostManager.addHost(c.host)
}
}

View File

@@ -50,6 +50,7 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
// 如果配置了跳板机或者代理,那么通过 SSH 的端口转发到本地
if (useJumpHosts) {
host = host.copy(
updateDate = System.currentTimeMillis(),
tunnelings = listOf(
Tunneling(
type = TunnelingType.Local,
@@ -66,7 +67,11 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
// 打开通道
for (tunneling in host.tunnelings) {
val address = SshClients.openTunneling(sshSession, host, tunneling)
host = host.copy(host = address.hostName, port = address.port)
host = host.copy(
host = address.hostName,
port = address.port,
updateDate = System.currentTimeMillis(),
)
}
}
@@ -128,9 +133,9 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal
private fun setAuthentication(commands: MutableList<String>, host: Host) {
// 如果通过公钥连接
if (host.authentication.type == AuthenticationType.PublicKey) {
val keyPair = keyManager.getOhKeyPair(host.authentication.password)
if (keyPair != null) {
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(keyPair)
val ohKeyPair = keyManager.getOhKeyPair(host.authentication.password)
if (ohKeyPair != null) {
val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair)
val privateKeyPath = Application.createSubTemporaryDir()
val privateKeyFile = Files.createTempFile(privateKeyPath, Application.getName(), StringUtils.EMPTY)
Files.newOutputStream(privateKeyFile)

View File

@@ -89,7 +89,8 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
terminal.write("SSH client is opening...\r\n")
}
var host = this.host.copy(authentication = this.host.authentication.copy())
var host =
this.host.copy(authentication = this.host.authentication.copy(), updateDate = System.currentTimeMillis())
val owner = SwingUtilities.getWindowAncestor(terminalPanel)
val client = SshClients.openClient(host).also { sshClient = it }
client.serverKeyVerifier = DialogServerKeyVerifier(owner)
@@ -103,13 +104,14 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
host = host.copy(
authentication = authentication,
username = dialog.getUsername(),
updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(
tab.host.copy(
authentication = authentication,
username = dialog.getUsername(),
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}

View File

@@ -151,7 +151,7 @@ object SshClients {
log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}")
}
// 映射完毕之后修改Host和端口
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port)
jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port, updateDate = System.currentTimeMillis())
}
}

View File

@@ -356,7 +356,7 @@ class TerminalTabbed(
}
host = host.copy(
protocol = Protocol.SFTPPty,
protocol = Protocol.SFTPPty, updateDate = System.currentTimeMillis(),
options = host.options.copy(env = envs.toPropertiesString())
)
}

View File

@@ -14,7 +14,7 @@ class SFTPAction : AnAction("SFTP", Icons.folder) {
val tab = openOrCreateSFTPTerminalTab(evt) ?: return
if (host != null) {
connectHost(host.copy(protocol = Protocol.SSH), tab)
connectHost(host.copy(protocol = Protocol.SSH, updateDate = System.currentTimeMillis()), tab)
}
}

View File

@@ -107,7 +107,7 @@ class SftpFileSystemPanel(
private suspend fun doConnect() {
val thisHost = this.host ?: return
var host = thisHost.copy(authentication = thisHost.authentication.copy())
var host = thisHost.copy(authentication = thisHost.authentication.copy(), updateDate = System.currentTimeMillis())
closeIO()
@@ -123,14 +123,14 @@ class SftpFileSystemPanel(
val authentication = dialog.getAuthentication()
host = host.copy(
authentication = authentication,
username = dialog.getUsername(),
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
// save
if (dialog.isRemembered()) {
HostManager.getInstance().addHost(
host.copy(
authentication = authentication,
username = dialog.getUsername(),
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}

View File

@@ -144,6 +144,10 @@ termora.welcome.contextmenu.new.host=Host
termora.welcome.contextmenu.new.folder.name=New Folder
termora.welcome.contextmenu.property=Properties
termora.welcome.contextmenu.show-more-info=Show more info
termora.welcome.contextmenu.download=Download
termora.welcome.contextmenu.import.csv.download-template=Do you want to import or download the template?
termora.welcome.contextmenu.import.csv.download-template-done=Download the template successfully
termora.welcome.contextmenu.import.csv.download-template-done-open-folder=Download the template successfully, Do you want to open the folder?
# New Host
termora.new-host.title=Create a new host

View File

@@ -132,6 +132,11 @@ termora.welcome.contextmenu.new.folder.name=新建文件夹
termora.welcome.contextmenu.property=属性
termora.welcome.contextmenu.show-more-info=显示更多信息
termora.welcome.contextmenu.download=下载
termora.welcome.contextmenu.import.csv.download-template=您要导入还是下载模板?
termora.welcome.contextmenu.import.csv.download-template-done=下载成功
termora.welcome.contextmenu.import.csv.download-template-done-open-folder=下载成功, 是否需要打开所在文件夹?
# New Host
termora.new-host.title=新建主机
termora.new-host.general=属性

View File

@@ -130,6 +130,10 @@ termora.welcome.contextmenu.new.host=主機
termora.welcome.contextmenu.new.folder.name=新建資料夾
termora.welcome.contextmenu.property=屬性
termora.welcome.contextmenu.show-more-info=顯示更多信息
termora.welcome.contextmenu.download=下載
termora.welcome.contextmenu.import.csv.download-template=您要匯入還是下載範本?
termora.welcome.contextmenu.import.csv.download-template-done=下載成功
termora.welcome.contextmenu.import.csv.download-template-done-open-folder=下載成功, 是否需要開啟所在資料夾?
# New Host
termora.new-host.title=新主機