User:DressyPear4/Reverter

From OpenStreetMap Wiki
Jump to navigation Jump to search

Complemento para o Plugin Reverter

- O script monitora de forma passiva a interface do JOSM para extrair automaticamente os IDs de alterações inseridos na janela do plugin Reverter.

- Adiciona a tag revert:id com o ID dos changesets revertidos e injeta comment no diálogo de upload.

- Lê nativamente o Token OAuth2 armazenado nas preferências locais do JOSM (preferences.xml). Isso faz com que seu usuário seja identificado ao comentar em um changeset revertido.

Como Funciona

  • Ao executar o script, uma pequena janela flutuante é criada para exibir o andamento dos IDs capturados.
  • Assim que você utiliza o plugin Reverter e insere o changeset, o script lê o campo interno da janela e preenche o campo 1. ID do Changeset Revertido.
  • Ao abrir a janela de Upload do JOSM, o script sincroniza as tags baseando-se das caixas de seleção ativas.
  • Quando a mensagem de sucesso do upload surge na tela, o script captura o novo ID gerado, exibe no campo 2. ID do Novo Upload e dispara o comentário na API do OSM com a estrutura padronizada:
    • Este changeset foi revertido com changeset/XXXXXX

Demonstração

Código

JavaScript (old)

Última atualização: 2026-08-18

"use strict";

import { println } from 'josm/scriptingconsole';
import { addResetCallback } from 'josm/context';

const MainApplication = Java.type("org.openstreetmap.josm.gui.MainApplication");
const Notification    = Java.type("org.openstreetmap.josm.gui.Notification");
const OsmApi          = Java.type("org.openstreetmap.josm.io.OsmApi");
const Config          = Java.type("org.openstreetmap.josm.spi.preferences.Config");

const JDialog         = Java.type("javax.swing.JDialog");
const UIManager       = Java.type("javax.swing.UIManager");
const JPanel          = Java.type("javax.swing.JPanel");
const JLabel          = Java.type("javax.swing.JLabel");
const JButton         = Java.type("javax.swing.JButton");
const JCheckBox       = Java.type("javax.swing.JCheckBox");
const JTextField      = Java.type("javax.swing.JTextField");
const JScrollPane     = Java.type("javax.swing.JScrollPane");
const BoxLayout       = Java.type("javax.swing.BoxLayout");
const BorderLayout    = Java.type("java.awt.BorderLayout");
const BorderFactory   = Java.type("javax.swing.BorderFactory");
const Timer           = Java.type("javax.swing.Timer");
const SwingUtilities  = Java.type("javax.swing.SwingUtilities");

const GridBagLayout      = Java.type("java.awt.GridBagLayout");
const GridBagConstraints = Java.type("java.awt.GridBagConstraints");
const Insets             = Java.type("java.awt.Insets");
const Dimension          = Java.type("java.awt.Dimension");
const Font               = Java.type("java.awt.Font");
const Color              = Java.type("java.awt.Color");
const KeyboardFocusManager = Java.type("java.awt.KeyboardFocusManager");
const WindowAdapter          = Java.extend(Java.type("java.awt.event.WindowAdapter"));
const ActionListener         = Java.extend(Java.type("java.awt.event.ActionListener"));
const PropertyChangeListener = Java.extend(Java.type("java.beans.PropertyChangeListener"));

