chore!: migrate to version 2.x

This commit is contained in:
hstyi
2025-06-13 15:16:56 +08:00
committed by GitHub
parent ca484618c7
commit 6177bbdc68
444 changed files with 18594 additions and 3832 deletions

View File

@@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
project.version = "0.0.1"
dependencies {
testImplementation(kotlin("test"))
compileOnly(project(":"))
}
apply(from = "$rootDir/plugins/common.gradle.kts")

View File

@@ -0,0 +1,876 @@
package app.termora.plugins.sync
import app.termora.*
import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.account.AccountManager
import app.termora.account.AccountOwner
import app.termora.database.DatabaseManager
import app.termora.database.OwnerType
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import app.termora.nv.FileChooser
import app.termora.snippet.Snippet
import app.termora.snippet.SnippetManager
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.*
import org.apache.commons.codec.binary.Base64
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import org.slf4j.LoggerFactory
import java.awt.BorderLayout
import java.awt.Component
import java.awt.event.ActionEvent
import java.awt.event.ItemEvent
import java.io.File
import java.net.URI
import java.nio.charset.StandardCharsets
import java.util.*
import java.util.function.Consumer
import javax.swing.*
import javax.swing.event.DocumentEvent
class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
companion object {
private val log = LoggerFactory.getLogger(CloudSyncOption::class.java)
}
private val database get() = DatabaseManager.getInstance()
private val hostManager get() = HostManager.getInstance()
private val snippetManager get() = SnippetManager.getInstance()
private val keymapManager get() = KeymapManager.getInstance()
private val macroManager get() = MacroManager.getInstance()
private val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
private val keyManager get() = KeyManager.getInstance()
private val formMargin = "7dlu"
private val accountManager get() = AccountManager.getInstance()
private val accountOwner
get() = AccountOwner(
id = accountManager.getAccountId(),
name = accountManager.getEmail(),
type = OwnerType.User
)
val typeComboBox = FlatComboBox<SyncType>()
val tokenTextField = OutlinePasswordField(255)
val gistTextField = OutlineTextField(255)
val policyComboBox = JComboBox<SyncPolicy>()
val domainTextField = OutlineTextField(255)
val syncConfigButton = JButton(I18n.getString("termora.settings.sync"), Icons.settingSync)
val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export)
val importConfigButton = JButton(I18n.getString("termora.settings.sync.import"), Icons.import)
val lastSyncTimeLabel = JLabel()
val sync get() = SyncProperties.getInstance()
val hostsCheckBox = JCheckBox(I18n.getString("termora.welcome.my-hosts"))
val keysCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keys"))
val snippetsCheckBox = JCheckBox(I18n.getString("termora.snippet.title"))
val keywordHighlightsCheckBox = JCheckBox(I18n.getString("termora.settings.sync.range.keyword-highlights"))
val macrosCheckBox = JCheckBox(I18n.getString("termora.macro"))
val keymapCheckBox = JCheckBox(I18n.getString("termora.settings.keymap"))
val visitGistBtn = JButton(Icons.externalLink)
val getTokenBtn = JButton(Icons.externalLink)
private val owner get() = SwingUtilities.getWindowAncestor(this)
init {
initView()
initEvents()
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
syncConfigButton.addActionListener(object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (typeComboBox.selectedItem == SyncType.WebDAV) {
if (tokenTextField.password.isEmpty()) {
tokenTextField.outline = FlatClientProperties.OUTLINE_ERROR
tokenTextField.requestFocusInWindow()
return
} else if (gistTextField.text.isEmpty()) {
gistTextField.outline = FlatClientProperties.OUTLINE_ERROR
gistTextField.requestFocusInWindow()
return
}
}
swingCoroutineScope.launch(Dispatchers.IO) { sync() }
}
})
typeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
sync.type = typeComboBox.selectedItem as SyncType
if (typeComboBox.selectedItem == SyncType.GitLab) {
if (domainTextField.text.isBlank()) {
domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api")
}
}
removeAll()
add(getCenterComponent(), BorderLayout.CENTER)
revalidate()
repaint()
}
}
policyComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
sync.policy = (policyComboBox.selectedItem as SyncPolicy).name
}
}
tokenTextField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
sync.token = String(tokenTextField.password)
tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null
}
})
domainTextField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
sync.domain = domainTextField.text
}
})
gistTextField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) {
sync.gist = gistTextField.text
gistTextField.trailingComponent = if (gistTextField.text.isNotBlank()) visitGistBtn else null
}
})
visitGistBtn.addActionListener {
if (typeComboBox.selectedItem == SyncType.GitLab) {
if (domainTextField.text.isNotBlank()) {
try {
val baseUrl = URI.create(domainTextField.text)
val url = StringBuilder()
url.append(baseUrl.scheme).append("://")
url.append(baseUrl.host)
if (baseUrl.port > 0) {
url.append(":").append(baseUrl.port)
}
url.append("/-/snippets/").append(gistTextField.text)
Application.browse(URI.create(url.toString()))
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
} else if (typeComboBox.selectedItem == SyncType.GitHub) {
Application.browse(URI.create("https://gist.github.com/${gistTextField.text}"))
}
}
getTokenBtn.addActionListener {
when (typeComboBox.selectedItem) {
SyncType.GitLab -> {
val uri = URI.create(domainTextField.text)
Application.browse(URI.create("${uri.scheme}://${uri.host}/-/user_settings/personal_access_tokens?name=Termora%20Sync%20Config&scopes=api"))
}
SyncType.GitHub -> Application.browse(URI.create("https://github.com/settings/tokens"))
SyncType.Gitee -> Application.browse(URI.create("https://gitee.com/profile/personal_access_tokens"))
}
}
exportConfigButton.addActionListener { export() }
importConfigButton.addActionListener { import() }
keysCheckBox.addActionListener { refreshButtons() }
hostsCheckBox.addActionListener { refreshButtons() }
snippetsCheckBox.addActionListener { refreshButtons() }
keywordHighlightsCheckBox.addActionListener { refreshButtons() }
}
private suspend fun sync() {
// 如果 gist 为空说明要创建一个 gist
if (gistTextField.text.isBlank()) {
if (!pushOrPull(true)) return
} else {
if (!pushOrPull(false)) return
if (!pushOrPull(true)) return
}
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(owner, message = I18n.getString("termora.settings.sync.done"))
}
}
private fun visit(c: JComponent, consumer: Consumer<JComponent>) {
for (e in c.components) {
if (e is JComponent) {
consumer.accept(e)
visit(e, consumer)
}
}
}
private fun refreshButtons() {
sync.rangeKeyPairs = keysCheckBox.isSelected
sync.rangeHosts = hostsCheckBox.isSelected
sync.rangeSnippets = snippetsCheckBox.isSelected
sync.rangeKeywordHighlights = keywordHighlightsCheckBox.isSelected
syncConfigButton.isEnabled = keysCheckBox.isSelected || hostsCheckBox.isSelected
|| keywordHighlightsCheckBox.isSelected
exportConfigButton.isEnabled = syncConfigButton.isEnabled
importConfigButton.isEnabled = syncConfigButton.isEnabled
}
private fun export() {
assertEventDispatchThread()
val passwordField = OutlinePasswordField()
val panel = object : JPanel(BorderLayout()) {
override fun requestFocusInWindow(): Boolean {
return passwordField.requestFocusInWindow()
}
}
val label = JLabel(I18n.getString("termora.settings.sync.export-encrypt") + StringUtils.SPACE.repeat(25))
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
panel.add(label, BorderLayout.NORTH)
panel.add(passwordField, BorderLayout.CENTER)
var password = StringUtils.EMPTY
if (OptionPane.showConfirmDialog(
owner,
panel,
optionType = JOptionPane.YES_NO_OPTION,
initialValue = passwordField
) == JOptionPane.YES_OPTION
) {
password = String(passwordField.password).trim()
}
val fileChooser = FileChooser()
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
fileChooser.win32Filters.add(Pair("All Files", listOf("*")))
fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
fileChooser.showSaveDialog(owner, "${Application.getName()}.json").thenAccept { file ->
if (file != null) {
SwingUtilities.invokeLater { exportText(file, password) }
}
}
}
private fun import() {
val fileChooser = FileChooser()
fileChooser.fileSelectionMode = JFileChooser.FILES_ONLY
fileChooser.osxAllowedFileTypes = listOf("json")
fileChooser.win32Filters.add(Pair("JSON files", listOf("json")))
fileChooser.showOpenDialog(owner).thenAccept { files ->
if (files.isNotEmpty()) {
SwingUtilities.invokeLater { importFromFile(files.first()) }
}
}
}
@Suppress("DuplicatedCode")
private fun importFromFile(file: File) {
if (!file.exists()) {
return
}
val ranges = getSyncConfig().ranges
if (ranges.isEmpty()) {
return
}
// 最大 100MB
if (file.length() >= 1024 * 1024 * 100) {
OptionPane.showMessageDialog(
owner, I18n.getString("termora.settings.sync.import.file-too-large"),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
val text = file.readText()
val jsonResult = ohMyJson.runCatching { decodeFromString<JsonObject>(text) }
if (jsonResult.isFailure) {
val e = jsonResult.exceptionOrNull() ?: return
OptionPane.showMessageDialog(
owner, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
var json = jsonResult.getOrNull() ?: return
// 如果加密了 则解密数据
if (json["encryption"]?.jsonPrimitive?.booleanOrNull == true) {
val data = json["data"]?.jsonPrimitive?.content ?: StringUtils.EMPTY
if (data.isBlank()) {
OptionPane.showMessageDialog(
owner, "Data file corruption",
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
while (true) {
val passwordField = OutlinePasswordField()
val panel = object : JPanel(BorderLayout()) {
override fun requestFocusInWindow(): Boolean {
return passwordField.requestFocusInWindow()
}
}
val label = JLabel("Please enter the password" + StringUtils.SPACE.repeat(25))
label.border = BorderFactory.createEmptyBorder(0, 0, 8, 0)
panel.add(label, BorderLayout.NORTH)
panel.add(passwordField, BorderLayout.CENTER)
if (OptionPane.showConfirmDialog(
owner,
panel,
optionType = JOptionPane.YES_NO_OPTION,
initialValue = passwordField
) != JOptionPane.YES_OPTION
) {
return
}
if (passwordField.password.isEmpty()) {
OptionPane.showMessageDialog(
owner, I18n.getString("termora.doorman.unlock-data"),
messageType = JOptionPane.ERROR_MESSAGE
)
continue
}
val password = String(passwordField.password)
val key = PBKDF2.generateSecret(
password.toCharArray(),
password.toByteArray(), keyLength = 128
)
try {
val dataText = AES.ECB.decrypt(key, Base64.decodeBase64(data)).toString(Charsets.UTF_8)
val dataJsonResult = ohMyJson.runCatching { decodeFromString<JsonObject>(dataText) }
if (dataJsonResult.isFailure) {
val e = dataJsonResult.exceptionOrNull() ?: return
OptionPane.showMessageDialog(
owner, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
return
}
json = dataJsonResult.getOrNull() ?: return
break
} catch (_: Exception) {
OptionPane.showMessageDialog(
owner, I18n.getString("termora.doorman.password-wrong"),
messageType = JOptionPane.ERROR_MESSAGE
)
}
}
}
if (ranges.contains(SyncRange.Hosts)) {
val hosts = json["hosts"]
if (hosts is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<Host>>(hosts.jsonArray) }.onSuccess {
for (host in it) {
hostManager.addHost(host)
}
}
}
}
if (ranges.contains(SyncRange.Snippets)) {
val snippets = json["snippets"]
if (snippets is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<Snippet>>(snippets.jsonArray) }.onSuccess {
for (snippet in it) {
snippetManager.addSnippet(snippet)
}
}
}
}
if (ranges.contains(SyncRange.KeyPairs)) {
val keyPairs = json["keyPairs"]
if (keyPairs is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<OhKeyPair>>(keyPairs.jsonArray) }.onSuccess {
for (keyPair in it) {
keyManager.addOhKeyPair(keyPair, accountOwner)
}
}
}
}
if (ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlights = json["keywordHighlights"]
if (keywordHighlights is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<KeywordHighlight>>(keywordHighlights.jsonArray) }
.onSuccess {
for (keyPair in it) {
keywordHighlightManager.addKeywordHighlight(keyPair, accountOwner)
}
}
}
}
if (ranges.contains(SyncRange.Macros)) {
val macros = json["macros"]
if (macros is JsonArray) {
ohMyJson.runCatching { decodeFromJsonElement<List<Macro>>(macros.jsonArray) }.onSuccess {
for (macro in it) {
macroManager.addMacro(macro)
}
}
}
}
if (ranges.contains(SyncRange.Keymap)) {
val keymaps = json["keymaps"]
if (keymaps is JsonArray) {
for (keymap in keymaps.jsonArray.mapNotNull { Keymap.fromJSON(it.jsonObject) }) {
keymapManager.addKeymap(keymap)
}
}
}
OptionPane.showMessageDialog(
owner, I18n.getString("termora.settings.sync.import.successful"),
messageType = JOptionPane.INFORMATION_MESSAGE
)
}
private fun exportText(file: File, password: String) {
val syncConfig = getSyncConfig()
var text = ohMyJson.encodeToString(buildJsonObject {
val now = System.currentTimeMillis()
put("exporter", SystemUtils.USER_NAME)
put("version", Application.getVersion())
put("exportDate", now)
put("os", SystemUtils.OS_NAME)
put("exportDateHuman", DateFormatUtils.ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.format(Date(now)))
if (syncConfig.ranges.contains(SyncRange.Hosts)) {
put("hosts", ohMyJson.encodeToJsonElement(hostManager.hosts()))
}
if (syncConfig.ranges.contains(SyncRange.Snippets)) {
put("snippets", ohMyJson.encodeToJsonElement(snippetManager.snippets()))
}
if (syncConfig.ranges.contains(SyncRange.KeyPairs)) {
put("keyPairs", ohMyJson.encodeToJsonElement(keyManager.getOhKeyPairs()))
}
if (syncConfig.ranges.contains(SyncRange.KeywordHighlights)) {
put(
"keywordHighlights",
ohMyJson.encodeToJsonElement(keywordHighlightManager.getKeywordHighlights())
)
}
if (syncConfig.ranges.contains(SyncRange.Macros)) {
put(
"macros",
ohMyJson.encodeToJsonElement(macroManager.getMacros())
)
}
if (syncConfig.ranges.contains(SyncRange.Keymap)) {
val keymaps = keymapManager.getKeymaps().filter { !it.isReadonly }
.map { it.toJSONObject() }
put(
"keymaps",
ohMyJson.encodeToJsonElement(keymaps)
)
}
put("settings", buildJsonObject {
put("appearance", ohMyJson.encodeToJsonElement(database.appearance.getProperties()))
put("sync", ohMyJson.encodeToJsonElement(sync.getProperties()))
put("terminal", ohMyJson.encodeToJsonElement(database.terminal.getProperties()))
})
})
if (password.isNotBlank()) {
val key = PBKDF2.generateSecret(
password.toCharArray(),
password.toByteArray(), keyLength = 128
)
text = ohMyJson.encodeToString(buildJsonObject {
put("encryption", true)
put("data", AES.ECB.encrypt(key, text.toByteArray(Charsets.UTF_8)).encodeBase64String())
})
}
file.outputStream().use {
IOUtils.write(text, it, StandardCharsets.UTF_8)
OptionPane.openFileInFolder(
owner,
file, I18n.getString("termora.settings.sync.export-done-open-folder"),
I18n.getString("termora.settings.sync.export-done")
)
}
}
private fun getSyncConfig(): SyncConfig {
val range = mutableSetOf<SyncRange>()
if (hostsCheckBox.isSelected) {
range.add(SyncRange.Hosts)
}
if (keysCheckBox.isSelected) {
range.add(SyncRange.KeyPairs)
}
if (keywordHighlightsCheckBox.isSelected) {
range.add(SyncRange.KeywordHighlights)
}
if (macrosCheckBox.isSelected) {
range.add(SyncRange.Macros)
}
if (keymapCheckBox.isSelected) {
range.add(SyncRange.Keymap)
}
if (snippetsCheckBox.isSelected) {
range.add(SyncRange.Snippets)
}
return SyncConfig(
type = typeComboBox.selectedItem as SyncType,
token = String(tokenTextField.password),
gistId = gistTextField.text,
options = mapOf("domain" to domainTextField.text),
ranges = range
)
}
/**
* @return true 同步成功
*/
@Suppress("DuplicatedCode")
private suspend fun pushOrPull(push: Boolean): Boolean {
if (typeComboBox.selectedItem == SyncType.GitLab) {
if (domainTextField.text.isBlank()) {
withContext(Dispatchers.Swing) {
domainTextField.outline = "error"
domainTextField.requestFocusInWindow()
}
return false
}
}
if (tokenTextField.password.isEmpty()) {
withContext(Dispatchers.Swing) {
tokenTextField.outline = "error"
tokenTextField.requestFocusInWindow()
}
return false
}
if (gistTextField.text.isBlank() && !push) {
withContext(Dispatchers.Swing) {
gistTextField.outline = "error"
gistTextField.requestFocusInWindow()
}
return false
}
withContext(Dispatchers.Swing) {
exportConfigButton.isEnabled = false
importConfigButton.isEnabled = false
syncConfigButton.isEnabled = false
typeComboBox.isEnabled = false
gistTextField.isEnabled = false
tokenTextField.isEnabled = false
keysCheckBox.isEnabled = false
macrosCheckBox.isEnabled = false
keymapCheckBox.isEnabled = false
keywordHighlightsCheckBox.isEnabled = false
hostsCheckBox.isEnabled = false
snippetsCheckBox.isEnabled = false
domainTextField.isEnabled = false
syncConfigButton.text = "${I18n.getString("termora.settings.sync")}..."
}
val syncConfig = getSyncConfig()
// sync
val syncResult = runCatching {
val syncer = SyncManager.getInstance()
if (push) {
syncer.push(syncConfig)
} else {
syncer.pull(syncConfig)
}
}
// 恢复状态
withContext(Dispatchers.Swing) {
syncConfigButton.isEnabled = true
exportConfigButton.isEnabled = true
importConfigButton.isEnabled = true
keysCheckBox.isEnabled = true
hostsCheckBox.isEnabled = true
snippetsCheckBox.isEnabled = true
typeComboBox.isEnabled = true
macrosCheckBox.isEnabled = true
keymapCheckBox.isEnabled = true
gistTextField.isEnabled = true
tokenTextField.isEnabled = true
domainTextField.isEnabled = true
keywordHighlightsCheckBox.isEnabled = true
syncConfigButton.text = I18n.getString("termora.settings.sync")
}
// 如果失败,提示错误
if (syncResult.isFailure) {
val exception = syncResult.exceptionOrNull()
var message = exception?.message ?: "Failed to sync data"
if (exception is ResponseException) {
message = "Server response: ${exception.code}"
}
if (exception != null) {
if (log.isErrorEnabled) {
log.error(exception.message, exception)
}
}
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(owner, message, messageType = JOptionPane.ERROR_MESSAGE)
}
} else {
withContext(Dispatchers.Swing) {
val now = System.currentTimeMillis()
sync.lastSyncTime = now
val date = DateFormatUtils.format(Date(now), I18n.getString("termora.date-format"))
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: $date"
if (push && gistTextField.text.isBlank()) {
gistTextField.text = syncResult.map { it.config }.getOrDefault(syncConfig).gistId
}
}
}
return syncResult.isSuccess
}
private fun initView() {
typeComboBox.addItem(SyncType.GitHub)
typeComboBox.addItem(SyncType.GitLab)
typeComboBox.addItem(SyncType.Gitee)
typeComboBox.addItem(SyncType.WebDAV)
policyComboBox.addItem(SyncPolicy.Manual)
policyComboBox.addItem(SyncPolicy.OnChange)
hostsCheckBox.isFocusable = false
snippetsCheckBox.isFocusable = false
keysCheckBox.isFocusable = false
keywordHighlightsCheckBox.isFocusable = false
macrosCheckBox.isFocusable = false
keymapCheckBox.isFocusable = false
hostsCheckBox.isSelected = sync.rangeHosts
snippetsCheckBox.isSelected = sync.rangeSnippets
keysCheckBox.isSelected = sync.rangeKeyPairs
keywordHighlightsCheckBox.isSelected = sync.rangeKeywordHighlights
macrosCheckBox.isSelected = sync.rangeMacros
keymapCheckBox.isSelected = sync.rangeKeymap
if (sync.policy == SyncPolicy.Manual.name) {
policyComboBox.selectedItem = SyncPolicy.Manual
} else if (sync.policy == SyncPolicy.OnChange.name) {
policyComboBox.selectedItem = SyncPolicy.OnChange
}
typeComboBox.selectedItem = sync.type
gistTextField.text = sync.gist
tokenTextField.text = sync.token
domainTextField.trailingComponent = JButton(Icons.externalLink).apply {
addActionListener {
if (typeComboBox.selectedItem == SyncType.GitLab) {
Application.browse(URI.create("https://docs.gitlab.com/ee/api/snippets.html"))
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
val url = domainTextField.text
if (url.isNullOrBlank()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.settings.sync.webdav.help")
)
} else {
val uri = URI.create(url)
val sb = StringBuilder()
sb.append(uri.scheme).append("://")
if (tokenTextField.password.isNotEmpty() && gistTextField.text.isNotBlank()) {
sb.append(String(tokenTextField.password)).append(":").append(gistTextField.text)
sb.append('@')
}
sb.append(uri.authority).append(uri.path)
if (!uri.query.isNullOrBlank()) {
sb.append('?').append(uri.query)
}
Application.browse(URI.create(sb.toString()))
}
}
}
}
if (typeComboBox.selectedItem != SyncType.Gitee) {
gistTextField.trailingComponent = if (gistTextField.text.isNotBlank()) visitGistBtn else null
}
tokenTextField.trailingComponent = if (tokenTextField.password.isEmpty()) getTokenBtn else null
if (domainTextField.text.isBlank()) {
if (typeComboBox.selectedItem == SyncType.GitLab) {
domainTextField.text = StringUtils.defaultIfBlank(sync.domain, "https://gitlab.com/api")
} else if (typeComboBox.selectedItem == SyncType.WebDAV) {
domainTextField.text = sync.domain
}
}
policyComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
var text = value?.toString() ?: StringUtils.EMPTY
if (value == SyncPolicy.Manual) {
text = I18n.getString("termora.settings.sync.policy.manual")
} else if (value == SyncPolicy.OnChange) {
text = I18n.getString("termora.settings.sync.policy.on-change")
}
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
}
}
val lastSyncTime = sync.lastSyncTime
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
if (lastSyncTime > 0) DateFormatUtils.format(
Date(lastSyncTime), I18n.getString("termora.date-format")
) else "-"
}"
refreshButtons()
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.cloud
}
override fun getTitle(): String {
return I18n.getString("termora.settings.sync")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, 30dlu",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
val rangeBox = FormBuilder.create()
.layout(
FormLayout(
"left:pref, $formMargin, left:pref, $formMargin, left:pref",
"pref, 2dlu, pref"
)
)
.add(hostsCheckBox).xy(1, 1)
.add(keysCheckBox).xy(3, 1)
.add(keywordHighlightsCheckBox).xy(5, 1)
.add(macrosCheckBox).xy(1, 3)
.add(keymapCheckBox).xy(3, 3)
.add(snippetsCheckBox).xy(5, 3)
.build()
var rows = 1
val step = 2
val builder = FormBuilder.create().layout(layout).debug(false)
val box = Box.createHorizontalBox()
box.add(typeComboBox)
if (typeComboBox.selectedItem == SyncType.GitLab || typeComboBox.selectedItem == SyncType.WebDAV) {
box.add(Box.createHorizontalStrut(4))
box.add(domainTextField)
}
builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows)
.add(box).xy(3, rows).apply { rows += step }
val isWebDAV = typeComboBox.selectedItem == SyncType.WebDAV
val tokenText = if (isWebDAV) {
I18n.getString("termora.new-host.general.username")
} else {
I18n.getString("termora.settings.sync.token")
}
val gistText = if (isWebDAV) {
I18n.getString("termora.new-host.general.password")
} else {
I18n.getString("termora.settings.sync.gist")
}
if (typeComboBox.selectedItem == SyncType.Gitee || isWebDAV) {
gistTextField.trailingComponent = null
} else {
gistTextField.trailingComponent = visitGistBtn
}
val syncPolicyBox = Box.createHorizontalBox()
syncPolicyBox.add(policyComboBox)
syncPolicyBox.add(Box.createHorizontalGlue())
syncPolicyBox.add(Box.createHorizontalGlue())
builder.add("${tokenText}:").xy(1, rows)
.add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step }
.add("${gistText}:").xy(1, rows)
.add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.policy")}:").xy(1, rows)
.add(syncPolicyBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
.add(rangeBox).xy(3, rows).apply { rows += step }
// Sync buttons
.add(
FormBuilder.create()
.layout(FormLayout("pref, 2dlu, pref, 2dlu, pref", "pref"))
.add(syncConfigButton).xy(1, 1)
.add(exportConfigButton).xy(3, 1)
.add(importConfigButton).xy(5, 1)
.build()
).xy(3, rows, "center, fill").apply { rows += step }
.add(lastSyncTimeLabel).xy(3, rows, "center, fill").apply { rows += step }
return builder.build()
}
}

