| /* |
| * Copyright (c) 2010-2020 BSI Business Systems Integration AG. |
| * All rights reserved. This program and the accompanying materials |
| * are made available under the terms of the Eclipse Public License v1.0 |
| * which accompanies this distribution, and is available at |
| * http://www.eclipse.org/legal/epl-v10.html |
| * |
| * Contributors: |
| * BSI Business Systems Integration AG - initial API and implementation |
| */ |
| package org.eclipse.scout.sdk.s2i.nls.editor |
| |
| import com.intellij.find.impl.RegExHelpPopup |
| import com.intellij.icons.AllIcons |
| import com.intellij.lang.properties.psi.PropertiesFile |
| import com.intellij.openapi.actionSystem.* |
| import com.intellij.openapi.fileEditor.FileDocumentManager |
| import com.intellij.openapi.progress.ProgressIndicator |
| import com.intellij.openapi.progress.Task |
| import com.intellij.openapi.project.DumbAwareAction |
| import com.intellij.openapi.project.Project |
| import com.intellij.openapi.ui.MessageType |
| import com.intellij.openapi.ui.popup.Balloon |
| import com.intellij.openapi.ui.popup.JBPopupFactory |
| import com.intellij.openapi.util.registry.Registry |
| import com.intellij.openapi.vfs.VirtualFile |
| import com.intellij.psi.PsiManager |
| import com.intellij.ui.BalloonImpl |
| import com.intellij.ui.DocumentAdapter |
| import com.intellij.ui.awt.RelativePoint |
| import com.intellij.ui.components.JBCheckBox |
| import com.intellij.ui.components.JBLabel |
| import com.intellij.ui.components.JBPanel |
| import org.apache.commons.csv.CSVFormat |
| import org.eclipse.scout.sdk.core.log.SdkLog |
| import org.eclipse.scout.sdk.core.log.SdkLog.onTrace |
| import org.eclipse.scout.sdk.core.s.nls.* |
| import org.eclipse.scout.sdk.core.s.nls.properties.EditableTranslationFile |
| import org.eclipse.scout.sdk.core.s.nls.properties.PropertiesTranslationStore |
| import org.eclipse.scout.sdk.core.s.nls.properties.ReadOnlyTranslationFile |
| import org.eclipse.scout.sdk.core.util.CoreUtils |
| import org.eclipse.scout.sdk.core.util.Strings |
| import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message |
| import org.eclipse.scout.sdk.s2i.resolvePsi |
| import org.eclipse.scout.sdk.s2i.toScoutProgress |
| import org.eclipse.scout.sdk.s2i.toVirtualFile |
| import org.eclipse.scout.sdk.s2i.ui.IndexedFocusTraversalPolicy |
| import org.eclipse.scout.sdk.s2i.ui.TextFieldWithMaxLen |
| import java.awt.GridBagConstraints |
| import java.awt.GridBagLayout |
| import java.awt.Insets |
| import java.awt.Point |
| import java.io.StringReader |
| import java.util.concurrent.TimeUnit |
| import java.util.function.Predicate |
| import java.util.regex.Pattern |
| import javax.swing.Icon |
| import javax.swing.JComponent |
| import javax.swing.event.DocumentEvent |
| import kotlin.streams.toList |
| |
| class NlsEditorContent(val project: Project, val stack: TranslationStoreStack, val primaryStore: ITranslationStore) : JBPanel<NlsEditorContent>(GridBagLayout()) { |
| |
| private val m_table = NlsTable(stack, project) |
| private val m_textFilter = TextFieldWithMaxLen(maxLength = 2000) |
| private val m_regexHelpButton = RegExHelpPopup.createRegExLink("<html><body><b>?</b></body></html>", this, null) |
| private val m_hideReadOnly = JBCheckBox(message("hide.readonly.rows"), true) |
| private val m_hideInherited = JBCheckBox(message("hide.inherited.rows"), true) |
| |
| private var m_searchPattern: Predicate<String>? = null |
| |
| init { |
| val typeFilterLayout = GridBagConstraints() |
| typeFilterLayout.gridx = 0 |
| typeFilterLayout.gridy = 0 |
| typeFilterLayout.gridwidth = 1 |
| typeFilterLayout.gridheight = 1 |
| typeFilterLayout.fill = GridBagConstraints.HORIZONTAL |
| typeFilterLayout.insets = Insets(15, 7, 0, 0) |
| add(TranslationFilterPanel(), typeFilterLayout) |
| |
| val tableLayout = GridBagConstraints() |
| tableLayout.gridx = 0 |
| tableLayout.gridy = 1 |
| tableLayout.gridwidth = 1 |
| tableLayout.gridheight = 1 |
| tableLayout.fill = GridBagConstraints.BOTH |
| tableLayout.insets = Insets(8, 8, 0, 0) |
| tableLayout.weightx = 1.0 |
| tableLayout.weighty = 1.0 |
| add(m_table, tableLayout) |
| |
| val actionsLayout = GridBagConstraints() |
| actionsLayout.gridx = 1 |
| actionsLayout.gridy = 1 |
| actionsLayout.gridwidth = 1 |
| actionsLayout.gridheight = 1 |
| actionsLayout.fill = GridBagConstraints.VERTICAL |
| actionsLayout.insets = Insets(8, 0, 0, 0) |
| val toolbar = createToolbar() |
| add(toolbar, actionsLayout) |
| |
| isFocusTraversalPolicyProvider = true |
| isFocusCycleRoot = true |
| val focusPolicy = IndexedFocusTraversalPolicy() |
| focusPolicy.addComponent(m_textFilter) |
| focusPolicy.addComponent(m_regexHelpButton) |
| focusPolicy.addComponent(m_hideReadOnly) |
| focusPolicy.addComponent(m_hideInherited) |
| focusTraversalPolicy = focusPolicy |
| |
| filterChanged() |
| } |
| |
| fun textFilterField() = m_textFilter |
| |
| private fun filterChanged() { |
| m_searchPattern = toPredicate(m_textFilter.text) |
| m_table.setFilter(Predicate { acceptTranslation(it) }) |
| } |
| |
| private fun acceptTranslation(candidate: ITranslationEntry): Boolean { |
| val isHideReadOnlyRows = m_hideReadOnly.isSelected |
| if (isHideReadOnlyRows && !candidate.store().isEditable) { |
| return false |
| } |
| |
| val isHideInheritedRows = m_hideInherited.isSelected |
| if (isHideInheritedRows && candidate.store() != primaryStore) { |
| return false |
| } |
| |
| val textFilter = m_searchPattern ?: return true |
| if (textFilter.test(candidate.key())) { |
| return true |
| } |
| return candidate.texts().values.any { textFilter.test(it) } |
| } |
| |
| private fun toPredicate(searchText: String): Predicate<String>? { |
| if (Strings.isBlank(searchText)) { |
| return null |
| } |
| |
| return try { |
| Pattern.compile(searchText, Pattern.CASE_INSENSITIVE) |
| } catch (e: Exception) { |
| Pattern.compile(Pattern.quote(searchText), Pattern.CASE_INSENSITIVE) |
| }.asPredicate() |
| } |
| |
| private fun createToolbar(): JComponent { |
| return ActionManager.getInstance() |
| .createActionToolbar(ActionPlaces.EDITOR_TOOLBAR, createActionGroup(), false) |
| .component |
| } |
| |
| private fun createActionGroup(): ActionGroup { |
| val result = DefaultActionGroup() |
| result.add(TranslationNewActionGroup()) |
| result.add(RemoveTranslationsAction()) |
| result.addSeparator() |
| result.add(TranslationLocateActionGroup()) |
| result.add(ReloadAction()) |
| result.addSeparator() |
| result.add(LanguageNewAction()) |
| result.addSeparator() |
| result.add(ImportFromClipboardAction()) |
| result.add(ExportToClipboardAction()) |
| return result |
| } |
| |
| private fun showBalloon(text: String, severity: MessageType) { |
| val lbl = JBLabel(text) |
| val balloon = JBPopupFactory.getInstance() |
| .createBalloonBuilder(lbl) |
| .setShowCallout(false) |
| .setAnimationCycle(Registry.intValue("ide.tooltip.animationCycle")) |
| .setBlockClicksThroughBalloon(true) |
| .setFillColor(severity.popupBackground) |
| .setBorderColor(severity.borderColor) |
| .createBalloon() |
| if (balloon is BalloonImpl) { |
| balloon.startSmartFadeoutTimer(TimeUnit.SECONDS.toMillis(30).toInt()) |
| } |
| balloon.show(RelativePoint(m_table, Point(m_table.visibleRect.width / 2, 0)), Balloon.Position.above) |
| } |
| |
| private inner class TranslationNewActionGroup : AbstractEditableStoresAction(message("create.new.translation"), message("create.new.translation.in"), AllIcons.General.Add, { |
| TranslationNewDialogOpenAction(it) |
| }) |
| |
| private inner class TranslationNewDialogOpenAction(private val store: ITranslationStore) : DumbAwareAction(store.service().type().elementName()) { |
| override fun actionPerformed(e: AnActionEvent) { |
| val dialog = TranslationNewDialog(project, store, stack) |
| val ok = dialog.showAndGet() |
| if (ok) { |
| val createdTranslation = dialog.createdTranslation() ?: return |
| m_table.selectTranslation(createdTranslation) |
| } |
| } |
| } |
| |
| private inner class RemoveTranslationsAction : DumbAwareAction(message("remove.selected.rows"), null, AllIcons.General.Remove) { |
| override fun update(e: AnActionEvent) { |
| val selectedTranslations = m_table.selectedTranslations() |
| e.presentation.isEnabled = selectedTranslations.isNotEmpty() |
| && selectedTranslations.map { it.store() }.all { it.isEditable } |
| } |
| |
| override fun actionPerformed(e: AnActionEvent) { |
| val toDelete = m_table.selectedTranslations() |
| .map { it.key() } |
| .stream() |
| stack.removeTranslations(toDelete) |
| } |
| } |
| |
| private inner class TranslationLocateActionGroup : DumbAwareAction(message("jump.to.declaration"), null, AllIcons.General.Locate) { |
| override fun update(e: AnActionEvent) { |
| e.presentation.isEnabled = m_table.selectedTranslations().size == 1 |
| } |
| |
| override fun actionPerformed(e: AnActionEvent) { |
| val selection = m_table.selectedTranslations() |
| if (selection.size != 1) { |
| return |
| } |
| val selectedTranslation = selection[0] |
| val selectedLanguages = m_table.selectedLanguages() |
| if (selectedLanguages.size == 1) { |
| // open chooser: jump to service or property? |
| val group = DefaultActionGroup(listOf(TranslationServiceLocateAction(selectedTranslation), TranslationTextLocateAction(selectedTranslation, selectedLanguages[0]))) |
| val popup = JBPopupFactory.getInstance().createActionGroupPopup(templatePresentation.text, group, e.dataContext, JBPopupFactory.ActionSelectionAid.NUMBERING, false) |
| popup.showUnderneathOf(e.inputEvent.component) |
| } else { |
| TranslationServiceLocateAction(selectedTranslation).actionPerformed(e) |
| } |
| } |
| } |
| |
| private inner class TranslationTextLocateAction(val translation: ITranslationEntry, val language: Language) : DumbAwareAction(message("jump.to.property"), null, AllIcons.Nodes.ResourceBundle) { |
| override fun actionPerformed(e: AnActionEvent) { |
| val store = translation.store() |
| if (store !is PropertiesTranslationStore) { |
| return |
| } |
| |
| val file = store.files()[language] ?: return |
| if (file is EditableTranslationFile) { |
| // in project |
| val origin = file.path().toVirtualFile() ?: return |
| open(origin) |
| } else if (file is ReadOnlyTranslationFile) { |
| // in libraries |
| val source = file.source() |
| if (source is VirtualFile) { |
| open(source) |
| } |
| } |
| } |
| |
| private fun open(file: VirtualFile) { |
| val psi = PsiManager.getInstance(project).findFile(file) |
| if (psi !is PropertiesFile) { |
| return |
| } |
| psi.findPropertyByKey(translation.key())?.navigate(true) |
| } |
| } |
| |
| private inner class TranslationServiceLocateAction(val translation: ITranslationEntry) : DumbAwareAction(message("jump.to.text.service"), null, AllIcons.Nodes.Services) { |
| override fun actionPerformed(e: AnActionEvent) { |
| translation.store().service().type().resolvePsi()?.navigate(true) |
| } |
| } |
| |
| private inner class LanguageNewAction : DumbAwareAction(message("add.new.language"), null, AllIcons.ToolbarDecorator.AddLink) { |
| override fun actionPerformed(e: AnActionEvent) { |
| LanguageNewDialog(project, primaryStore, stack).show() |
| } |
| } |
| |
| private inner class ReloadAction : DumbAwareAction(message("reload.from.filesystem"), null, AllIcons.Actions.Refresh) { |
| override fun actionPerformed(e: AnActionEvent) { |
| FileDocumentManager.getInstance().saveAllDocuments() |
| object : Task.Modal(project, message("loading.translations"), true) { |
| override fun run(indicator: ProgressIndicator) { |
| stack.reload(indicator.toScoutProgress()) |
| } |
| }.queue() |
| } |
| } |
| |
| private inner class ImportFromClipboardAction : DumbAwareAction(message("import.translations.from.clipboard"), null, AllIcons.ToolbarDecorator.Import) { |
| override fun actionPerformed(e: AnActionEvent) { |
| val clipboardContent: String? = CoreUtils.getTextFromClipboard() |
| if (clipboardContent == null) { |
| showBalloon(message("clipboard.no.text.content"), MessageType.ERROR) |
| return |
| } |
| val data = parseCsv(clipboardContent) |
| if (data == null) { |
| showBalloon(message("clipboard.no.valid.content"), MessageType.ERROR) |
| return |
| } |
| handleResult(stack.importTranslations(data, NlsTableModel.KEY_COLUMN_HEADER_NAME, primaryStore)) |
| } |
| |
| fun parseCsv(content: String): List<List<String>>? { |
| return listOf(CSVFormat.TDF, CSVFormat.EXCEL, CSVFormat.DEFAULT, CSVFormat.RFC4180, CSVFormat.POSTGRESQL_CSV, CSVFormat.POSTGRESQL_TEXT, CSVFormat.MYSQL, CSVFormat.ORACLE) |
| .mapNotNull { tryParseUsing(it, content) } |
| .firstOrNull() |
| } |
| |
| private fun tryParseUsing(format: CSVFormat, content: String): List<List<String>>? { |
| try { |
| val lines = format.parse(StringReader(content)).records |
| if (lines.size < 2) { |
| return null |
| } |
| val result = ArrayList<ArrayList<String>>(lines.size) |
| for (record in lines) { |
| val row = ArrayList<String>(record.size()) |
| row.addAll(record) |
| result.add(row) |
| } |
| return result |
| } catch (e: Exception) { |
| SdkLog.debug("Unable to parse clipboard content as csv using format '{}'.", format, onTrace(e)) |
| return null |
| } |
| } |
| |
| private fun handleResult(importInfo: ITranslationImportInfo) { |
| val result = importInfo.result() |
| if (result < 1) { |
| showBalloon(message("clipboard.content.no.mapping"), MessageType.ERROR) |
| return |
| } |
| val listSeparator = ", " |
| val listPrefix = "[" |
| val listPostfix = "]" |
| val balloonMessages = ArrayList<String>() |
| val logMessages = ArrayList<String>() |
| val duplicateKeys = importInfo.duplicateKeys() |
| val maxNumItemsInBalloon = 3 |
| balloonMessages.add(message("import.successful.x.rows", result)) |
| if (duplicateKeys.isNotEmpty()) { |
| val balloonList = duplicateKeys.joinToString(listSeparator, listPrefix, listPostfix, maxNumItemsInBalloon) |
| balloonMessages.add(message("import.duplicate.keys", balloonList)) |
| logMessages.add(message("import.duplicate.keys", duplicateKeys)) |
| } |
| val ignoredColumns = importInfo.ignoredColumns() |
| if (ignoredColumns.isNotEmpty()) { |
| val messages = ignoredColumns.entries.map { message("column.x", it.key + 1) + if (Strings.hasText(it.value)) "=" + it.value else "" } |
| val balloonList = messages.joinToString(listSeparator, listPrefix, listPostfix, maxNumItemsInBalloon) |
| balloonMessages.add(message("import.columns.not.mapped", balloonList)) |
| logMessages.add(message("import.columns.not.mapped", messages)) |
| } |
| val invalidRows = importInfo.invalidRowIndices() |
| .map { it + 1 } // convert to row number |
| .toList() |
| if (invalidRows.isNotEmpty()) { |
| val balloonList = invalidRows.joinToString(listSeparator, listPrefix, listPostfix, maxNumItemsInBalloon) |
| balloonMessages.add(message("import.rows.invalid", balloonList)) |
| logMessages.add(message("import.rows.invalid", invalidRows)) |
| } |
| |
| val balloonMessage = balloonMessages.joinToString("<br>", "<html>", "</html>", transform = Strings::escapeHtml) |
| val hasWarnings = logMessages.isNotEmpty() |
| if (hasWarnings) { |
| showBalloon(balloonMessage, MessageType.WARNING) |
| logMessages.forEach { SdkLog.warning(it) } |
| } else { |
| showBalloon(balloonMessage, MessageType.INFO) |
| } |
| } |
| } |
| |
| private inner class ExportToClipboardAction : DumbAwareAction(message("export.table.to.clipboard"), null, AllIcons.ToolbarDecorator.Export) { |
| override fun actionPerformed(e: AnActionEvent) { |
| val tableData = m_table.visibleData() |
| val table = toTable(tableData) |
| if (CoreUtils.setTextToClipboard(table)) { |
| showBalloon(message("table.content.copied.to.clipboard"), MessageType.INFO) |
| } |
| } |
| |
| fun toTable(data: List<List<String>>): String { |
| val prefix = "<html><head><meta charset=\"utf-8\"></head><body><table>\n" |
| val postfix = "</table></body></html>\n" |
| return data.indices |
| .associateWith { data[it] } |
| .map { encodeRow(it.key, it.value) } |
| .joinToString("\n", prefix, postfix) |
| } |
| |
| fun encodeRow(index: Int, row: List<String>): String { |
| val tag = if (index == 0) "th" else "td" |
| return row |
| .map { Strings.escapeHtml(it) } |
| .map { Strings.replaceEach(it, arrayOf("\r", "\n", " "), arrayOf("", "<br style=\"mso-data-placement:same-cell;\"/>", " ")) } |
| .joinToString("", "<tr>", "</tr>") { |
| "<$tag>$it</$tag>" |
| } |
| } |
| } |
| |
| private abstract inner class AbstractEditableStoresAction(text: String, val groupTitle: String, icon: Icon?, val task: (ITranslationStore) -> AnAction) : DumbAwareAction(text, null, icon) { |
| override fun actionPerformed(e: AnActionEvent) { |
| val stores = stack.allEditableStores().toList() |
| if (stores.isEmpty()) { |
| return |
| } |
| if (stores.size == 1) { |
| stores[0]?.let { task.invoke(it).actionPerformed(e) } |
| } else { |
| val popupActions = stores.map { task.invoke(it) } |
| val group = DefaultActionGroup(popupActions) |
| val popup = JBPopupFactory.getInstance().createActionGroupPopup(groupTitle, group, e.dataContext, JBPopupFactory.ActionSelectionAid.ALPHA_NUMBERING, false) |
| popup.showUnderneathOf(e.inputEvent.component) |
| } |
| } |
| } |
| |
| private inner class TranslationFilterPanel : JBPanel<TranslationFilterPanel>(GridBagLayout()) { |
| init { |
| val filterLayout = GridBagConstraints() |
| filterLayout.gridx = 0 |
| filterLayout.gridy = 0 |
| filterLayout.fill = GridBagConstraints.HORIZONTAL |
| filterLayout.weightx = 1.0 |
| filterLayout.insets = Insets(0, 0, 0, 0) |
| m_textFilter.document.addDocumentListener(object : DocumentAdapter() { |
| override fun textChanged(e: DocumentEvent) { |
| filterChanged() |
| } |
| }) |
| m_textFilter.isFocusable = true |
| add(m_textFilter, filterLayout) |
| |
| val regexHelpLayout = GridBagConstraints() |
| regexHelpLayout.gridx = 1 |
| regexHelpLayout.gridy = 0 |
| regexHelpLayout.insets = Insets(0, 4, 0, 0) |
| m_regexHelpButton.isFocusable = true |
| add(m_regexHelpButton, regexHelpLayout) |
| |
| val readOnlyLayout = GridBagConstraints() |
| readOnlyLayout.gridx = 2 |
| readOnlyLayout.gridy = 0 |
| readOnlyLayout.insets = Insets(0, 24, 0, 0) |
| m_hideReadOnly.addActionListener { filterChanged() } |
| m_hideReadOnly.toolTipText = message("hide.readonly.rows.desc") |
| m_hideReadOnly.isFocusable = true |
| add(m_hideReadOnly, readOnlyLayout) |
| |
| val inheritedLayout = GridBagConstraints() |
| inheritedLayout.gridx = 3 |
| inheritedLayout.gridy = 0 |
| inheritedLayout.insets = Insets(0, 16, 0, 4) |
| m_hideInherited.addActionListener { filterChanged() } |
| m_hideInherited.toolTipText = message("hide.inherited.rows.desc", primaryStore.service().type().name()) |
| m_hideInherited.isFocusable = true |
| add(m_hideInherited, inheritedLayout) |
| } |
| } |
| } |