function iniciarScript() {
    let layer = MainApplication.getLayerManager().getEditLayer();
    if (!layer) {
        new Notification("Nenhuma camada de edição ativa.")
            .setIcon(UIManager.getIcon("OptionPane.errorIcon")).show();
        return;
    }

    // Lista acumulada de grupos
    let listaGrupos = [];
    let lastChangesetId     = null;
    let lastLayer           = layer;
    let janelaReverterAberta = false;
    let idsCandidatos        = "";

    // Formata grupo para exibição: 3 IDs por linha
    function formatarGrupo(ids) {
        const linhas = [];
        for (let i = 0; i < ids.length; i += 3) {
            linhas.push(ids.slice(i, i + 3).join(";"));
        }
        return linhas.join("<br>");
    }

    // Todos os IDs únicos de todos os grupos
    function todosIds() {
        const set = new Set();
        listaGrupos.forEach(grupo => grupo.forEach(id => set.add(id)));
        return Array.from(set);
    }

    // Sync das tags do changeset
    const syncTags = function() {
        const ds = MainApplication.getLayerManager().getEditDataSet();
        if (!ds) return;
        const ids = todosIds();
        if (chkInjectId.isSelected() && ids.length > 0) {
            ds.addChangeSetTag("revert:id", ids.join(";"));
        } else {
            ds.addChangeSetTag("revert:id", null);
        }
        if (chkInjectComment.isSelected() && ids.length > 0) {
            ds.addChangeSetTag("comment", "Revertendo changeset " + ids.join(", "));
        } else {
            ds.addChangeSetTag("comment", null);
        }
    };

    // Atualiza lista visual
    const updateUI = function() {
        listPanel.removeAll();
        listBorder.setTitle("ID's dos Changesets Revertidos (" + listaGrupos.length + ")");

        const gbc = new GridBagConstraints();
        gbc.fill = GridBagConstraints.HORIZONTAL;
        gbc.weightx = 1.0;
        gbc.insets = new Insets(2, 2, 2, 2);
        gbc.gridx = 0;

        listaGrupos.forEach(function(grupo, index) {
            gbc.gridy = index;
            gbc.weighty = 0.0;

            const itemPanel = new JPanel(new BorderLayout(6, 0));
            itemPanel.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(200, 200, 200)),
                BorderFactory.createEmptyBorder(3, 6, 3, 6)
            ));

            const btnX = new JButton("x");
            btnX.setPreferredSize(new Dimension(22, 20));
            btnX.setFont(btnX.getFont().deriveFont(Font.BOLD, 10.0));
            btnX.setToolTipText("Remover este grupo (" + grupo.length + " ID(s))");
            btnX.addActionListener(new ActionListener({ actionPerformed: function() {
                listaGrupos.splice(index, 1);
                updateUI();
                syncTags();
            }}));

            const lbl = new JLabel("<html>" + formatarGrupo(grupo) + "</html>");
            lbl.setFont(lbl.getFont().deriveFont(Font.PLAIN, 11.0));

            itemPanel.add(btnX, BorderLayout.WEST);
            itemPanel.add(lbl, BorderLayout.CENTER);
            listPanel.add(itemPanel, gbc);
        });

        // Filler
        gbc.gridy = listaGrupos.length;
        gbc.weighty = 1.0;
        listPanel.add(new JPanel(), gbc);

        listWrapper.revalidate();
        listWrapper.repaint();
    };

    // Envio de comentários em todos os IDs acumulados
    const dispararComentarios = function(novoUploadId) {
        const ids = todosIds();
        if (ids.length === 0 || !novoUploadId) return;

        const prefKey = 'oauth.access-token.object.OAuth20.api.openstreetmap.org';
        const prefValue = Config.getPref().get(prefKey);
        if (!prefValue) {
            new Notification("Erro: Autenticação OAuth2 não encontrada.")
                .setIcon(UIManager.getIcon("OptionPane.errorIcon")).show();
            return;
        }

        let oauthToken;
        try {
            oauthToken = JSON.parse(prefValue).access_token;
        } catch (e) {
            println("❌ Erro ao processar credenciais: " + e);
            return;
        }

        const comentario = "Este changeset foi revertido com changeset/" + novoUploadId;
        const baseUrl = OsmApi.getOsmApi().getBaseUrl();
        let successes = 0;

        ids.forEach(function(idStr) {
            const cId = parseInt(idStr, 10);
            if (isNaN(cId)) return;
            try {
                const url = new java.net.URL(baseUrl + "changeset/" + cId + "/comment");
                const conn = url.openConnection();
                conn.setRequestMethod("POST");
                conn.setRequestProperty("Authorization", "Bearer " + oauthToken);
                conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
                conn.setRequestProperty("User-Agent", "JOSM-Reverter-Automation-Script/1.0");
                conn.setDoOutput(true);
                const params = "text=" + java.net.URLEncoder.encode(comentario, "UTF-8");
                const os = conn.getOutputStream();
                const writer = new java.io.OutputStreamWriter(os, "UTF-8");
                writer.write(params);
                writer.flush();
                writer.close();
                os.close();
                if (conn.getResponseCode() === 200) {
                    successes++;
                } else {
                    println("Erro API OSM (Changeset " + cId + "): HTTP " + conn.getResponseCode());
                }
            } catch (err) {
                println("Falha na conexão com changeset " + cId + ": " + err);
            }
        });

        if (successes > 0) {
            new Notification(successes + " comentário(s) enviado(s) com sucesso!")
                .setIcon(UIManager.getIcon("OptionPane.informationIcon")).show();
            listaGrupos = [];
            SwingUtilities.invokeLater(function() { updateUI(); syncTags(); });
        } else {
            new Notification("Falha ao comentar. Verifique o Console do JOSM.")
                .setIcon(UIManager.getIcon("OptionPane.errorIcon")).show();
        }
    };

    // Monitoramento de janelas
    const monitorarJanelas = function() {
        const windows = java.awt.Window.getWindows();
        let reverterVisivelNesteTick = false;

        for (let i = 0; i < windows.length; i++) {
            const win = windows[i];
            if (!win || !win.isVisible()) continue;
            const winClass = win.getClass().getSimpleName();
            if (winClass.match(/HistoryBrowserDialog|NotePopup|OSMObjInfoDialog|ValidatorDialog|Notification/i)) continue;

            // Captura IDs do plugin Reverter
            if (winClass === "ChangesetIdQuery") {
                reverterVisivelNesteTick = true;
                const extrairId = function(comp) {
                    try {
                        if (comp.getClass().getSimpleName() === "ChangesetIdsTextField") {
                            idsCandidatos = String(comp.getText()).trim();
                        }
                        if (comp.getComponents) {
                            const children = comp.getComponents();
                            for (let j = 0; j < children.length; j++) extrairId(children[j]);
                        }
                    } catch(e) {}
                };
                extrairId(win);
                continue;
            }

            // Captura ID do novo upload
            let foundId = null, isConfirmacaoUpload = false;
            const extrairUpload = function(comp) {
                try {
                    if (comp.getClass().getSimpleName().match(/NotePopup/i)) return;
                    let txt = comp.getText ? String(comp.getText()) : (comp.getContent ? String(comp.getContent()) : "");
                    if (txt) {
                        if (txt.includes("Enviando comentários") || txt.includes("enviado(s) com sucesso")) return;
                        const m = txt.match(/changeset\/(\d+)/) || txt.match(/changeset\s(\d+)/);
                        if (m) {
                            foundId = m[1];
                            if (txt.toLowerCase().match(/<h[34]>/) &&
                                txt.toLowerCase().match(/sucesso|successful|upload|enviado/)) {
                                isConfirmacaoUpload = true;
                            }
                        }
                    }
                    if (!isConfirmacaoUpload && comp.getComponents) {
                        const children = comp.getComponents();
                        for (let j = 0; j < children.length; j++) {
                            extrairUpload(children[j]);
                            if (isConfirmacaoUpload) break;
                        }
                    }
                } catch(e) {}
            };
            extrairUpload(win);

            if (foundId && isConfirmacaoUpload && foundId !== lastChangesetId) {
                lastChangesetId = foundId;
                txtNovoUpload.setText(foundId);
                new Notification("Upload detectado: " + foundId + ". Enviando comentários...")
                    .setIcon(UIManager.getIcon("OptionPane.informationIcon")).show();
                dispararComentarios(foundId);
            }
        }

        // Fora do loop: processa ao fechar a janela do reverter
        if (reverterVisivelNesteTick) {
            janelaReverterAberta = true;
        } else if (janelaReverterAberta) {
            janelaReverterAberta = false;
            if (idsCandidatos) {
                const novosIds = idsCandidatos.split(/[;,]/)
                    .map(function(s) { return s.trim(); })
                    .filter(function(s) { return s !== ""; });
                if (novosIds.length > 0) {
                    const chave    = novosIds.slice().sort().join(";");
                    const jaExiste = listaGrupos.some(function(g) {
                        return g.slice().sort().join(";") === chave;
                    });
                    if (!jaExiste) {
                        listaGrupos.push(novosIds);
                        SwingUtilities.invokeLater(function() { updateUI(); syncTags(); });
                    }
                }
                idsCandidatos = "";
            }
        }
    };

    // --- Interface ---
    const parent = MainApplication.getMainFrame();
    const dialog = new JDialog(parent, "Complemento do Plugin Reverter", false);
    dialog.setLayout(new BorderLayout(8, 8));
    dialog.setLocationRelativeTo(parent);

    const outerPanel = new JPanel(new BorderLayout(6, 6));
    outerPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
    dialog.setContentPane(outerPanel);

    // Checkboxes
    const chkInjectId = new JCheckBox("Injetar revert:id no changeset", true);
    chkInjectId.setFont(chkInjectId.getFont().deriveFont(11.0));
    const chkInjectComment = new JCheckBox("Injetar comentário no changeset", true);
    chkInjectComment.setFont(chkInjectComment.getFont().deriveFont(11.0));

    chkInjectId.addActionListener(new ActionListener({ actionPerformed: function() { syncTags(); } }));
    chkInjectComment.addActionListener(new ActionListener({ actionPerformed: function() { syncTags(); } }));

    // Campo novo upload
    const uploadPanel = new JPanel(new BorderLayout(4, 4));
    uploadPanel.setBorder(BorderFactory.createTitledBorder("ID do Novo Upload (Automático)"));
    const txtNovoUpload = new JTextField();
    txtNovoUpload.setEditable(false);
    uploadPanel.add(txtNovoUpload, BorderLayout.CENTER);
    const chkPanel = new JPanel();
    chkPanel.setLayout(new BoxLayout(chkPanel, BoxLayout.Y_AXIS));
    chkPanel.add(chkInjectId);
    chkPanel.add(chkInjectComment);

    // Texto de aviso
    const lblAviso = new JLabel("<html><font color='red'><b>Reverter no máximo 20 por vez.<br>" +
        "Evita erro no limite de caracteres (max. 255)</b></font></html>");
    lblAviso.setFont(lblAviso.getFont().deriveFont(10.5));
    lblAviso.setBorder(BorderFactory.createEmptyBorder(4, 2, 2, 0));
    chkPanel.add(lblAviso);

    uploadPanel.add(chkPanel, BorderLayout.SOUTH);
    outerPanel.add(uploadPanel, BorderLayout.NORTH);

    // Lista de grupos
    const listWrapper = new JPanel(new BorderLayout());
    const listBorder = BorderFactory.createTitledBorder("ID's dos Changesets Revertidos (0)");
    listWrapper.setBorder(listBorder);

    const listPanel = new JPanel(new GridBagLayout());
    const scrollPane = new JScrollPane(listPanel);
    scrollPane.setBorder(BorderFactory.createEmptyBorder());
    scrollPane.setPreferredSize(new Dimension(260, 140));
    listWrapper.add(scrollPane, BorderLayout.CENTER);
    outerPanel.add(listWrapper, BorderLayout.CENTER);

    // Timer
    const monitorTimer = new Timer(1000, new ActionListener({ actionPerformed: function() {
        if (!dialog.isVisible()) { monitorTimer.stop(); return; }
        const allLayers = MainApplication.getLayerManager().getLayers();
        if (lastLayer && !allLayers.contains(lastLayer)) {
            // Camada deletada — limpa todos os estados da sessão anterior
            listaGrupos          = [];
            lastChangesetId      = null;
            idsCandidatos        = "";
            janelaReverterAberta = false;
            txtNovoUpload.setText("");
            lastLayer = MainApplication.getLayerManager().getEditLayer();
            SwingUtilities.invokeLater(function() { updateUI(); syncTags(); });
        }
        monitorarJanelas();
    }}));
    monitorTimer.start();

    // Focus handler
    const focusHandler = new PropertyChangeListener({
        propertyChange: function(e) {
            if (e.getPropertyName() !== "activeWindow") return;
            const win = e.getNewValue();
            if (win instanceof JDialog) {
                const title = String(win.getTitle() || "");
                if (title.match(/Upload|Enviar/)) syncTags();
            }
        }
    });
    KeyboardFocusManager.getCurrentKeyboardFocusManager().addPropertyChangeListener(focusHandler);

    dialog.addWindowListener(new WindowAdapter({ windowClosing: function() {
        monitorTimer.stop();
        KeyboardFocusManager.getCurrentKeyboardFocusManager().removePropertyChangeListener(focusHandler);
        const ds = MainApplication.getLayerManager().getEditDataSet();
        if (ds) ds.addChangeSetTag("revert:id", null);
        dialog.dispose();
    }}));

    addResetCallback(function() {
        try { monitorTimer.stop(); } catch(e) {}
        try { KeyboardFocusManager.getCurrentKeyboardFocusManager().removePropertyChangeListener(focusHandler); } catch(e) {}
        dialog.dispose();
    });

    updateUI();
    dialog.pack();
    dialog.setLocationRelativeTo(parent);
    dialog.setVisible(true);
}