View File

@@ -0,0 +1,86 @@
package app.termora.plugins.sync
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.ResponseException
import kotlinx.serialization.json.*
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
class GitHubSyncer private constructor() : GitSyncer() {
companion object {
fun getInstance(): GitHubSyncer {
return ApplicationScope.forApplicationScope().getOrCreate(GitHubSyncer::class) { GitHubSyncer() }
}
}
override fun newPullRequestBuilder(config: SyncConfig): Request.Builder {
return Request.Builder()
.get()
.url("https://api.github.com/gists/${config.gistId}")
.header("Accept", "application/vnd.github+json")
.header("Authorization", "Bearer ${config.token}")
.header("X-GitHub-Api-Version", "2022-11-28")
}
override fun newPushRequestBuilder(gistFiles: List<GistFile>, config: SyncConfig): Request.Builder {
val create = config.gistId.isBlank()
val content = ohMyJson.encodeToString(buildJsonObject {
if (create) {
put("public", false)
}
put("description", description)
putJsonObject("files") {
for (file in gistFiles) {
putJsonObject(file.filename) {
put("content", file.content)
put("type", "application/json")
}
}
}
})
val builder = Request.Builder()
if (create) {
builder.post(content.toRequestBody())
.url("https://api.github.com/gists")
} else {
builder.patch(content.toRequestBody())
.url("https://api.github.com/gists/${config.gistId}")
}
return builder.header("Accept", "application/vnd.github+json")
.header("Authorization", "Bearer ${config.token}")
.header("X-GitHub-Api-Version", "2022-11-28")
}
override fun parsePullResponse(response: Response, config: SyncConfig): GistResponse {
if (response.code != 200) {
throw ResponseException(response.code, response)
}
val gistResponse = super.parsePullResponse(response, config)
val text = parseResponse(response)
val json = ohMyJson.parseToJsonElement(text).jsonObject
val files = json.getValue("files").jsonObject
if (files.isEmpty()) {
return gistResponse
}
val gists = mutableListOf<GistFile>()
for (key in files.keys) {
val file = files.getValue(key).jsonObject
gists.add(
GistFile(
filename = file.getValue("filename").jsonPrimitive.content,
content = file.getValue("content").jsonPrimitive.content,
)
)
}
return gistResponse.copy(gists = gists)
}
}

