mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
chore!: migrate to version 2.x
This commit is contained in:
14
plugins/sync/build.gradle.kts
Normal file
14
plugins/sync/build.gradle.kts
Normal 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")
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package app.termora.plugins.sync
|
||||
|
||||
interface Syncer {
|
||||
fun pull(config: SyncConfig): GistResponse
|
||||
|
||||
fun push(config: SyncConfig): GistResponse
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
22
plugins/sync/src/main/resources/META-INF/plugin.xml
Normal file
22
plugins/sync/src/main/resources/META-INF/plugin.xml
Normal 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>
|
||||
5
plugins/sync/src/main/resources/META-INF/pluginIcon.svg
Normal file
5
plugins/sync/src/main/resources/META-INF/pluginIcon.svg
Normal 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 |
@@ -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 |
Reference in New Issue
Block a user