iniciarScript();

JavaScript (new)

Última atualização: 2026-08-18

"use strict";

import { println } from 'josm/scriptingconsole';

const MainApplication = Java.type("org.openstreetmap.josm.gui.MainApplication");
const Notification    = Java.type("org.openstreetmap.josm.gui.Notification");
const UIManager       = Java.type("javax.swing.UIManager");
const OsmApi          = Java.type("org.openstreetmap.josm.io.OsmApi");
const Config          = Java.type("org.openstreetmap.josm.spi.preferences.Config");

const JDialog         = Java.type("javax.swing.JDialog");
const JPanel          = Java.type("javax.swing.JPanel");
const JLabel          = Java.type("javax.swing.JLabel");
const JButton         = Java.type("javax.swing.JButton");
const JCheckBox       = Java.type("javax.swing.JCheckBox");
const JTextField      = Java.type("javax.swing.JTextField");
const JScrollPane     = Java.type("javax.swing.JScrollPane");
const JOptionPane     = Java.type("javax.swing.JOptionPane");
const BoxLayout       = Java.type("javax.swing.BoxLayout");
const BorderLayout    = Java.type("java.awt.BorderLayout");
const BorderFactory   = Java.type("javax.swing.BorderFactory");
const GridBagLayout   = Java.type("java.awt.GridBagLayout");
const GridBagConstraints = Java.type("java.awt.GridBagConstraints");
const Insets          = Java.type("java.awt.Insets");
const Dimension       = Java.type("java.awt.Dimension");
const Font            = Java.type("java.awt.Font");
const Color           = Java.type("java.awt.Color");
const Timer           = Java.type("javax.swing.Timer");
const SwingUtilities  = Java.type("javax.swing.SwingUtilities");
const ChangesetCache         = Java.type("org.openstreetmap.josm.data.osm.ChangesetCache");
const ChangesetCacheListener = Java.type("org.openstreetmap.josm.data.osm.ChangesetCacheListener");
const OsmDataLayer           = Java.type("org.openstreetmap.josm.gui.layer.OsmDataLayer");
const LayerChangeListener    = Java.type("org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener");