View File

@@ -0,0 +1,119 @@
package app.termora.plugins.sync
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.ResponseException
import kotlinx.serialization.json.*
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.apache.commons.io.IOUtils
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class GitLabSyncer private constructor() : GitSyncer() {
companion object {
fun getInstance(): GitLabSyncer {
return ApplicationScope.forApplicationScope().getOrCreate(GitLabSyncer::class) { GitLabSyncer() }
}
}
private val SyncConfig.domain get() = options.getValue("domain")
private fun getRawSnippet(config: SyncConfig, filename: String): String {
val name = URLEncoder.encode(filename, StandardCharsets.UTF_8)
val request = Request.Builder()
.get()
.url("${config.domain}/v4/snippets/${config.gistId}/files/main/${name}/raw")
.header("PRIVATE-TOKEN", config.token)
.build()
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful.not()) {
IOUtils.closeQuietly(response)
throw ResponseException(response.code, response)
}
return parseResponse(response)
}
}
override fun newPullRequestBuilder(config: SyncConfig): Request.Builder {
return Request.Builder()
.get()
.url("${config.domain}/v4/snippets/${config.gistId}")
.header("PRIVATE-TOKEN", config.token)
}
override fun newPushRequestBuilder(gistFiles: List<GistFile>, config: SyncConfig): Request.Builder {
val create = config.gistId.isBlank()
val oldFileNames = mutableSetOf<String>()
if (!create) {
val response = httpClient.newCall(newPullRequestBuilder(config).build()).execute()
oldFileNames.addAll(parsePullResponseFileNames(response, config))
}
val content = ohMyJson.encodeToString(buildJsonObject {
if (create) {
put("visibility", "private")
put("title", description)
} else {
put("id", config.gistId.toInt())
}
putJsonArray("files") {
for (file in gistFiles) {
add(buildJsonObject {
if (!create) {
put("action", if (oldFileNames.contains(file.filename)) "update" else "create")
}
put("content", file.content)
put("file_path", file.filename)
})
}
}
})
val requestBody = content.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
val builder = Request.Builder()
if (create) {
builder.post(requestBody)
.url("${config.domain}/v4/snippets")
} else {
builder.put(requestBody)
.url("${config.domain}/v4/snippets/${config.gistId}")
}
return builder.header("PRIVATE-TOKEN", config.token)
}
override fun parsePullResponse(response: Response, config: SyncConfig): GistResponse {
val gists = mutableListOf<GistFile>()
val rangeNames = config.ranges.map { it.name }
for (e in parsePullResponseFileNames(response, config)) {
if (rangeNames.contains(e)) {
gists.add(GistFile(filename = e, content = getRawSnippet(config, e)))
}
}
return super.parsePullResponse(response, config).copy(gists = gists)
}
private fun parsePullResponseFileNames(response: Response, config: SyncConfig): List<String> {
if (!response.isSuccessful) {
throw ResponseException(response.code, response)
}
val text = parseResponse(response)
val json = ohMyJson.parseToJsonElement(text).jsonObject
val files = json.getValue("files").jsonArray
if (files.isEmpty()) {
return emptyList()
}
return files.map { it.jsonObject }.map { it.getValue("path").jsonPrimitive.content }
}
}

