chore: improve sync plugin

This commit is contained in:
hstyi
2025-06-16 17:37:10 +08:00
committed by hstyi
parent a64aef24b2
commit e6a45d25cd
25 changed files with 293 additions and 485 deletions

View File

@@ -3,7 +3,7 @@ plugins {
}
project.version = "0.0.1"
project.version = "0.0.2"
dependencies {

View File

@@ -1,22 +1,16 @@
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.snippet.Snippet
import app.termora.snippet.SnippetManager
import app.termora.tag.TagManager
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.jgoodies.forms.builder.FormBuilder
@@ -25,21 +19,14 @@ 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.*
@@ -53,6 +40,7 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
private val database get() = DatabaseManager.getInstance()
private val hostManager get() = HostManager.getInstance()
private val tagManager get() = TagManager.getInstance()
private val snippetManager get() = SnippetManager.getInstance()
private val keymapManager get() = KeymapManager.getInstance()
private val macroManager get() = MacroManager.getInstance()
@@ -72,17 +60,15 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
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 syncConfigButton = JButton(SyncI18n.getString("termora.settings.sync"), Icons.settingSync)
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 keysCheckBox = JCheckBox(SyncI18n.getString("termora.settings.sync.range.keys"))
val snippetsCheckBox = JCheckBox(SyncI18n.getString("termora.snippet.title"))
val keywordHighlightsCheckBox = JCheckBox(SyncI18n.getString("termora.settings.sync.range.keyword-highlights"))
val macrosCheckBox = JCheckBox(SyncI18n.getString("termora.macro"))
val keymapCheckBox = JCheckBox(SyncI18n.getString("termora.settings.keymap"))
val visitGistBtn = JButton(Icons.externalLink)
val getTokenBtn = JButton(Icons.externalLink)
private val owner get() = SwingUtilities.getWindowAncestor(this)
@@ -191,9 +177,6 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
}
}
exportConfigButton.addActionListener { export() }
importConfigButton.addActionListener { import() }
keysCheckBox.addActionListener { refreshButtons() }
hostsCheckBox.addActionListener { refreshButtons() }
snippetsCheckBox.addActionListener { refreshButtons() }
@@ -212,7 +195,7 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
}
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(owner, message = I18n.getString("termora.settings.sync.done"))
OptionPane.showMessageDialog(owner, message = SyncI18n.getString("termora.settings.sync.done"))
}
}
@@ -233,302 +216,6 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
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 {
@@ -593,8 +280,6 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
}
withContext(Dispatchers.Swing) {
exportConfigButton.isEnabled = false
importConfigButton.isEnabled = false
syncConfigButton.isEnabled = false
typeComboBox.isEnabled = false
gistTextField.isEnabled = false
@@ -606,7 +291,7 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
hostsCheckBox.isEnabled = false
snippetsCheckBox.isEnabled = false
domainTextField.isEnabled = false
syncConfigButton.text = "${I18n.getString("termora.settings.sync")}..."
syncConfigButton.text = "${SyncI18n.getString("termora.settings.sync")}..."
}
val syncConfig = getSyncConfig()
@@ -624,8 +309,6 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
// 恢复状态
withContext(Dispatchers.Swing) {
syncConfigButton.isEnabled = true
exportConfigButton.isEnabled = true
importConfigButton.isEnabled = true
keysCheckBox.isEnabled = true
hostsCheckBox.isEnabled = true
snippetsCheckBox.isEnabled = true
@@ -636,7 +319,7 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
tokenTextField.isEnabled = true
domainTextField.isEnabled = true
keywordHighlightsCheckBox.isEnabled = true
syncConfigButton.text = I18n.getString("termora.settings.sync")
syncConfigButton.text = SyncI18n.getString("termora.settings.sync")
}
// 如果失败,提示错误
@@ -662,7 +345,7 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
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"
lastSyncTimeLabel.text = "${SyncI18n.getString("termora.settings.sync.last-sync-time")}: $date"
if (push && gistTextField.text.isBlank()) {
gistTextField.text = syncResult.map { it.config }.getOrDefault(syncConfig).gistId
}
@@ -715,7 +398,7 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
if (url.isNullOrBlank()) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.settings.sync.webdav.help")
SyncI18n.getString("termora.settings.sync.webdav.help")
)
} else {
val uri = URI.create(url)
@@ -759,18 +442,18 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
): Component {
var text = value?.toString() ?: StringUtils.EMPTY
if (value == SyncPolicy.Manual) {
text = I18n.getString("termora.settings.sync.policy.manual")
text = SyncI18n.getString("termora.settings.sync.policy.manual")
} else if (value == SyncPolicy.OnChange) {
text = I18n.getString("termora.settings.sync.policy.on-change")
text = SyncI18n.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")}: ${
lastSyncTimeLabel.text = "${SyncI18n.getString("termora.settings.sync.last-sync-time")}: ${
if (lastSyncTime > 0) DateFormatUtils.format(
Date(lastSyncTime), I18n.getString("termora.date-format")
Date(lastSyncTime), SyncI18n.getString("termora.date-format")
) else "-"
}"
@@ -784,7 +467,7 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
}
override fun getTitle(): String {
return I18n.getString("termora.settings.sync")
return SyncI18n.getString("termora.settings.sync")
}
override fun getJComponent(): JComponent {
@@ -821,21 +504,21 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
box.add(Box.createHorizontalStrut(4))
box.add(domainTextField)
}
builder.add("${I18n.getString("termora.settings.sync.type")}:").xy(1, rows)
builder.add("${SyncI18n.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")
SyncI18n.getString("termora.new-host.general.username")
} else {
I18n.getString("termora.settings.sync.token")
SyncI18n.getString("termora.settings.sync.token")
}
val gistText = if (isWebDAV) {
I18n.getString("termora.new-host.general.password")
SyncI18n.getString("termora.new-host.general.password")
} else {
I18n.getString("termora.settings.sync.gist")
SyncI18n.getString("termora.settings.sync.gist")
}
if (typeComboBox.selectedItem == SyncType.Gitee || isWebDAV) {
@@ -853,17 +536,15 @@ class CloudSyncOption : JPanel(BorderLayout()), OptionsPane.PluginOption {
.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("${SyncI18n.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("${SyncI18n.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"))
.layout(FormLayout("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 }

View File

@@ -0,0 +1,57 @@
package app.termora.plugins.sync
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.DeletedData
import app.termora.EnableManager
import app.termora.database.DatabaseChangedExtension
import app.termora.database.DatabaseManager
/**
* 仅标记
*/
class DeleteDataManager private constructor() : DatabaseChangedExtension {
companion object {
fun getInstance(): DeleteDataManager {
return ApplicationScope.Companion.forApplicationScope()
.getOrCreate(DeleteDataManager::class) { DeleteDataManager() }
}
}
private val data = mutableMapOf<String, DeletedData>()
private val databaseManager get() = DatabaseManager.Companion.getInstance()
private val enableManager get() = EnableManager.Companion.getInstance()
init {
for (e in databaseManager.properties.getProperties()) {
if (e.key.startsWith("Setting.Properties.DeleteData_")) {
val deletedData = runCatching { ohMyJson.decodeFromString<DeletedData>(e.value) }
.getOrNull() ?: continue
data[deletedData.id] = deletedData
}
}
}
private fun addDeletedData(deletedData: DeletedData) {
data[deletedData.id] = deletedData
}
fun getDeletedData(): List<DeletedData> {
return data.values.sortedBy { it.deleteDate }
}
override fun onDataChanged(
id: String,
type: String,
action: DatabaseChangedExtension.Action,
source: DatabaseChangedExtension.Source
) {
if (action != DatabaseChangedExtension.Action.Removed) return
if (id.isBlank() || type.isBlank()) return
val key = "DeleteData_${type}_${id}"
val deletedData = DeletedData(id, type, System.currentTimeMillis())
enableManager.setFlag(key, ohMyJson.encodeToString(deletedData))
addDeletedData(deletedData)
}
}

View File

@@ -37,6 +37,9 @@ abstract class GitSyncer : SafetySyncer() {
// decode hosts
if (config.ranges.contains(SyncRange.Hosts)) {
gistResponse.gists.findLast { it.filename == "Tags" }?.let {
decodeTags(it.content, deletedData.filter { e -> e.type == "Tag" }, config)
}
gistResponse.gists.findLast { it.filename == "Hosts" }?.let {
decodeHosts(it.content, deletedData.filter { e -> e.type == "Host" }, config)
}
@@ -103,6 +106,12 @@ abstract class GitSyncer : SafetySyncer() {
log.debug("Push encryptedHosts: {}", hostsContent)
}
gistFiles.add(GistFile("Hosts", hostsContent))
val tagsContent = encodeHosts(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedTags: {}", tagsContent)
}
gistFiles.add(GistFile("Tags", tagsContent))
}

View File

@@ -8,6 +8,7 @@ import app.termora.AES.encodeBase64String
import app.termora.Application.ohMyJson
import app.termora.account.AccountManager
import app.termora.account.AccountOwner
import app.termora.database.DatabaseChangedExtension
import app.termora.database.OwnerType
import app.termora.highlight.KeywordHighlight
import app.termora.highlight.KeywordHighlightManager
@@ -19,6 +20,8 @@ import app.termora.macro.Macro
import app.termora.macro.MacroManager
import app.termora.snippet.Snippet
import app.termora.snippet.SnippetManager
import app.termora.tag.Tag
import app.termora.tag.TagManager
import kotlinx.serialization.json.JsonObject
import org.apache.commons.lang3.ArrayUtils
import org.slf4j.LoggerFactory
@@ -39,6 +42,7 @@ abstract class SafetySyncer : Syncer {
protected val snippetManager get() = SnippetManager.getInstance()
protected val deleteDataManager get() = DeleteDataManager.getInstance()
protected val accountManager get() = AccountManager.getInstance()
protected val tagManager get() = TagManager.getInstance()
protected val accountOwner
get() = AccountOwner(
id = accountManager.getAccountId(),
@@ -90,10 +94,11 @@ abstract class SafetySyncer : Syncer {
parentId = encryptedHost.parentId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
ownerId = encryptedHost.ownerId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
creatorId = encryptedHost.creatorId.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
ownerType = OwnerType.User.name,
createDate = encryptedHost.createDate,
updateDate = encryptedHost.updateDate,
)
SwingUtilities.invokeLater { hostManager.addHost(host) }
SwingUtilities.invokeLater { hostManager.addHost(host, DatabaseChangedExtension.Source.Sync) }
} catch (e: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode host: ${encryptedHost.id} failed. error: {}", e.message, e)
@@ -104,7 +109,6 @@ abstract class SafetySyncer : Syncer {
SwingUtilities.invokeLater {
deletedData.forEach {
hostManager.removeHost(it.id)
deleteDataManager.removeHost(it.id, it.deleteDate)
}
}
@@ -192,7 +196,6 @@ abstract class SafetySyncer : Syncer {
SwingUtilities.invokeLater {
deletedData.forEach {
snippetManager.removeSnippet(it.id)
deleteDataManager.removeSnippet(it.id, it.deleteDate)
}
}
@@ -256,7 +259,6 @@ abstract class SafetySyncer : Syncer {
SwingUtilities.invokeLater {
deletedData.forEach {
keyManager.removeOhKeyPair(it.id)
deleteDataManager.removeKeyPair(it.id, it.deleteDate)
}
}
@@ -318,7 +320,6 @@ abstract class SafetySyncer : Syncer {
SwingUtilities.invokeLater {
deletedData.forEach {
keywordHighlightManager.removeKeywordHighlight(it.id)
deleteDataManager.removeKeywordHighlight(it.id, it.deleteDate)
}
}
@@ -329,18 +330,76 @@ abstract class SafetySyncer : Syncer {
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)
for (ownerId in accountManager.getOwnerIds()) {
for (keywordHighlight in keywordHighlightManager.getKeywordHighlights(ownerId)) {
// 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 decodeTags(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key
val key = getKey(config)
val encryptedTags = runCatching { ohMyJson.decodeFromString<List<Tag>>(text) }.getOrNull() ?: emptyList()
val tags = accountManager.getOwnerIds().map { tagManager.getTags(it) }
.flatten().associateBy { it.id }
for (e in encryptedTags) {
val tag = tags[e.id]
if (tag != null) {
if (tag.updateDate >= e.updateDate) {
continue
}
}
try {
// aes iv
val iv = getIv(e.id)
tagManager.addTag(
e.copy(
text = e.text.decodeBase64().aesCBCDecrypt(key, iv).decodeToString(),
), accountOwner
)
} catch (ex: Exception) {
if (log.isWarnEnabled) {
log.warn("Decode Tag: ${e.id} failed. error: {}", ex.message, ex)
}
}
}
SwingUtilities.invokeLater {
deletedData.forEach {
tagManager.removeTag(it.id)
}
}
if (log.isDebugEnabled) {
log.debug("Decode Tag: {}", text)
}
}
protected fun encodeTags(key: ByteArray): String {
val tags = mutableListOf<Tag>()
for (ownerId in accountManager.getOwnerIds()) {
for (tag in tagManager.getTags(ownerId)) {
// aes iv
val iv = getIv(tag.id)
val encryptedKeyPair = tag.copy(text = tag.text.aesCBCEncrypt(key, iv).encodeBase64String())
tags.add(encryptedKeyPair)
}
}
return ohMyJson.encodeToString(tags)
}
protected fun decodeMacros(text: String, deletedData: List<DeletedData>, config: SyncConfig) {
// aes key
val key = getKey(config)
@@ -373,7 +432,6 @@ abstract class SafetySyncer : Syncer {
SwingUtilities.invokeLater {
deletedData.forEach {
macroManager.removeMacro(it.id)
deleteDataManager.removeMacro(it.id, it.deleteDate)
}
}
@@ -413,7 +471,6 @@ abstract class SafetySyncer : Syncer {
SwingUtilities.invokeLater {
deletedData.forEach {
keymapManager.removeKeymap(it.id)
deleteDataManager.removeKeymap(it.id, it.deleteDate)
}
}

View File

@@ -0,0 +1,24 @@
package app.termora.plugins.sync
import app.termora.I18n
import app.termora.NamedI18n
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.*
object SyncI18n : NamedI18n("i18n/messages") {
private val log = LoggerFactory.getLogger(SyncI18n::class.java)
override fun getLogger(): Logger {
return log
}
override fun getString(key: String): String {
return try {
substitutor.replace(getBundle().getString(key))
} catch (_: MissingResourceException) {
I18n.getString(key)
}
}
}

View File

@@ -2,6 +2,7 @@ package app.termora.plugins.sync
import app.termora.ApplicationScope
import app.termora.Disposable
import app.termora.account.AccountManager
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import kotlin.random.Random
@@ -21,6 +22,7 @@ class SyncManager private constructor() : Disposable {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
private var disableTrigger = false
private val accountManager get() = AccountManager.getInstance()
private fun trigger() {
@@ -38,6 +40,10 @@ class SyncManager private constructor() : Disposable {
job?.cancel()
if (accountManager.isLocally().not()) {
return
}
if (log.isInfoEnabled) {
log.info("Automatic synchronisation is interrupted")
}
@@ -124,6 +130,10 @@ class SyncManager private constructor() : Disposable {
fun pull(config: SyncConfig): GistResponse {
if (accountManager.isLocally().not()) {
throw IllegalStateException(SyncI18n.getString("termora.plugins.sync.disabled-sync"))
}
synchronized(this) {
disableTrigger = true
try {
@@ -135,6 +145,10 @@ class SyncManager private constructor() : Disposable {
}
fun push(config: SyncConfig): GistResponse {
if (accountManager.isLocally().not()) {
throw IllegalStateException(SyncI18n.getString("termora.plugins.sync.disabled-sync"))
}
synchronized(this) {
try {
disableTrigger = true

View File

@@ -12,6 +12,7 @@ class SyncPlugin : Plugin {
init {
support.addExtension(SettingsOptionExtension::class.java) { SyncSettingsOptionExtension.instance }
support.addExtension(DatabaseChangedExtension::class.java) { SyncDatabaseChangedExtension.instance }
support.addExtension(DatabaseChangedExtension::class.java) { DeleteDataManager.getInstance() }
}
override fun getAuthor(): String {

View File

@@ -44,6 +44,9 @@ class WebDAVSyncer private constructor() : SafetySyncer() {
// decode hosts
if (config.ranges.contains(SyncRange.Hosts)) {
json["Tags"]?.jsonPrimitive?.content?.let {
decodeTags(it, deletedData.filter { e -> e.type == "Tags" }, config)
}
json["Hosts"]?.jsonPrimitive?.content?.let {
decodeHosts(it, deletedData.filter { e -> e.type == "Host" }, config)
}
@@ -98,6 +101,12 @@ class WebDAVSyncer private constructor() : SafetySyncer() {
log.debug("Push encryptedHosts: {}", hostsContent)
}
put("Hosts", hostsContent)
val tagsContent = encodeTags(key)
if (log.isDebugEnabled) {
log.debug("Push encryptedTags: {}", tagsContent)
}
put("Tags", tagsContent)
}
// Snippets

View File

@@ -0,0 +1,22 @@
termora.plugins.sync.disabled-sync=You are already logged in and cannot use this feature
termora.settings.sync=Sync
termora.settings.sync.done=Synchronized data successfully
termora.settings.sync.export=${termora.keymgr.export}
termora.settings.sync.import=${termora.keymgr.import}
termora.settings.sync.import.file-too-large=The file is too large
termora.settings.sync.import.successful=Import data successfully
termora.settings.sync.export-done=The export was successful
termora.settings.sync.export-encrypt=Enter password to encrypt file (optional)
termora.settings.sync.export-done-open-folder=The export was successful. Do you want to open the folder?
termora.settings.sync.range=Range
termora.settings.sync.range.keys=My keys
termora.settings.sync.range.keyword-highlights=${termora.highlight}
termora.settings.sync.last-sync-time=Last sync time
termora.settings.sync.gist=Gist
termora.settings.sync.token=Token
termora.settings.sync.type=Type
termora.settings.sync.webdav.help=WebDAV storage address, e.g. https://yourhost/webdav/termora.json
termora.settings.sync.policy=Sync Policy
termora.settings.sync.policy.manual=Manual
termora.settings.sync.policy.on-change=On Change

View File

@@ -0,0 +1,20 @@
termora.plugins.sync.disabled-sync=你已登录,无法使用此功能
termora.settings.sync=同步
termora.settings.sync.export-done=导出成功
termora.settings.sync.export-encrypt=输入密码加密文件 (可选)
termora.settings.sync.export-done-open-folder=导出成功,是否需要打开所在文件夹?
termora.settings.sync.range=范围
termora.settings.sync.range.keys=我的密钥
termora.settings.sync.last-sync-time=最后同步时间
termora.settings.sync.done=同步数据成功
termora.settings.sync.import.file-too-large=文件太大
termora.settings.sync.import.successful=导入数据成功
termora.settings.sync.gist=片段
termora.settings.sync.token=令牌
termora.settings.sync.type=类型
termora.settings.sync.webdav.help=WebDAV 的存储地址例如https://yourhost/webdav/termora.json
termora.settings.sync.policy=同步策略
termora.settings.sync.policy.manual=手动
termora.settings.sync.policy.on-change=数据变动时

View File

@@ -0,0 +1,19 @@
termora.plugins.sync.disabled-sync=你已登錄,無法使用此功能
termora.settings.sync=同步
termora.settings.sync.export-done=匯出成功
termora.settings.sync.export-encrypt=輸入密碼加密檔案 (可選)
termora.settings.sync.export-done-open-folder=匯出成功,是否需要打開所在資料夾?
termora.settings.sync.range=範圍
termora.settings.sync.range.keys=我的密鑰
termora.settings.sync.last-sync-time=最後同步時間
termora.settings.sync.done=同步資料成功
termora.settings.sync.import.file-too-large=檔案太大
termora.settings.sync.import.successful=導入資料成功
termora.settings.sync.gist=片段
termora.settings.sync.token=令牌
termora.settings.sync.type=類型
termora.settings.sync.webdav.help=WebDAV 的儲存位址例如https://yourhost/webdav/termora.json
termora.settings.sync.policy=同步策略
termora.settings.sync.policy.manual=手動
termora.settings.sync.policy.on-change=資料變動時

View File

@@ -8,9 +8,9 @@ import java.util.*
abstract class AbstractI18n {
private val log get() = getLogger()
private val substitutor by lazy { StringSubstitutor { key -> getString(key) } }
protected val substitutor by lazy { StringSubstitutor { key -> getString(key) } }
fun getString(key: String, vararg args: Any): String {
open fun getString(key: String, vararg args: Any): String {
val text = getString(key)
if (args.isNotEmpty()) {
return MessageFormat.format(text, *args)
@@ -19,7 +19,7 @@ abstract class AbstractI18n {
}
fun getString(key: String): String {
open fun getString(key: String): String {
try {
return substitutor.replace(getBundle().getString(key))
} catch (e: MissingResourceException) {

View File

@@ -1,54 +0,0 @@
package app.termora
import app.termora.database.DatabaseManager
/**
* 仅标记
*/
class DeleteDataManager private constructor() {
companion object {
fun getInstance(): DeleteDataManager {
return ApplicationScope.forApplicationScope().getOrCreate(DeleteDataManager::class) { DeleteDataManager() }
}
}
private val data = mutableMapOf<String, DeletedData>()
private val database get() = DatabaseManager.getInstance()
fun removeHost(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "Host", deleteDate))
}
fun removeKeymap(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "Keymap", deleteDate))
}
fun removeKeyPair(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "KeyPair", deleteDate))
}
fun removeKeywordHighlight(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "KeywordHighlight", deleteDate))
}
fun removeMacro(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "Macro", deleteDate))
}
fun removeSnippet(id: String, deleteDate: Long = System.currentTimeMillis()) {
addDeletedData(DeletedData(id, "Snippet", deleteDate))
}
private fun addDeletedData(deletedData: DeletedData) {
if (data.containsKey(deletedData.id)) return
data[deletedData.id] = deletedData
// TODO database.addDeletedData(deletedData)
}
fun getDeletedData(): List<DeletedData> {
if (data.isEmpty()) {
// TODO data.putAll(database.getDeletedData().associateBy { it.id })
}
return data.values.sortedBy { it.deleteDate }
}
}

View File

@@ -2,6 +2,7 @@ package app.termora.account
import app.termora.*
import app.termora.Application.ohMyJson
import app.termora.database.OwnerType
import app.termora.plugin.ExtensionManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -47,6 +48,10 @@ class AccountManager private constructor() : ApplicationRunnerExtension {
fun getAccessToken() = account.accessToken
fun getRefreshToken() = account.refreshToken
fun getOwnerIds() = account.teams.map { it.id }.toMutableList().apply { add(getAccountId()) }.toSet()
fun getOwners() =
account.teams.map { AccountOwner(it.id, it.name, OwnerType.Team) }
.toMutableList().apply { AccountOwner(getAccountId(), getEmail(), OwnerType.User) }
.toSet()
fun isFreePlan(): Boolean {
return isLocally() || getSubscription().plan == SubscriptionPlan.Free

View File

@@ -252,7 +252,7 @@ class DatabaseManager private constructor() : Disposable {
source
)
} else {
save(data)
save(data, source)
}
}

View File

@@ -2,7 +2,6 @@ package app.termora.highlight
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.DeleteDataManager
import app.termora.TerminalPanelFactory
import app.termora.account.AccountOwner
import app.termora.database.Data
@@ -48,7 +47,6 @@ class KeywordHighlightManager private constructor() {
fun removeKeywordHighlight(id: String) {
database.delete(id, DataType.KeywordHighlight.name)
TerminalPanelFactory.getInstance().repaintAll()
DeleteDataManager.getInstance().removeKeywordHighlight(id)
if (log.isDebugEnabled) {
log.debug("Keyword highlighter removed. {}", id)

View File

@@ -2,7 +2,6 @@ package app.termora.keymgr
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.DeleteDataManager
import app.termora.account.AccountOwner
import app.termora.database.Data
import app.termora.database.DataType
@@ -36,7 +35,6 @@ class KeyManager private constructor() {
fun removeOhKeyPair(id: String) {
databaseManager.delete(id, DataType.KeyPair.name)
DeleteDataManager.getInstance().removeKeyPair(id)
}
fun getOhKeyPairs(): List<OhKeyPair> {

View File

@@ -2,7 +2,6 @@ package app.termora.macro
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.DeleteDataManager
import app.termora.account.AccountManager
import app.termora.database.Data
import app.termora.database.DataType
@@ -50,7 +49,6 @@ class MacroManager private constructor() {
fun removeMacro(id: String) {
database.delete(id, DataType.Macro.name)
DeleteDataManager.getInstance().removeMacro(id)
if (log.isDebugEnabled) {
log.debug("Removed macro $id")

View File

@@ -2,7 +2,6 @@ package app.termora.snippet
import app.termora.Application.ohMyJson
import app.termora.ApplicationScope
import app.termora.DeleteDataManager
import app.termora.account.AccountManager
import app.termora.assertEventDispatchThread
import app.termora.database.Data
@@ -45,7 +44,6 @@ class SnippetManager private constructor() {
fun removeSnippet(id: String) {
database.delete(id, DataType.Snippet.name)
DeleteDataManager.getInstance().removeSnippet(id)
}
/**

View File

@@ -6,6 +6,6 @@ import kotlinx.serialization.Serializable
data class Tag(
val id: String,
val text: String,
val createDate: Long = System.currentTimeMillis(),
val updateDate: Long = System.currentTimeMillis(),
val createDate: Long,
val updateDate: Long,
)

View File

@@ -63,7 +63,14 @@ class TagPanel(accountOwner: AccountOwner) : JPanel(BorderLayout()), Disposable
title = I18n.getString("termora.tag"),
)
if (text.isNullOrBlank().not()) {
model.addElement(Tag(id = randomUUID(), text = text))
model.addElement(
Tag(
id = randomUUID(),
text = text,
createDate = System.currentTimeMillis(),
updateDate = System.currentTimeMillis(),
)
)
}
}

View File

@@ -70,26 +70,6 @@ termora.settings.terminal.floating-toolbar=Floating Toolbar
termora.settings.terminal.auto-close-tab=Auto Close Tab
termora.settings.terminal.auto-close-tab-description=Automatically close the tab when the terminal is disconnected normally
termora.settings.sync=Sync
termora.settings.sync.done=Synchronized data successfully
termora.settings.sync.export=${termora.keymgr.export}
termora.settings.sync.import=${termora.keymgr.import}
termora.settings.sync.import.file-too-large=The file is too large
termora.settings.sync.import.successful=Import data successfully
termora.settings.sync.export-done=The export was successful
termora.settings.sync.export-encrypt=Enter password to encrypt file (optional)
termora.settings.sync.export-done-open-folder=The export was successful. Do you want to open the folder?
termora.settings.sync.range=Range
termora.settings.sync.range.keys=My keys
termora.settings.sync.range.keyword-highlights=${termora.highlight}
termora.settings.sync.last-sync-time=Last sync time
termora.settings.sync.gist=Gist
termora.settings.sync.token=Token
termora.settings.sync.type=Type
termora.settings.sync.webdav.help=WebDAV storage address, e.g. https://yourhost/webdav/termora.json
termora.settings.sync.policy=Sync Policy
termora.settings.sync.policy.manual=Manual
termora.settings.sync.policy.on-change=On Change
termora.settings.about=About
termora.settings.about.author=Author

View File

@@ -81,24 +81,6 @@ termora.settings.terminal.auto-close-tab=自动关闭标签
termora.settings.terminal.auto-close-tab-description=当终端正常断开连接时自动关闭标签页
termora.settings.sync=同步
termora.settings.sync.export-done=导出成功
termora.settings.sync.export-encrypt=输入密码加密文件 (可选)
termora.settings.sync.export-done-open-folder=导出成功,是否需要打开所在文件夹?
termora.settings.sync.range=范围
termora.settings.sync.range.keys=我的密钥
termora.settings.sync.last-sync-time=最后同步时间
termora.settings.sync.done=同步数据成功
termora.settings.sync.import.file-too-large=文件太大
termora.settings.sync.import.successful=导入数据成功
termora.settings.sync.gist=片段
termora.settings.sync.token=令牌
termora.settings.sync.type=类型
termora.settings.sync.webdav.help=WebDAV 的存储地址例如https://yourhost/webdav/termora.json
termora.settings.sync.policy=同步策略
termora.settings.sync.policy.manual=手动
termora.settings.sync.policy.on-change=数据变动时
termora.settings.about=关于
termora.settings.about.author=作者
termora.settings.about.source=源代码

View File

@@ -92,23 +92,6 @@ termora.settings.terminal.floating-toolbar=懸浮工具列
termora.settings.terminal.auto-close-tab=自動關閉標籤
termora.settings.terminal.auto-close-tab-description=當終端正常斷開連線時自動關閉標籤頁
termora.settings.sync=同步
termora.settings.sync.export-done=匯出成功
termora.settings.sync.export-encrypt=輸入密碼加密檔案 (可選)
termora.settings.sync.export-done-open-folder=匯出成功,是否需要打開所在資料夾?
termora.settings.sync.range=範圍
termora.settings.sync.range.keys=我的密鑰
termora.settings.sync.last-sync-time=最後同步時間
termora.settings.sync.done=同步資料成功
termora.settings.sync.import.file-too-large=檔案太大
termora.settings.sync.import.successful=導入資料成功
termora.settings.sync.gist=片段
termora.settings.sync.token=令牌
termora.settings.sync.type=類型
termora.settings.sync.webdav.help=WebDAV 的儲存位址例如https://yourhost/webdav/termora.json
termora.settings.sync.policy=同步策略
termora.settings.sync.policy.manual=手動
termora.settings.sync.policy.on-change=資料變動時
termora.settings.about=關於
termora.settings.about.author=作者