const WindowAdapter          = Java.type("java.awt.event.WindowAdapter");
const ActionListener         = Java.type("java.awt.event.ActionListener");
const AtomicBoolean          = Java.type("java.util.concurrent.atomic.AtomicBoolean");
const ConcurrentHashMap      = Java.type("java.util.concurrent.ConcurrentHashMap");

function iniciarScript() {
    let layer = MainApplication.getLayerManager().getEditLayer();
    if (!layer) {
        new Notification("Nenhuma camada de edição ativa.")
            .setIcon(UIManager.getIcon("OptionPane.errorIcon")).show();
        return;
    }

    let listaGrupos = [];
    let lastChangesetId     = null;
    let lastLayer           = layer;
    let janelaReverterAberta = false;
    let idsCandidatos        = "";
    let ultimoReverterWin    = null;
    let temConflito          = false;

	// Variáveis thread-safe para comunicação segura entre o listener de background e a UI
    const pendingUploadId = { id: null };
    const triggerUploadNotification = new AtomicBoolean(false);
    const activeUploadChangesets = new ConcurrentHashMap();
    const temPendenciasFlag = new AtomicBoolean(false);

    const atualizarPendencias = function() {
        temPendenciasFlag.set(listaGrupos.length > 0);
    };

    // Função para limpar o estado da sessão/camada
    const limparEstadoCompleto = function() {
        listaGrupos          = [];
        lastChangesetId      = null;
        pendingUploadId.id   = null;
        idsCandidatos        = "";
        janelaReverterAberta = false;
        temConflito          = false;
        txtNovoUpload.setText("");
        activeUploadChangesets.clear();
        atualizarPendencias();
        lastLayer = MainApplication.getLayerManager().getEditLayer();
        updateUI();
        syncTags();
    };

	// Formata grupo para exibição: 3 IDs por linha
    function formatarGrupo(ids) {
        const linhas = [];
        for (let i = 0; i < ids.length; i += 3) {
            linhas.push(ids.slice(i, i + 3).join(";"));
        }
        return linhas.join("<br>");
    }

	// Todos os IDs únicos de todos os grupos
    function todosIds() {
        const set = new Set();
        listaGrupos.forEach(grupo => grupo.ids.forEach(id => set.add(id)));
        return Array.from(set);
    }

	// Sync das tags do changeset
    const syncTags = function() {
        const ds = MainApplication.getLayerManager().getEditDataSet();
        if (!ds) return;
        const ids = todosIds();
        // Se houver conflito ativo, bloqueia a injeção de tags para evitar corromper o estado do dataset
        if (chkInjectId.isSelected() && ids.length > 0 && !temConflito) {
            ds.addChangeSetTag("revert:id", ids.join(";"));
        } else {
            ds.addChangeSetTag("revert:id", null);
        }
        if (chkInjectComment.isSelected() && ids.length > 0 && !temConflito) {
            ds.addChangeSetTag("comment", "Revertendo changeset " + ids.join(", "));
        } else {
            ds.addChangeSetTag("comment", null);
        }
    };

	// Atualiza lista visual
    const updateUI = function() {
        listPanel.removeAll();

        if (temConflito) {
            listBorder = BorderFactory.createTitledBorder(null, "⚠ Conflito detetado! Resolva no mapa", 0, 0, null, new Color(200, 0, 0));
        } else {
            listBorder = BorderFactory.createTitledBorder(null, "ID's dos Changesets Revertidos (" + listaGrupos.length + ")", 0, 0, null, null);
        }
        listWrapper.setBorder(listBorder);

        const gbc = new GridBagConstraints();
        gbc.fill = GridBagConstraints.HORIZONTAL;
        gbc.weightx = 1.0;
        gbc.insets = new Insets(2, 2, 2, 2);
        gbc.gridx = 0;

        listaGrupos.forEach(function(grupo, index) {
            gbc.gridy = index;
            gbc.weighty = 0.0;

            const itemPanel = new JPanel(new BorderLayout(6, 0));
            itemPanel.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(200, 200, 200)),
                BorderFactory.createEmptyBorder(3, 6, 3, 6)
            ));

            const btnX = new JButton("x");
            btnX.setPreferredSize(new Dimension(22, 20));
            btnX.setFont(btnX.getFont().deriveFont(Font.BOLD, 10.0));
            btnX.setToolTipText("Remover este grupo (" + grupo.ids.length + " ID(s))");
            btnX.addActionListener(new (Java.extend(ActionListener, { actionPerformed: function() {
                listaGrupos.splice(index, 1);
                atualizarPendencias();
                updateUI();
                syncTags();
            }}))());

            const lbl = new JLabel("<html>" + formatarGrupo(grupo.ids) + "</html>");
            lbl.setFont(lbl.getFont().deriveFont(Font.PLAIN, 11.0));

            // Painel lateral para as opções (Parcial e Texto extra)
            const rightPanel = new JPanel();
            rightPanel.setLayout(new BoxLayout(rightPanel, BoxLayout.Y_AXIS));

            // Checkbox para definir Reversão Parcial por grupo
            const chkParcial = new JCheckBox("Parcial", grupo.parcial);
            chkParcial.setFont(chkParcial.getFont().deriveFont(10.0));
            chkParcial.setToolTipText("Marcar se a reversão deste grupo foi apenas parcial");
            chkParcial.addActionListener(new (Java.extend(ActionListener, { actionPerformed: function() {
                grupo.parcial = chkParcial.isSelected();
            }}))());

            // Checkbox para texto extra acima do comentário
            const chkTextoExtra = new JCheckBox("Comentário", grupo.textoExtra ? true : false);
            chkTextoExtra.setFont(chkTextoExtra.getFont().deriveFont(10.0));
            chkTextoExtra.setToolTipText("Adicionar texto extra acima do comentário de reversão");
            chkTextoExtra.addActionListener(new (Java.extend(ActionListener, { actionPerformed: function() {
                if (chkTextoExtra.isSelected()) {
                    const input = JOptionPane.showInputDialog(dialog, "Digite o texto extra para o comentário:", grupo.textoExtra || "");
                    if (input !== null && String(input).trim() !== "") {
                        grupo.textoExtra = String(input).trim();
                    } else {
                        grupo.textoExtra = "";
                        chkTextoExtra.setSelected(false);
                    }
                } else {
                    grupo.textoExtra = "";
                }
            }}))());

            rightPanel.add(chkParcial);
            rightPanel.add(chkTextoExtra);

            itemPanel.add(btnX, BorderLayout.WEST);
            itemPanel.add(lbl, BorderLayout.CENTER);
            itemPanel.add(rightPanel, BorderLayout.EAST);
            listPanel.add(itemPanel, gbc);
        });

		// Filler
        gbc.gridy = listaGrupos.length;
        gbc.weighty = 1.0;
        listPanel.add(new JPanel(), gbc);

        listWrapper.revalidate();
        listWrapper.repaint();
    };

	// Envio de comentários em todos os IDs acumulados
    const dispararComentarios = function(novoUploadId) {
        if (listaGrupos.length === 0 || !novoUploadId) return;

        const prefKey = 'oauth.access-token.object.OAuth20.api.openstreetmap.org';
        const prefValue = Config.getPref().get(prefKey);
        if (!prefValue) {
            new Notification("Erro: Autenticação OAuth2 não encontrada.")
                .setIcon(UIManager.getIcon("OptionPane.errorIcon")).show();
            return;
        }

        let oauthToken;
        try {
            oauthToken = JSON.parse(prefValue).access_token;
        } catch (e) {
            println("❌ Erro ao processar credenciais: " + e);
            return;
        }

        const baseUrl = OsmApi.getOsmApi().getBaseUrl();
        let successes = 0;

        // Mapeia cada ID para o seu estado de reversão (parcial ou total) e texto extra
        const mapIds = new Map();
        listaGrupos.forEach(grupo => {
            grupo.ids.forEach(id => {
                const infoAnterior = mapIds.get(id) || { parcial: false, textoExtra: "" };
                mapIds.set(id, {
                    parcial: infoAnterior.parcial || grupo.parcial,
                    textoExtra: grupo.textoExtra ? grupo.textoExtra : infoAnterior.textoExtra
                });
            });
        });

        mapIds.forEach(function(info, idStr) {
            const cId = parseInt(idStr, 10);
            if (isNaN(cId)) return;

            let comentario = info.parcial
                ? "Este changeset foi revertido parcialmente com changeset/" + novoUploadId
                : "Este changeset foi revertido com changeset/" + novoUploadId;

            if (info.textoExtra) {
                comentario = info.textoExtra + "\n" + comentario;
            }

            try {
                const url = new java.net.URL(baseUrl + "changeset/" + cId + "/comment");
                const conn = url.openConnection();
                conn.setRequestMethod("POST");
                conn.setRequestProperty("Authorization", "Bearer " + oauthToken);
                conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
                conn.setRequestProperty("User-Agent", "JOSM-Reverter-Automation-Script/1.0");
                conn.setDoOutput(true);
                const params = "text=" + java.net.URLEncoder.encode(comentario, "UTF-8");
                const os = conn.getOutputStream();
                const writer = new java.io.OutputStreamWriter(os, "UTF-8");
                writer.write(params);
                writer.flush();
                writer.close();
                os.close();
                if (conn.getResponseCode() === 200) {
                    successes++;
                } else {
                    println("Erro API OSM (Changeset " + cId + "): HTTP " + conn.getResponseCode());
                }
            } catch (err) {
                println("Falha na conexão com changeset " + cId + ": " + err);
            }
        });

        if (successes > 0) {
            new Notification(successes + " comentário(s) enviado(s) com sucesso!")
                .setIcon(UIManager.getIcon("OptionPane.informationIcon")).show();
            listaGrupos = [];
            temConflito = false;
            atualizarPendencias();
            updateUI();
            syncTags();
        } else {
            new Notification("Falha ao comentar. Verifique o Console do JOSM.")
                .setIcon(UIManager.getIcon("OptionPane.errorIcon")).show();
        }
    };

    // Aguarda o PleaseWaitDialog fechar e aplica 400ms para a UI desenhar a notificação nativa
    const aguardarFimUploadEDisparar = function(id) {
        const check = new Timer(150, new (Java.extend(ActionListener, {
            actionPerformed: function(e) {
                const windows = java.awt.Window.getWindows();
                let uploading = false;
                for (let i = 0; i < windows.length; i++) {
                    const w = windows[i];
                    if (w && w.isVisible() && w.getClass().getSimpleName() === "PleaseWaitDialog") {
                        uploading = true;
                        break;
                    }
                }
                if (!uploading) {
                    e.getSource().stop();
                    const visualDelay = new Timer(400, new (Java.extend(ActionListener, {
                        actionPerformed: function(evt) {
                            evt.getSource().stop();
                            dispararComentarios(id);
                        }
                    }))());
                    visualDelay.setRepeats(false);
                    visualDelay.start();
                }
            }
        }))());
        check.setRepeats(true);
        check.start();
    };

	// ChangesetCacheListener blindado contra histórico
    const changesetCacheListener = new (Java.extend(ChangesetCacheListener, {
        changesetCacheUpdated: function(event) {
            try {
				// 1. Rastreia apenas os changesets que abrem enquanto temos IDs para reverter
                if (!temPendenciasFlag.get()) return;

                const added = event.getAddedChangesets().iterator();
                while (added.hasNext()) {
                    let cs = added.next();
                    if (cs.isOpen()) {
                        activeUploadChangesets.put(String(cs.getId()), true);
                    }
                }

				// 2. Só aceita o fechamento se o changeset estava na lista de abertos
                const updated = event.getUpdatedChangesets().iterator();
                while (updated.hasNext()) {
                    let cs = updated.next();
                    let csIdStr = String(cs.getId());

                    if (!cs.isOpen() && activeUploadChangesets.containsKey(csIdStr)) {
                        activeUploadChangesets.remove(csIdStr);
						// Se estiver em conflito, ignora o fecho automático do changeset atual
                        if (!temConflito && csIdStr !== lastChangesetId) {
                            lastChangesetId = csIdStr;
                            pendingUploadId.id = csIdStr;
                            triggerUploadNotification.set(true);
                        }
                    }
                }
            } catch (e) {
                println("Erro Reverter ao processar ChangesetCacheEvent: " + e);
            }
        }
    }))();

    // Listener de camadas para limpar imediatamente ao remover a camada
    const layerHandler = new (Java.extend(LayerChangeListener, {
        layerRemoving: function(e) {
            if (e.getRemovedLayer() instanceof OsmDataLayer) {
                SwingUtilities.invokeLater(function() {
                    limparEstadoCompleto();
                });
            }
        },
        layerAdded: function(e) {},
        layerOrderChanged: function(e) {}
    }))();

	// Monitoramento de janelas
    const monitorarJanelas = function() {
        const windows = java.awt.Window.getWindows();
        let reverterVisivelNesteTick = false;
        let conflitoVisivelNesteTick = false;
        let uploadDialogVisivelNesteTick = false;

        for (let i = 0; i < windows.length; i++) {
            const win = windows[i];
            if (!win || !win.isVisible()) continue;
            const winClass = win.getClass().getSimpleName();

			// Deteta o diálogo de conflito gerado pelo JOSM (HTTP 409)
            if (winClass.match(/JOptionPane|HelpAwareOptionPane/i)) {
                try {
                    const title = String(win.getTitle() || "");
                    if (title.match(/Conflict|Conflito/i)) {
                        conflitoVisivelNesteTick = true;
                    }
                } catch(e) {}
            }

            if (winClass === "UploadDialog") {
                uploadDialogVisivelNesteTick = true;
            }

            if (winClass.match(/HistoryBrowserDialog|NotePopup|OSMObjInfoDialog|ValidatorDialog|Notification/i)) continue;

			// Captura IDs do plugin Reverter
            if (winClass === "ChangesetIdQuery") {
                reverterVisivelNesteTick = true;
                ultimoReverterWin = win;
                const extrairId = function(comp) {
                    try {
                        if (comp.getClass().getSimpleName() === "ChangesetIdsTextField") {
                            idsCandidatos = String(comp.getText()).trim();
                        }
                        if (comp.getComponents) {
                            const children = comp.getComponents();
                            for (let j = 0; j < children.length; j++) extrairId(children[j]);
                        }
                    } catch(e) {}
                };
                extrairId(win);
                continue;
            }
        }

		// Se ocorreu conflito, bloqueia a limpeza automática e protege os dados
        if (conflitoVisivelNesteTick && !temConflito) {
            temConflito = true;
            activeUploadChangesets.clear();
            SwingUtilities.invokeLater(function() { updateUI(); syncTags(); });
        }

		// Se estava em conflito, mas o utilizador abriu um novo UploadDialog após corrigir, destrava automaticamente
        if (temConflito && uploadDialogVisivelNesteTick) {
            temConflito = false;
            SwingUtilities.invokeLater(function() {
                updateUI();
                syncTags();
                new Notification("Conflito ultrapassado. Tags de reestruturação re-aplicadas.")
                    .setIcon(UIManager.getIcon("OptionPane.informationIcon")).show();
            });
        }

		// Processa ao fechar a janela do reverter
        if (reverterVisivelNesteTick) {
            janelaReverterAberta = true;
        } else if (janelaReverterAberta) {
            janelaReverterAberta = false;
            let foiConfirmado = true;
            if (ultimoReverterWin) {
                try {
                    if (typeof ultimoReverterWin.getValue === "function") {
                        foiConfirmado = (ultimoReverterWin.getValue() === 1);
                    }
                } catch(e) {}
            }

            if (foiConfirmado && idsCandidatos) {
                const novosIds = idsCandidatos.split(/[;,]/)
                    .map(function(s) { return s.trim(); })
                    .filter(function(s) { return s !== ""; });
                if (novosIds.length > 0) {
                    const chave    = novosIds.slice().sort().join(";");
                    const jaExiste = listaGrupos.some(function(g) {
                        return g.ids.slice().sort().join(";") === chave;
                    });
                    if (!jaExiste) {
                        listaGrupos.push({ ids: novosIds, parcial: false, textoExtra: "" });
                        atualizarPendencias();
                        SwingUtilities.invokeLater(function() { updateUI(); syncTags(); });
                    }
                }
            }
            idsCandidatos = "";
            ultimoReverterWin = null;
        }
    };

	// Interface
    const parent = MainApplication.getMainFrame();
    const dialog = new JDialog(parent, "Complemento do Plugin Reverter", false);
    dialog.setLayout(new BorderLayout(8, 8));
    dialog.setLocationRelativeTo(parent);

    const outerPanel = new JPanel(new BorderLayout(6, 6));
    outerPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
    dialog.setContentPane(outerPanel);

	// Checkboxes
    const chkInjectId = new JCheckBox("Inserir revert:id no changeset", true);
    chkInjectId.setFont(chkInjectId.getFont().deriveFont(11.0));
    const chkInjectComment = new JCheckBox("Inserir comentário no changeset", true);
    chkInjectComment.setFont(chkInjectComment.getFont().deriveFont(11.0));

    chkInjectId.addActionListener(new (Java.extend(ActionListener, { actionPerformed: function() { syncTags(); } }))());
    chkInjectComment.addActionListener(new (Java.extend(ActionListener, { actionPerformed: function() { syncTags(); } }))());

	// Campo novo upload
    const uploadPanel = new JPanel(new BorderLayout(4, 4));
    uploadPanel.setBorder(BorderFactory.createTitledBorder("ID do Novo Upload (Automático)"));
    const txtNovoUpload = new JTextField();
    txtNovoUpload.setEditable(false);
    uploadPanel.add(txtNovoUpload, BorderLayout.CENTER);
    const chkPanel = new JPanel();
    chkPanel.setLayout(new BoxLayout(chkPanel, BoxLayout.Y_AXIS));
    chkPanel.add(chkInjectId);
    chkPanel.add(chkInjectComment);

	// Texto de aviso
    const lblAviso = new JLabel("<html><font color='red'><b>Reverter no máximo 20 changesets por vez.<br>"+
                    "Evita erro no limíte de caracteres (Máx. 255)</b></font></html>");
    lblAviso.setFont(lblAviso.getFont().deriveFont(10.5));
    lblAviso.setBorder(BorderFactory.createEmptyBorder(4, 2, 2, 0));
    chkPanel.add(lblAviso);

    uploadPanel.add(chkPanel, BorderLayout.SOUTH);
    outerPanel.add(uploadPanel, BorderLayout.NORTH);

    // Lista de grupos
    const listWrapper = new JPanel(new BorderLayout());
    let listBorder = BorderFactory.createTitledBorder("ID's dos Changesets Revertidos (0)");
    listWrapper.setBorder(listBorder);

    const listPanel = new JPanel(new GridBagLayout());
    const scrollPane = new JScrollPane(listPanel);
    scrollPane.setBorder(BorderFactory.createEmptyBorder());
    scrollPane.setPreferredSize(new Dimension(280, 140));
    listWrapper.add(scrollPane, BorderLayout.CENTER);
    outerPanel.add(listWrapper, BorderLayout.CENTER);

    // Timer consome flags thread-safe e evita property change do AWT
    const monitorTimer = new Timer(1000, new (Java.extend(ActionListener, { actionPerformed: function() {
        if (!dialog.isVisible()) { monitorTimer.stop(); return; }

        if (triggerUploadNotification.getAndSet(false) && !temConflito) {
            const id = pendingUploadId.id;
            if (id) {
                txtNovoUpload.setText(id);
                new Notification("Upload detectado: " + id + ". Enviando comentários...")
                    .setIcon(UIManager.getIcon("OptionPane.informationIcon")).show();

                // Usa o poller dinâmico do PleaseWaitDialog
                aguardarFimUploadEDisparar(id);
            }
        }

        monitorarJanelas();
    }}))());
    monitorTimer.start();

    ChangesetCache.getInstance().addChangesetCacheListener(changesetCacheListener);
    MainApplication.getLayerManager().addLayerChangeListener(layerHandler);

    dialog.addWindowListener(new (Java.extend(WindowAdapter, { windowClosing: function() {
        monitorTimer.stop();
        ChangesetCache.getInstance().removeChangesetCacheListener(changesetCacheListener);
        MainApplication.getLayerManager().removeLayerChangeListener(layerHandler);
        const ds = MainApplication.getLayerManager().getEditDataSet();
        if (ds) ds.addChangeSetTag("revert:id", null);
        dialog.dispose();
    }}))());

    updateUI();
    dialog.pack();
    dialog.setLocationRelativeTo(parent);
    dialog.setVisible(true);
}

iniciarScript();