View File

@@ -0,0 +1,201 @@
package app.termora.plugins.sync
import app.termora.Application.ohMyJson
import app.termora.DeletedData
import app.termora.ResponseException
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Request
import okhttp3.Response
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory
abstract class GitSyncer : SafetySyncer() {
companion object {
private val log = LoggerFactory.getLogger(GitSyncer::class.java)
}
override fun pull(config: SyncConfig): GistResponse {
if (log.isInfoEnabled) {
log.info("Type: ${config.type} , Gist: ${config.gistId} Pull...")
}
val response = httpClient.newCall(newPullRequestBuilder(config).build()).execute()
if (response.isSuccessful.not()) {
IOUtils.closeQuietly(response)
throw ResponseException(response.code, response)
}
val gistResponse = parsePullResponse(response, config)
val deletedData = mutableListOf<DeletedData>()
// DeletedData
gistResponse.gists.findLast { it.filename == "DeletedData" }
?.let { deletedData.addAll(decodeDeletedData(it.content, config)) }
// decode hosts
if (config.ranges.contains(SyncRange.Hosts)) {
gistResponse.gists.findLast { it.filename == "Hosts" }?.let {
decodeHosts(it.content, deletedData.filter { e -> e.type == "Host" }, config)
}
}
// decode keys
if (config.ranges.contains(SyncRange.KeyPairs)) {
gistResponse.gists.findLast { it.filename == "KeyPairs" }?.let {
decodeKeys(it.content, deletedData.filter { e -> e.type == "KeyPair" }, config)
}
}
// decode keyword highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
gistResponse.gists.findLast { it.filename == "KeywordHighlights" }?.let {
decodeKeywordHighlights(it.content, deletedData.filter { e -> e.type == "KeywordHighlight" }, config)
}
}
// decode macros
if (config.ranges.contains(SyncRange.Macros)) {
gistResponse.gists.findLast { it.filename == "Macros" }?.let {
decodeMacros(it.content, deletedData.filter { e -> e.type == "Macro" }, config)
}
}
// decode keymaps
if (config.ranges.contains(SyncRange.Macros)) {
gistResponse.gists.findLast { it.filename == "Keymaps" }?.let {
decodeKeymaps(it.content, deletedData.filter { e -> e.type == "Keymap" }, config)
}
}
// decode Snippets
if (config.ranges.contains(SyncRange.Snippets)) {
gistResponse.gists.findLast { it.filename == "Snippets" }?.let {
decodeSnippets(it.content, deletedData.filter { e -> e.type == "Snippet" }, config)
}
}
if (log.isInfoEnabled) {
log.info("Type: ${config.type} , Gist: ${config.gistId} Pulled")
}
return gistResponse
}
override fun push(config: SyncConfig): GistResponse {
if (log.isInfoEnabled) {
log.info("Type: ${config.type} , Gist: ${config.gistId} Push...")
}
val gistFiles = mutableListOf<GistFile>()
// aes key
val key = ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
// Hosts
if (config.ranges.contains(SyncRange.Hosts)) {
val hostsContent = encodeHosts(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedHosts: {}", hostsContent)
}
gistFiles.add(GistFile("Hosts", hostsContent))
}
// Snippets
if (config.ranges.contains(SyncRange.Snippets)) {
val snippetsContent = encodeSnippets(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedSnippets: {}", snippetsContent)
}
gistFiles.add(GistFile("Snippets", snippetsContent))
}
// KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) {
val keysContent = encodeKeys(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedKeys: {}", keysContent)
}
gistFiles.add(GistFile("KeyPairs", keysContent))
}
// Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlightsContent = encodeKeywordHighlights(key)
if (log.isDebugEnabled) {
log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
}
gistFiles.add(GistFile("KeywordHighlights", keywordHighlightsContent))
}
// Macros
if (config.ranges.contains(SyncRange.Macros)) {
val macrosContent = encodeMacros(key)
if (log.isDebugEnabled) {
log.debug("Push macros: {}", macrosContent)
}
gistFiles.add(GistFile("Macros", macrosContent))
}
// Keymap
if (config.ranges.contains(SyncRange.Keymap)) {
val keymapsContent = encodeKeymaps()
if (log.isDebugEnabled) {
log.debug("Push keymaps: {}", keymapsContent)
}
gistFiles.add(GistFile("Keymaps", keymapsContent))
}
if (gistFiles.isEmpty()) {
throw IllegalArgumentException("No gist files found")
}
val deletedData = encodeDeletedData(config)
if (log.isDebugEnabled) {
log.debug("Push DeletedData: {}", deletedData)
}
gistFiles.add(GistFile("DeletedData", deletedData))
val request = newPushRequestBuilder(gistFiles, config).build()
try {
return parsePushResponse(httpClient.newCall(request).execute(), config)
} finally {
if (log.isInfoEnabled) {
log.info("Type: ${config.type} , Gist: ${config.gistId} Pushed")
}
}
}
open fun parsePullResponse(response: Response, config: SyncConfig): GistResponse {
return GistResponse(config, emptyList())
}
open fun parsePushResponse(response: Response, config: SyncConfig): GistResponse {
if (!response.isSuccessful) {
throw ResponseException(response.code, response)
}
val gistResponse = GistResponse(config, emptyList())
val text = parseResponse(response)
val json = ohMyJson.parseToJsonElement(text).jsonObject
return gistResponse.copy(
config = config.copy(gistId = json.getValue("id").jsonPrimitive.content)
)
}
open fun parseResponse(response: Response): String {
return response.use { resp -> resp.body?.use { it.string() } }
?: throw ResponseException(response.code, response)
}
abstract fun newPullRequestBuilder(config: SyncConfig): Request.Builder
abstract fun newPushRequestBuilder(gistFiles: List<GistFile>, config: SyncConfig): Request.Builder
}

View File

@@ -0,0 +1,85 @@
package app.termora.plugins.sync
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.ResponseException
import kotlinx.serialization.json.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.apache.commons.lang3.StringUtils
class GiteeSyncer private constructor() : GitSyncer() {
companion object {
fun getInstance(): GiteeSyncer {
return ApplicationScope.forApplicationScope().getOrCreate(GiteeSyncer::class) { GiteeSyncer() }
}
}
override fun newPullRequestBuilder(config: SyncConfig): Request.Builder {
val gistId = StringUtils.defaultIfBlank(config.gistId, "empty")
return Request.Builder().get()
.url("https://gitee.com/api/v5/gists/${gistId}?access_token=${config.token}")
}
override fun newPushRequestBuilder(gistFiles: List<GistFile>, config: SyncConfig): Request.Builder {
val content = ohMyJson.encodeToString(buildJsonObject {
if (config.gistId.isBlank()) {
put("public", false)
}
put("description", description)
put("access_token", config.token)
putJsonObject("files") {
for (file in gistFiles) {
putJsonObject(file.filename) {
put("content", file.content)
put("type", "application/json")
}
}
}
})
val builder = Request.Builder()
if (config.gistId.isBlank()) {
builder.post(content.toRequestBody("application/json; charset=utf-8".toMediaType()))
.url("https://gitee.com/api/v5/gists")
} else {
builder.patch(content.toRequestBody("application/json; charset=utf-8".toMediaType()))
.url("https://gitee.com/api/v5/gists/${config.gistId}")
}
return builder
}
override fun parsePullResponse(response: Response, config: SyncConfig): GistResponse {
if (response.code != 200) {
throw ResponseException(response.code, response)
}
val gistResponse = super.parsePullResponse(response, config)
val text = parseResponse(response)
val json = ohMyJson.parseToJsonElement(text).jsonObject
val files = json.getValue("files").jsonObject
if (files.isEmpty()) {
return gistResponse
}
val gists = mutableListOf<GistFile>()
for (key in files.keys) {
val file = files.getValue(key).jsonObject
gists.add(
GistFile(
filename = key,
content = file.getValue("content").jsonPrimitive.content,
)
)
}
return gistResponse.copy(gists = gists)
}
}

View File

@@ -0,0 +1,37 @@
package app.termora.plugins.sync
import org.slf4j.LoggerFactory
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import kotlin.time.measureTime
object PBKDF2 {
private const val ALGORITHM = "PBKDF2WithHmacSHA512"
private val log = LoggerFactory.getLogger(PBKDF2::class.java)
fun generateSecret(
password: CharArray,
salt: ByteArray,
iterationCount: Int = 150000,
keyLength: Int = 256
): ByteArray {
val bytes: ByteArray
val time = measureTime {
bytes = SecretKeyFactory.getInstance(ALGORITHM)
.generateSecret(PBEKeySpec(password, salt, iterationCount, keyLength))
.encoded
}
if (log.isDebugEnabled) {
log.debug("Secret generated $time")
}
return bytes
}
fun hash(slat: ByteArray, password: CharArray, iterationCount: Int, keyLength: Int): ByteArray {
val spec = PBEKeySpec(password, slat, iterationCount, keyLength)
val secretKeyFactory = SecretKeyFactory.getInstance(ALGORITHM)
return secretKeyFactory.generateSecret(spec).encoded
}
}

View File

@@ -0,0 +1,445 @@
package app.termora.plugins.sync
import app.termora.*
import app.termora.AES.CBC.aesCBCDecrypt
import app.termora.AES.CBC.aesCBCEncrypt
import app.termora.AES.decodeBase64
import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.account.AccountManager
import app.termora.account.AccountOwner
import app.termora.database.OwnerType
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
import app.termora.keymap.Keymap
import app.termora.keymap.KeymapManager
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro
import app.termora.macro.MacroManager
import app.termora.snippet.Snippet
import app.termora.snippet.SnippetManager
import kotlinx.serialization.json.JsonObject
import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory
import javax.swing.SwingUtilities
abstract class SafetySyncer : Syncer {
companion object {
private val log = LoggerFactory.getLogger(SafetySyncer::class.java)
}
protected val description = "${Application.getName()} config"
protected val httpClient get() = Application.httpClient
protected val hostManager get() = HostManager.getInstance()
protected val keyManager get() = KeyManager.getInstance()
protected val keywordHighlightManager get() = KeywordHighlightManager.getInstance()
protected val macroManager get() = MacroManager.getInstance()
protected val keymapManager get() = KeymapManager.getInstance()
protected val snippetManager get() = SnippetManager.getInstance()
protected val deleteDataManager get() = DeleteDataManager.getInstance()
protected val accountManager get() = AccountManager.getInstance()
protected val accountOwner
get() = AccountOwner(
id = accountManager.getAccountId(),
name = accountManager.getEmail(),
type = OwnerType.User
)
protected fun decodeHosts(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedHosts = ohMyJson.decodeFromString<List<EncryptedHost>>(text)
val hosts = hostManager.hosts().associateBy { it.id }
for (encryptedHost in encryptedHosts) {
val oldHost = hosts[encryptedHost.id]
// 如果本地的修改时间大于云端时间,那么跳过
if (oldHost != null) {
if (oldHost.updateDate >= encryptedHost.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedHost.id)
val host = Host(
id = encryptedHost.id,
name = encryptedHost.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
protocol = encryptedHost.protocol.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
host = encryptedHost.host.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
port = encryptedHost.port.decodeBase64().aesCBCDecrypt(key, iv)
.decodeToString().toIntOrNull() ?: 0,
username = encryptedHost.username.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedHost.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
authentication = ohMyJson.decodeFromString(
encryptedHost.authentication.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
proxy = ohMyJson.decodeFromString(
encryptedHost.proxy.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
options = ohMyJson.decodeFromString(
encryptedHost.options.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
tunnelings = ohMyJson.decodeFromString(
encryptedHost.tunnelings.decodeBase64().aesCBCDecrypt(key, iv).decodeToString()
),
sort = encryptedHost.sort,
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
createDate = encryptedHost.createDate,
updateDate = encryptedHost.updateDate,
)
SwingUtilities.invokeLater { hostManager.addHost(host) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
}
}
}
SwingUtilities.invokeLater {
deletedData.forEach {
hostManager.removeHost(it.id)
deleteDataManager.removeHost(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) {
log.debug("Decode hosts: {}", text)
}
}
protected fun encodeHosts(key: ByteArray): String {
val encryptedHosts = mutableListOf<EncryptedHost>()
for (host in hostManager.hosts()) {
// aes iv
val iv = ArrayUtils.subarray(host.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedHost = EncryptedHost()
encryptedHost.id = host.id
encryptedHost.name = host.name.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.protocol = host.protocol.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.host = host.host.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.port = "${host.port}".aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.username = host.username.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.remark = host.remark.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.authentication = ohMyJson.encodeToString(host.authentication)
.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.proxy = ohMyJson.encodeToString(host.proxy).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.options =
ohMyJson.encodeToString(host.options).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.tunnelings =
ohMyJson.encodeToString(host.tunnelings).aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.sort = host.sort
encryptedHost.parentId = host.parentId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.ownerId = host.ownerId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.creatorId = host.creatorId.aesCBCEncrypt(key, iv).encodeBase64String()
encryptedHost.createDate = host.createDate
encryptedHost.updateDate = host.updateDate
encryptedHosts.add(encryptedHost)
}
return ohMyJson.encodeToString(encryptedHosts)
}
protected fun encodeDeletedData(config: SyncConfig): String {
return ohMyJson.encodeToString(deleteDataManager.getDeletedData())
}
protected fun decodeDeletedData(text: String, config: SyncConfig): List<DeletedData> {
val deletedData = ohMyJson.decodeFromString<List<DeletedData>>(text).toMutableList()
// 和本地融合
deletedData.addAll(deleteDataManager.getDeletedData())
return deletedData
}
protected fun decodeSnippets(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedSnippets = ohMyJson.decodeFromString<List<Snippet>>(text)
val snippets = snippetManager.snippets().associateBy { it.id }
for (encryptedSnippet in encryptedSnippets) {
val oldHost = snippets[encryptedSnippet.id]
// 如果一样,则无需配置
if (oldHost != null) {
if (oldHost.updateDate >= encryptedSnippet.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedSnippet.id)
val snippet = encryptedSnippet.copy(
name = encryptedSnippet.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
parentId = encryptedSnippet.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
snippet = encryptedSnippet.snippet.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
SwingUtilities.invokeLater { snippetManager.addSnippet(snippet) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode snippet: ${encryptedSnippet.id} failed. error: {}", e.message, e)
}
}
}
SwingUtilities.invokeLater {
deletedData.forEach {
snippetManager.removeSnippet(it.id)
deleteDataManager.removeSnippet(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) {
log.debug("Decode Snippets: {}", text)
}
}
protected fun encodeSnippets(key: ByteArray): String {
val snippets = mutableListOf<Snippet>()
for (snippet in snippetManager.snippets()) {
// aes iv
val iv = ArrayUtils.subarray(snippet.id.padEnd(16, '0').toByteArray(), 0, 16)
snippets.add(
snippet.copy(
name = snippet.name.aesCBCEncrypt(key, iv).encodeBase64String(),
snippet = snippet.snippet.aesCBCEncrypt(key, iv).encodeBase64String(),
parentId = snippet.parentId.aesCBCEncrypt(key, iv).encodeBase64String(),
)
)
}
return ohMyJson.encodeToString(snippets)
}
protected fun decodeKeys(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeys = ohMyJson.decodeFromString<List<OhKeyPair>>(text)
val keys = keyManager.getOhKeyPairs().associateBy { it.id }
for (encryptedKey in encryptedKeys) {
val k = keys[encryptedKey.id]
if (k != null) {
if (k.updateDate > encryptedKey.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(encryptedKey.id)
val keyPair = OhKeyPair(
id = encryptedKey.id,
publicKey = encryptedKey.publicKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
privateKey = encryptedKey.privateKey.decodeBase64().aesCBCDecrypt(key, iv).encodeBase64String(),
type = encryptedKey.type.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
name = encryptedKey.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
remark = encryptedKey.remark.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
length = encryptedKey.length,
sort = encryptedKey.sort
)
SwingUtilities.invokeLater { keyManager.addOhKeyPair(keyPair, accountOwner) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode key: ${encryptedKey.id} failed. error: {}", e.message, e)
}
}
}
SwingUtilities.invokeLater {
deletedData.forEach {
keyManager.removeOhKeyPair(it.id)
deleteDataManager.removeKeyPair(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) {
log.debug("Decode keys: {}", text)
}
}
protected fun encodeKeys(key: ByteArray): String {
val encryptedKeys = mutableListOf<OhKeyPair>()
for (keyPair in keyManager.getOhKeyPairs()) {
// aes iv
val iv = ArrayUtils.subarray(keyPair.id.padEnd(16, '0').toByteArray(), 0, 16)
val encryptedKeyPair = OhKeyPair(
id = keyPair.id,
publicKey = keyPair.publicKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
privateKey = keyPair.privateKey.decodeBase64().aesCBCEncrypt(key, iv).encodeBase64String(),
type = keyPair.type.aesCBCEncrypt(key, iv).encodeBase64String(),
name = keyPair.name.aesCBCEncrypt(key, iv).encodeBase64String(),
remark = keyPair.remark.aesCBCEncrypt(key, iv).encodeBase64String(),
length = keyPair.length,
sort = keyPair.sort
)
encryptedKeys.add(encryptedKeyPair)
}
return ohMyJson.encodeToString(encryptedKeys)
}
protected fun decodeKeywordHighlights(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedKeywordHighlights = ohMyJson.decodeFromString<List<KeywordHighlight>>(text)
val keywordHighlights = keywordHighlightManager.getKeywordHighlights().associateBy { it.id }
for (e in encryptedKeywordHighlights) {
val keywordHighlight = keywordHighlights[e.id]
if (keywordHighlight != null) {
if (keywordHighlight.updateDate >= e.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(e.id)
keywordHighlightManager.addKeywordHighlight(
e.copy(
keyword = e.keyword.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
description = e.description.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
), accountOwner
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode KeywordHighlight: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
SwingUtilities.invokeLater {
deletedData.forEach {
keywordHighlightManager.removeKeywordHighlight(it.id)
deleteDataManager.removeKeywordHighlight(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) {
log.debug("Decode KeywordHighlight: {}", text)
}
}
protected fun encodeKeywordHighlights(key: ByteArray): String {
val keywordHighlights = mutableListOf<KeywordHighlight>()
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights()) {
// aes iv
val iv = getIv(keywordHighlight.id)
val encryptedKeyPair = keywordHighlight.copy(
keyword = keywordHighlight.keyword.aesCBCEncrypt(key, iv).encodeBase64String(),
description = keywordHighlight.description.aesCBCEncrypt(key, iv).encodeBase64String(),
)
keywordHighlights.add(encryptedKeyPair)
}
return ohMyJson.encodeToString(keywordHighlights)
}
protected fun decodeMacros(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedMacros = ohMyJson.decodeFromString<List<Macro>>(text)
val macros = macroManager.getMacros().associateBy { it.id }
for (e in encryptedMacros) {
val macro = macros[e.id]
if (macro != null) {
if (macro.updateDate >= e.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(e.id)
macroManager.addMacro(
e.copy(
name = e.name.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
macro = e.macro.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
)
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode Macro: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
SwingUtilities.invokeLater {
deletedData.forEach {
macroManager.removeMacro(it.id)
deleteDataManager.removeMacro(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) {
log.debug("Decode Macros: {}", text)
}
}
protected fun encodeMacros(key: ByteArray): String {
val macros = mutableListOf<Macro>()
for (macro in macroManager.getMacros()) {
val iv = getIv(macro.id)
macros.add(
macro.copy(
name = macro.name.aesCBCEncrypt(key, iv).encodeBase64String(),
macro = macro.macro.aesCBCEncrypt(key, iv).encodeBase64String()
)
)
}
return ohMyJson.encodeToString(macros)
}
protected fun decodeKeymaps(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
val localKeymaps = keymapManager.getKeymaps().associateBy { it.name }
val remoteKeymaps = ohMyJson.decodeFromString<List<JsonObject>>(text).mapNotNull { Keymap.fromJSON(it) }
for (keymap in remoteKeymaps) {
val localKeymap = localKeymaps[keymap.name]
if (localKeymap != null) {
if (localKeymap.updateDate > keymap.updateDate) {
continue
}
}
keymapManager.addKeymap(keymap)
}
SwingUtilities.invokeLater {
deletedData.forEach {
keymapManager.removeKeymap(it.id)
deleteDataManager.removeKeymap(it.id, it.deleteDate)
}
}
if (log.isDebugEnabled) {
log.debug("Decode Keymaps: {}", text)
}
}
protected fun encodeKeymaps(): String {
val keymaps = mutableListOf<JsonObject>()
for (keymap in keymapManager.getKeymaps()) {
// 只读的是内置的
if (keymap.isReadonly) {
continue
}
keymaps.add(keymap.toJSONObject())
}
return ohMyJson.encodeToString(keymaps)
}
protected open fun getKey(config: SyncConfig): ByteArray {
return ArrayUtils.subarray(config.token.padEnd(16, '0').toByteArray(), 0, 16)
}
protected fun getIv(id: String): ByteArray {
return ArrayUtils.subarray(id.padEnd(16, '0').toByteArray(), 0, 16)
}
}

View File

@@ -0,0 +1,14 @@
package app.termora.plugins.sync
import app.termora.OptionsPane
import app.termora.SettingsOptionExtension
class SyncSettingsOptionExtension private constructor() : SettingsOptionExtension {
companion object {
val instance by lazy { SyncSettingsOptionExtension() }
}
override fun createSettingsOption(): OptionsPane.Option {
return CloudSyncOption()
}
}

View File

@@ -0,0 +1,40 @@
package app.termora.plugins.sync
enum class SyncType {
GitLab,
GitHub,
Gitee,
WebDAV,
}
enum class SyncPolicy {
Manual,
OnChange,
}
enum class SyncRange {
Hosts,
KeyPairs,
KeywordHighlights,
Macros,
Keymap,
Snippets,
}
data class SyncConfig(
val type: SyncType,
val token: String,
val gistId: String,
val options: Map<String, String>,
val ranges: Set<SyncRange> = setOf(SyncRange.Hosts, SyncRange.KeyPairs, SyncRange.KeywordHighlights),
)
data class GistFile(
val filename: String,
val content: String
)
data class GistResponse(
val config: SyncConfig,
val gists: List<GistFile>
)

View File

@@ -0,0 +1,19 @@
package app.termora.plugins.sync
import app.termora.database.DatabaseChangedExtension
class SyncDatabaseChangedExtension : DatabaseChangedExtension {
companion object {
val instance by lazy { SyncDatabaseChangedExtension() }
}
override fun onDataChanged(
id: String,
type: String,
action: DatabaseChangedExtension.Action,
source: DatabaseChangedExtension.Source
) {
SyncManager.getInstance().triggerOnChanged()
}
}

View File

@@ -0,0 +1,174 @@
package app.termora.plugins.sync
import app.termora.ApplicationScope
import app.termora.Disposable
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import kotlin.random.Random
import kotlin.time.Duration.Companion.seconds
@Suppress("DuplicatedCode")
class SyncManager private constructor() : Disposable {
companion object {
private val log = LoggerFactory.getLogger(SyncManager::class.java)
fun getInstance(): SyncManager {
return ApplicationScope.forApplicationScope().getOrCreate(SyncManager::class) { SyncManager() }
}
}
private val sync get() = SyncProperties.getInstance()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
private var disableTrigger = false
private fun trigger() {
trigger(getSyncConfig())
}
fun triggerOnChanged() {
if (sync.policy == SyncPolicy.OnChange.name) {
trigger()
}
}
private fun trigger(config: SyncConfig) {
if (disableTrigger) return
job?.cancel()
if (log.isInfoEnabled) {
log.info("Automatic synchronisation is interrupted")
}
job = coroutineScope.launch {
// 因为会频繁调用,等待 10 - 30 秒
val seconds = Random.nextInt(10, 30)
if (log.isInfoEnabled) {
log.info("Trigger synchronisation, which will take place after {} seconds", seconds)
}
delay(seconds.seconds)
if (!disableTrigger) {
try {
if (log.isInfoEnabled) {
log.info("Automatic synchronisation begin")
}
// 如果已经开始,设置为 null
// 因为同步的时候会修改数据,避免被中断
job = null
sync(config)
sync.lastSyncTime = System.currentTimeMillis()
if (log.isInfoEnabled) {
log.info("Automatic synchronisation end")
}
} catch (e: Exception) {
if (log.isErrorEnabled) {
log.error(e.message, e)
}
}
}
}
}
fun sync(config: SyncConfig): SyncResponse {
return syncImmediately(config)
}
private fun getSyncConfig(): SyncConfig {
val range = mutableSetOf<SyncRange>()
if (sync.rangeHosts) {
range.add(SyncRange.Hosts)
}
if (sync.rangeKeyPairs) {
range.add(SyncRange.KeyPairs)
}
if (sync.rangeKeywordHighlights) {
range.add(SyncRange.KeywordHighlights)
}
if (sync.rangeMacros) {
range.add(SyncRange.Macros)
}
if (sync.rangeKeymap) {
range.add(SyncRange.Keymap)
}
if (sync.rangeSnippets) {
range.add(SyncRange.Snippets)
}
return SyncConfig(
type = sync.type,
token = sync.token,
gistId = sync.gist,
options = mapOf("domain" to sync.domain),
ranges = range
)
}
private fun syncImmediately(config: SyncConfig): SyncResponse {
synchronized(this) {
return SyncResponse(pull(config), push(config))
}
}
fun pull(config: SyncConfig): GistResponse {
synchronized(this) {
disableTrigger = true
try {
return SyncerProvider.getInstance().getSyncer(config.type).pull(config)
} finally {
disableTrigger = false
}
}
}
fun push(config: SyncConfig): GistResponse {
synchronized(this) {
try {
disableTrigger = true
return SyncerProvider.getInstance().getSyncer(config.type).push(config)
} finally {
disableTrigger = false
}
}
}
override fun dispose() {
coroutineScope.cancel()
}
private class SyncerProvider private constructor() {
companion object {
fun getInstance(): SyncerProvider {
return ApplicationScope.forApplicationScope().getOrCreate(SyncerProvider::class) { SyncerProvider() }
}
}
fun getSyncer(type: SyncType): Syncer {
return when (type) {
SyncType.GitHub -> GitHubSyncer.getInstance()
SyncType.Gitee -> GiteeSyncer.getInstance()
SyncType.GitLab -> GitLabSyncer.getInstance()
SyncType.WebDAV -> WebDAVSyncer.getInstance()
}
}
}
data class SyncResponse(val pull: GistResponse, val push: GistResponse)
}

View File

@@ -0,0 +1,31 @@
package app.termora.plugins.sync
import app.termora.SettingsOptionExtension
import app.termora.database.DatabaseChangedExtension
import app.termora.plugin.Extension
import app.termora.plugin.ExtensionSupport
import app.termora.plugin.Plugin
class SyncPlugin : Plugin {
private val support = ExtensionSupport()
init {
support.addExtension(SettingsOptionExtension::class.java) { SyncSettingsOptionExtension.instance }
support.addExtension(DatabaseChangedExtension::class.java) { SyncDatabaseChangedExtension.instance }
}
override fun getAuthor(): String {
return "TermoraDev"
}
override fun getName(): String {
return "Sync"
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}
}

View File

@@ -0,0 +1,70 @@
package app.termora.plugins.sync
import app.termora.ApplicationScope
import app.termora.database.DatabaseManager
import org.apache.commons.lang3.StringUtils
/**
* 同步配置
*/
class SyncProperties private constructor(databaseManager: DatabaseManager) :
DatabaseManager.IProperties(databaseManager, "Setting.Sync") {
companion object {
fun getInstance(): SyncProperties {
return ApplicationScope.forApplicationScope()
.getOrCreate(SyncProperties::class) { SyncProperties(DatabaseManager.getInstance()) }
}
}
private inner class SyncTypePropertyDelegate(defaultValue: SyncType) :
PropertyDelegate<SyncType>(defaultValue) {
override fun convertValue(value: String): SyncType {
return try {
SyncType.valueOf(value)
} catch (_: Exception) {
initializer.invoke()
}
}
}
/**
* 同步类型
*/
var type by SyncTypePropertyDelegate(SyncType.GitHub)
/**
* 范围
*/
var rangeHosts by BooleanPropertyDelegate(true)
var rangeKeyPairs by BooleanPropertyDelegate(true)
var rangeSnippets by BooleanPropertyDelegate(true)
var rangeKeywordHighlights by BooleanPropertyDelegate(true)
var rangeMacros by BooleanPropertyDelegate(true)
var rangeKeymap by BooleanPropertyDelegate(true)
/**
* Token
*/
var token by StringPropertyDelegate(String())
/**
* Gist ID
*/
var gist by StringPropertyDelegate(String())
/**
* Domain
*/
var domain by StringPropertyDelegate(String())
/**
* 最后同步时间
*/
var lastSyncTime by LongPropertyDelegate(0L)
/**
* 同步策略,为空就是默认手动
*/
var policy by StringPropertyDelegate(StringUtils.EMPTY)
}

View File

@@ -0,0 +1,7 @@
package app.termora.plugins.sync
interface Syncer {
fun pull(config: SyncConfig): GistResponse
fun push(config: SyncConfig): GistResponse
}

View File

@@ -0,0 +1,192 @@
package app.termora.plugins.sync
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.DeletedData
import app.termora.ResponseException
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.io.IOUtils
import org.slf4j.LoggerFactory
class WebDAVSyncer private constructor() : SafetySyncer() {
companion object {
private val log = LoggerFactory.getLogger(WebDAVSyncer::class.java)
fun getInstance(): WebDAVSyncer {
return ApplicationScope.forApplicationScope().getOrCreate(WebDAVSyncer::class) { WebDAVSyncer() }
}
}
override fun pull(config: SyncConfig): GistResponse {
val response = httpClient.newCall(newRequestBuilder(config).get().build()).execute()
if (response.isSuccessful.not()) {
IOUtils.closeQuietly(response)
if (response.code == 404) {
return GistResponse(config, emptyList())
}
throw ResponseException(response.code, response)
}
val text = response.use { resp -> resp.body?.use { it.string() } }
?: throw ResponseException(response.code, response)
val json = ohMyJson.decodeFromString<JsonObject>(text)
val deletedData = mutableListOf<DeletedData>()
json["DeletedData"]?.jsonPrimitive?.content?.let { deletedData.addAll(decodeDeletedData(it, config)) }
// decode hosts
if (config.ranges.contains(SyncRange.Hosts)) {
json["Hosts"]?.jsonPrimitive?.content?.let {
decodeHosts(it, deletedData.filter { e -> e.type == "Host" }, config)
}
}
// decode KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) {
json["KeyPairs"]?.jsonPrimitive?.content?.let {
decodeKeys(it, deletedData.filter { e -> e.type == "KeyPair" }, config)
}
}
// decode Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
json["KeywordHighlights"]?.jsonPrimitive?.content?.let {
decodeKeywordHighlights(it, deletedData.filter { e -> e.type == "KeywordHighlight" }, config)
}
}
// decode Macros
if (config.ranges.contains(SyncRange.Macros)) {
json["Macros"]?.jsonPrimitive?.content?.let {
decodeMacros(it, deletedData.filter { e -> e.type == "Macro" }, config)
}
}
// decode Keymaps
if (config.ranges.contains(SyncRange.Keymap)) {
json["Keymaps"]?.jsonPrimitive?.content?.let {
decodeKeymaps(it, deletedData.filter { e -> e.type == "Keymap" }, config)
}
}
// decode Snippets
if (config.ranges.contains(SyncRange.Snippets)) {
json["Snippets"]?.jsonPrimitive?.content?.let {
decodeSnippets(it, deletedData.filter { e -> e.type == "Snippet" }, config)
}
}
return GistResponse(config, emptyList())
}
override fun push(config: SyncConfig): GistResponse {
// aes key
val key = getKey(config)
val json = buildJsonObject {
// Hosts
if (config.ranges.contains(SyncRange.Hosts)) {
val hostsContent = encodeHosts(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedHosts: {}", hostsContent)
}
put("Hosts", hostsContent)
}
// Snippets
if (config.ranges.contains(SyncRange.Snippets)) {
val snippetsContent = encodeSnippets(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedSnippets: {}", snippetsContent)
}
put("Snippets", snippetsContent)
}
// KeyPairs
if (config.ranges.contains(SyncRange.KeyPairs)) {
val keysContent = encodeKeys(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedKeys: {}", keysContent)
}
put("KeyPairs", keysContent)
}
// Highlights
if (config.ranges.contains(SyncRange.KeywordHighlights)) {
val keywordHighlightsContent = encodeKeywordHighlights(key)
if (log.isDebugEnabled) {
log.debug("Push keywordHighlights: {}", keywordHighlightsContent)
}
put("KeywordHighlights", keywordHighlightsContent)
}
// Macros
if (config.ranges.contains(SyncRange.Macros)) {
val macrosContent = encodeMacros(key)
if (log.isDebugEnabled) {
log.debug("Push macros: {}", macrosContent)
}
put("Macros", macrosContent)
}
// Keymap
if (config.ranges.contains(SyncRange.Keymap)) {
val keymapsContent = encodeKeymaps()
if (log.isDebugEnabled) {
log.debug("Push keymaps: {}", keymapsContent)
}
put("Keymaps", keymapsContent)
}
// deletedData
val deletedData = encodeDeletedData(config)
if (log.isDebugEnabled) {
log.debug("Push DeletedData: {}", deletedData)
}
put("DeletedData", deletedData)
}
val response = httpClient.newCall(
newRequestBuilder(config).put(
ohMyJson.encodeToString(json)
.toRequestBody("application/json".toMediaType())
).build()
).execute()
if (response.isSuccessful.not()) {
IOUtils.closeQuietly(response)
throw ResponseException(response.code, response)
}
return GistResponse(
config = config,
gists = emptyList()
)
}
private fun getWebDavFileUrl(config: SyncConfig): String {
return config.options["domain"] ?: throw IllegalStateException("domain is not defined")
}
override fun getKey(config: SyncConfig): ByteArray {
return PBKDF2.generateSecret(
config.gistId.toCharArray(),
config.token.toByteArray(),
10000, 128
)
}
private fun newRequestBuilder(config: SyncConfig): Request.Builder {
return Request.Builder()
.header("Authorization", Credentials.basic(config.gistId, config.token, Charsets.UTF_8))
.url(getWebDavFileUrl(config))
}
}

View File

@@ -0,0 +1,22 @@
<termora-plugin>
<id>sync</id>
<name>Sync</name>
<version>${projectVersion}</version>
<termora-version since=">=${rootProjectVersion}" until=""/>
<entry>app.termora.plugins.sync.SyncPlugin</entry>
<descriptions>
<description>Data sync to Gist or WebDAV</description>
<description language="zh_CN">支持将配置同步到 Gist 或 WebDAV</description>
<description language="zh_TW">支援將設定同步到 Gist 或 WebDAV</description>
</descriptions>
<vendor url="https://github.com/TermoraDev">TermoraDev</vendor>
</termora-plugin>

View File

@@ -0,0 +1,5 @@
<svg t="1734861958843" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="957"
width="16" height="16">
<path d="M745.8250113 860.44577104H236.69178523c-71.72210261 0-132.18006967-25.45957401-174.81968143-73.62590017-37.24427923-42.06698515-57.75472326-98.24884441-57.75472326-158.21227029 0-107.27948831 69.65965526-215.86535853 202.9329212-219.97785873 5.46969963-58.84544061 29.97737487-111.98072798 71.90554144-155.22271319 54.55693829-56.26986031 135.67036519-89.86023641 216.99573832-89.8602364 110.89620788 0 203.28740434 58.92972332 251.54173156 158.96833572a315.3834013 315.3834013 0 0 1 22.52827113-0.82547472c166.70871058 0 253.92891521 134.19293898 253.92891521 266.7423652 0 71.03668593-24.38744844 137.18621443-68.66437568 186.27345308-50.62167966 56.0913793-123.04779075 85.74029949-209.46111242 85.7402995z m-530.91906573-387.16871595c-100.33608079 0-146.03341949 80.51725072-146.0334195 155.33674273 0 43.48863606 15.12130874 85.50604316 41.48444292 115.28138743 30.00216389 33.88164721 73.6891122 51.78924301 126.33481624 51.78924301h509.13322607c67.57117942 0 123.37128759-22.25435234 161.36419555-64.36099995 33.52716406-37.16123597 51.99375251-87.91057915 51.9937525-142.8926493 0-97.2771144-59.2011632-201.98598022-189.17376968-201.98598023-12.22223159 0-25.00965311 0.95809603-38.00034466 2.85569635l-25.44098224 3.7059601-9.37892978-23.93133027c-35.07399957-89.46485136-108.42722043-140.77318718-201.249746-140.77318718-64.15896934 0-127.89900406 26.23423123-170.50515064 70.18022491-37.47977502 38.65353563-56.39628437 87.96263611-54.70939082 142.58278642l1.13657705 36.83154187-36.67289206-3.60432507a209.34212508 209.34212508 0 0 0-20.28238495-1.01635027z"
fill="#6C707E" p-id="958"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,5 @@
<svg t="1734861958843" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="957"
width="16" height="16">
<path d="M745.8250113 860.44577104H236.69178523c-71.72210261 0-132.18006967-25.45957401-174.81968143-73.62590017-37.24427923-42.06698515-57.75472326-98.24884441-57.75472326-158.21227029 0-107.27948831 69.65965526-215.86535853 202.9329212-219.97785873 5.46969963-58.84544061 29.97737487-111.98072798 71.90554144-155.22271319 54.55693829-56.26986031 135.67036519-89.86023641 216.99573832-89.8602364 110.89620788 0 203.28740434 58.92972332 251.54173156 158.96833572a315.3834013 315.3834013 0 0 1 22.52827113-0.82547472c166.70871058 0 253.92891521 134.19293898 253.92891521 266.7423652 0 71.03668593-24.38744844 137.18621443-68.66437568 186.27345308-50.62167966 56.0913793-123.04779075 85.74029949-209.46111242 85.7402995z m-530.91906573-387.16871595c-100.33608079 0-146.03341949 80.51725072-146.0334195 155.33674273 0 43.48863606 15.12130874 85.50604316 41.48444292 115.28138743 30.00216389 33.88164721 73.6891122 51.78924301 126.33481624 51.78924301h509.13322607c67.57117942 0 123.37128759-22.25435234 161.36419555-64.36099995 33.52716406-37.16123597 51.99375251-87.91057915 51.9937525-142.8926493 0-97.2771144-59.2011632-201.98598022-189.17376968-201.98598023-12.22223159 0-25.00965311 0.95809603-38.00034466 2.85569635l-25.44098224 3.7059601-9.37892978-23.93133027c-35.07399957-89.46485136-108.42722043-140.77318718-201.249746-140.77318718-64.15896934 0-127.89900406 26.23423123-170.50515064 70.18022491-37.47977502 38.65353563-56.39628437 87.96263611-54.70939082 142.58278642l1.13657705 36.83154187-36.67289206-3.60432507a209.34212508 209.34212508 0 0 0-20.28238495-1.01635027z"
fill="#CED0D6" p-id="958"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB