Inicial Commit

This commit is contained in:
Gustavo Carreiro 2025-11-22 16:08:06 -03:00
commit ddab451dc8
27 changed files with 4130 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

5
LICENSE Normal file
View file

@ -0,0 +1,5 @@
Copyright (C) 2020-2025 by Dynalist Inc.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

94
README.md Normal file
View file

@ -0,0 +1,94 @@
# Obsidian Sample Plugin
This is a sample plugin for Obsidian (https://obsidian.md).
This project uses TypeScript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
## First time developing plugins?
Quick starting guide for new plugin devs:
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Releasing new releases
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## Adding your plugin to the community plugin list
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
## How to use
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Manually installing the plugin
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Improve code quality with eslint (optional)
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
- To use eslint with this project, make sure to install eslint from terminal:
- `npm install -g eslint`
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint ./src/`
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
If you have multiple URLs, you can also do:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## API Documentation
See https://github.com/obsidianmd/obsidian-api

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "obsidian-syncthing-manager",
"name": "Syncthing Manager",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Painel de controle para Syncthing. Monitore status e force a sincronização no Desktop e Mobile.",
"author": "Gustavo Carreiro",
"authorUrl": "https://github.com/gustjose",
"isDesktopOnly": false
}

2320
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "node scripts/esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node scripts/esbuild.config.mjs production",
"version": "node scripts/version-bump.mjs && git add manifest.json versions.json",
"test-cofre": "node scripts/install-vault.mjs",
"sync-conflict": "node scripts/create-conflict.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}

View file

@ -0,0 +1,43 @@
import fs from 'fs';
import path from 'path';
// --- CONFIGURAÇÃO ---
const VAULT_PATH = 'C:/Users/Gustavo/Android/dev-obsidian';
const BASE_FILENAME = 'TesteConflito.md';
function generateTimestamp() {
const now = new Date();
// Formata para YYYYMMDD-HHMMSS (Padrão do Syncthing)
const date = now.toISOString().slice(0, 10).replace(/-/g, '');
const time = now.toTimeString().slice(0, 8).replace(/:/g, '');
return `${date}-${time}`;
}
function createConflict() {
if (!fs.existsSync(VAULT_PATH)) {
console.error(`❌ Erro: O caminho do cofre não existe: ${VAULT_PATH}`);
process.exit(1);
}
// 1. Cria o arquivo Original (se não existir ou sobrescreve)
const originalPath = path.join(VAULT_PATH, BASE_FILENAME);
const originalContent = `# Nota Original\n\nEsta é a versão que estava no disco.\n\n- Item A\n- Item B\n- Item C`;
fs.writeFileSync(originalPath, originalContent);
console.log(`✅ Arquivo original criado: ${BASE_FILENAME}`);
// 2. Cria o arquivo de Conflito
const timestamp = generateTimestamp();
const conflictName = `TesteConflito.sync-conflict-${timestamp}-ScriptDev.md`;
const conflictPath = path.join(VAULT_PATH, conflictName);
const conflictContent = `# Nota em Conflito\n\nEsta é a versão que veio de outro dispositivo (Conflito).\n\n- Item A\n- Item B (Modificado)\n- Item D (Novo)`;
fs.writeFileSync(conflictPath, conflictContent);
console.log(`⚠️ Arquivo de conflito criado: ${conflictName}`);
console.log('\n👉 Agora abra o Obsidian e verifique o painel lateral do plugin!');
}
createConflict();

View file

@ -0,0 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

44
scripts/install-vault.mjs Normal file
View file

@ -0,0 +1,44 @@
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
const VAULT_PLUGINS_PATH = 'C:/Users/Gustavo/Android/dev-obsidian/.obsidian/plugins';
console.log('🚀 Iniciando processo de Build e Instalação...');
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf-8'));
const pluginId = manifest.id;
const targetDir = path.join(VAULT_PLUGINS_PATH, pluginId);
console.log(`📦 Plugin ID detectado: ${pluginId}`);
console.log(`📂 Destino: ${targetDir}`);
try {
console.log('🔨 Compilando (npm run build)...');
execSync('npm run build', { stdio: 'inherit' });
} catch (e) {
console.error('❌ Erro na compilação. Processo abortado.');
process.exit(1);
}
if (!fs.existsSync(targetDir)) {
console.log('📁 Criando pasta do plugin no cofre...');
fs.mkdirSync(targetDir, { recursive: true });
}
const filesToCopy = ['main.js', 'manifest.json', 'styles.css'];
filesToCopy.forEach(file => {
const source = path.resolve(file);
const destination = path.join(targetDir, file);
if (fs.existsSync(source)) {
fs.copyFileSync(source, destination);
console.log(`✅ Copiado: ${file}`);
} else {
console.warn(`⚠️ Aviso: Arquivo ${file} não encontrado na raiz.`);
}
});
console.log('🎉 Sucesso! Plugin atualizado no cofre de testes.');
console.log('👉 Lembre-se de recarregar o plugin no Obsidian ou usar o Hot Reload.');

17
scripts/version-bump.mjs Normal file
View file

@ -0,0 +1,17 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}

65
src/api/syncthing-api.ts Normal file
View file

@ -0,0 +1,65 @@
import { requestUrl, RequestUrlParam } from 'obsidian';
export class SyncthingAPI {
// Checa se o servidor está online (Ping)
static async getStatus(url: string, apiKey: string): Promise<any> {
return this.request(url, apiKey, '/rest/system/status');
}
// Busca a lista de todas as pastas
static async getFolders(url: string, apiKey: string): Promise<any[]> {
const config = await this.request(url, apiKey, '/rest/config');
return config.folders;
}
// Busca o status de uma pasta
static async getFolderStats(url: string, apiKey: string, folderId: string): Promise<any> {
return this.request(url, apiKey, `/rest/db/status?folder=${folderId}`);
}
static async getConnections(url: string, apiKey: string): Promise<any> {
return this.request(url, apiKey, '/rest/system/connections');
}
// --- NOVO: Força o Scan (Sincronização) ---
static async forceScan(url: string, apiKey: string, folderId?: string): Promise<any> {
let endpoint = '/rest/db/scan';
// Se tiver um ID de pasta, forçamos scan SÓ nela (mais rápido).
// Se não tiver, o Syncthing faz scan em tudo.
if (folderId) {
endpoint += `?folder=${folderId}`;
}
// Nota: Scan exige método POST
return this.request(url, apiKey, endpoint, 'POST');
}
// Refatorei para aceitar o método (GET/POST)
private static async request(url: string, apiKey: string, endpointPath: string, method: string = 'GET'): Promise<any> {
const baseUrl = url.replace(/\/$/, '');
const endpoint = `${baseUrl}${endpointPath}`;
const params: RequestUrlParam = {
url: endpoint,
method: method, // Agora é dinâmico
headers: { 'X-API-Key': apiKey }
};
const response = await requestUrl(params);
// O Syncthing retorna 200 (OK) para a maioria das coisas
if (response.status === 200) {
// O Endpoint de Scan retorna vazio ou texto simples, nem sempre JSON
if (response.text && response.text.length > 0) {
try {
return response.json;
} catch {
return response.text;
}
}
return {};
} else {
throw new Error(`Erro HTTP ${response.status}: ${endpointPath}`);
}
}
}

30
src/lang/lang.ts Normal file
View file

@ -0,0 +1,30 @@
import { moment } from 'obsidian';
import en from './locales/en';
import pt_br from './locales/pt-br';
// Estado global do idioma
let userLanguage = 'auto';
// Função para o Main.ts definir o idioma escolhido
export function setLanguage(lang: string) {
userLanguage = lang;
}
export function t(key: keyof typeof en): string {
let current = userLanguage;
// Se for 'auto', perguntamos ao Obsidian qual a língua
if (current === 'auto') {
// @ts-ignore
current = moment.locale();
}
// Detecção de Português
if (current && current.toLowerCase().startsWith('pt_br')) {
// @ts-ignore
return pt_br[key] || en[key];
}
// Padrão: Inglês
return en[key];
}

83
src/lang/locales/en.ts Normal file
View file

@ -0,0 +1,83 @@
export default {
// Comandos
cmd_open_panel: 'Open Side Panel',
cmd_force_sync: 'Force Sync Now',
cmd_debug_connect: 'Debug: Test Connection',
// Ribbon
ribbon_tooltip: 'Open Syncthing Controller',
// Status / View
status_synced: 'Synced',
status_syncing: 'Syncing...',
status_offline: 'Offline',
status_error: 'Error',
status_unknown: 'Unknown',
info_last_sync: 'Last Sync',
info_devices: 'Online Devices',
info_folder: 'Folder',
btn_sync_now: 'Sync Now',
btn_requesting: 'Requesting...',
// Settings - Headers
setting_header_conn: 'Connection',
setting_header_folder: 'Folder Monitoring',
setting_header_interface: 'Interface',
setting_header_general: 'General',
// Settings - General
setting_lang_name: 'Language',
setting_lang_desc: 'Force a specific language or use Auto.',
// Settings - Connection
setting_https_name: 'Use HTTPS',
setting_https_desc: 'Enable if your Syncthing GUI uses secure connection (TLS). Usually disabled for localhost.',
setting_host_name: 'IP Address / Host',
setting_host_desc: 'Default: 127.0.0.1 (localhost).',
setting_port_name: 'Port',
setting_port_desc: 'Default: 8384.',
setting_api_name: 'API Key',
setting_api_desc: 'Find it in Syncthing > Actions > Settings > General.',
btn_test_conn: 'Test Connection',
// Settings - Folder
setting_folder_name: 'Vault Folder',
setting_folder_desc: 'Select which Syncthing folder corresponds to this Obsidian Vault.',
dropdown_default: 'Select a folder',
dropdown_none: 'None selected',
btn_search_folders: 'Search folders (uses connection settings above)',
// Settings - Interface
setting_status_bar_name: 'Show in Status Bar',
setting_status_bar_desc: 'Desktop only. Reload to apply.',
setting_ribbon_name: 'Show Ribbon Icon',
setting_ribbon_desc: 'Left sidebar icon (Desktop/Mobile). Reload to apply.',
// Notices / Errors
notice_syncing: 'Syncing...',
notice_success_conn: 'Success! Connected to ID: ',
notice_fail_conn: 'Connection failed. Check IP, Port and HTTPS.',
notice_error_auth: 'Authentication Error.',
notice_offline: 'Syncthing Offline.',
notice_folders_found: 'folders found!',
notice_config_first: 'Configure URL and API Key first.',
notice_searching: 'Searching folders...',
// Conflict Modal
modal_conflict_title: 'Resolve Conflicts',
modal_conflict_empty: 'No conflict files found. All clean!',
modal_conflict_desc: 'Choose which version you want to keep.',
btn_compare: 'Compare Content',
btn_keep_original: 'Keep Original',
tooltip_keep_original: 'Deletes the conflict file (right side) and keeps the current one.',
btn_keep_conflict: 'Use This Version',
tooltip_keep_conflict: 'Replaces the original file with this conflict version.',
// Diff View
diff_original_header: 'Original File (Current)',
diff_conflict_header: 'Conflict Version',
diff_loading: 'Loading...',
diff_original_missing: '(Original file not found or deleted)',
diff_read_error: 'Error reading file.'
};

83
src/lang/locales/pt-br.ts Normal file
View file

@ -0,0 +1,83 @@
export default {
// Comandos
cmd_open_panel: 'Abrir Painel Lateral',
cmd_force_sync: 'Forçar Sincronização Agora',
cmd_debug_connect: 'Debug: Testar Conexão',
// Ribbon
ribbon_tooltip: 'Abrir Syncthing Controller',
// Status / View
status_synced: 'Sincronizado',
status_syncing: 'Sincronizando...',
status_offline: 'Desconectado',
status_error: 'Erro',
status_unknown: 'Desconhecido',
info_last_sync: 'Último Sync',
info_devices: 'Dispositivos Online',
info_folder: 'Pasta',
btn_sync_now: 'Sincronizar Agora',
btn_requesting: 'Solicitando...',
// Settings - Headers
setting_header_conn: 'Conexão',
setting_header_folder: 'Monitoramento de Pasta',
setting_header_interface: 'Interface',
setting_header_general: 'Geral',
// Settings - General
setting_lang_name: 'Idioma',
setting_lang_desc: 'Force um idioma específico ou use Automático.',
// Settings - Connection
setting_https_name: 'Usar HTTPS',
setting_https_desc: 'Ative se o Syncthing usa conexão segura (TLS). Geralmente desligado para localhost.',
setting_host_name: 'Endereço IP / Host',
setting_host_desc: 'Padrão: 127.0.0.1 (localhost).',
setting_port_name: 'Porta',
setting_port_desc: 'Padrão: 8384.',
setting_api_name: 'Chave da API (API Key)',
setting_api_desc: 'Encontre em Syncthing > Ações > Configurações > Geral.',
btn_test_conn: 'Testar Conexão',
// Settings - Folder
setting_folder_name: 'Pasta do Cofre',
setting_folder_desc: 'Selecione qual pasta do Syncthing corresponde a este cofre.',
dropdown_default: 'Selecione uma pasta',
dropdown_none: 'Nenhuma selecionada',
btn_search_folders: 'Buscar pastas (usa configurações acima)',
// Settings - Interface
setting_status_bar_name: 'Mostrar na Barra de Status',
setting_status_bar_desc: 'Apenas Desktop. Recarregue para aplicar.',
setting_ribbon_name: 'Mostrar Ícone no Ribbon',
setting_ribbon_desc: 'Barra lateral esquerda (Desktop/Mobile). Recarregue para aplicar.',
// Notices / Errors
notice_syncing: 'Sincronizando...',
notice_success_conn: 'Sucesso! Conectado ao ID: ',
notice_fail_conn: 'Falha. Verifique IP, Porta e HTTPS.',
notice_error_auth: 'Erro de Autenticação.',
notice_offline: 'Syncthing Offline.',
notice_folders_found: 'pastas encontradas!',
notice_config_first: 'Configure a API Key primeiro.',
notice_searching: 'Buscando pastas...',
// Modal de Conflitos
modal_conflict_title: 'Resolver Conflitos',
modal_conflict_empty: 'Nenhum arquivo de conflito encontrado. Tudo limpo!',
modal_conflict_desc: 'Escolha qual versão você deseja manter.',
btn_compare: 'Comparar Conteúdo',
btn_keep_original: 'Manter Original',
tooltip_keep_original: 'Apaga o arquivo de conflito (o da direita) e mantém o atual.',
btn_keep_conflict: 'Usar Esta Versão',
tooltip_keep_conflict: 'Substitui o original pelo arquivo de conflito.',
// Visualização de Diferença
diff_original_header: 'Arquivo Original (Atual)',
diff_conflict_header: 'Versão em Conflito',
diff_loading: 'Carregando...',
diff_original_missing: '(Arquivo original não encontrado ou foi deletado)',
diff_read_error: 'Erro ao ler arquivo.'
};

360
src/main.ts Normal file
View file

@ -0,0 +1,360 @@
import { Plugin, Notice, WorkspaceLeaf, Platform } from 'obsidian';
import { SyncthingSettingTab } from './ui/settings';
import { t, setLanguage } from './lang/lang';
import { SyncthingAPI } from './api/syncthing-api';
import { SyncthingView, VIEW_TYPE_SYNCTHING } from './ui/view';
import { SyncthingEventMonitor } from './services/event-monitor';
// Chaves do LocalStorage (Apenas para dados SENSÍVEIS ou ESPECÍFICOS do hardware)
const LS_KEY_HOST = 'syncthing-controller-host';
const LS_KEY_PORT = 'syncthing-controller-port';
const LS_KEY_HTTPS = 'syncthing-controller-https';
const LS_KEY_API = 'syncthing-controller-api-key';
const LS_KEY_FOLDER = 'syncthing-controller-folder-id';
const LS_KEY_FOLDER_LABEL = 'syncthing-controller-folder-label';
export interface SyncthingPluginSettings {
// Dados locais (não salvos no data.json real)
syncthingHost: string;
syncthingPort: string;
useHttps: boolean;
syncthingApiKey: string;
syncthingFolderId: string;
syncthingFolderLabel: string;
// Dados globais (sincronizados via data.json)
updateInterval: number;
showStatusBar: boolean;
showRibbonIcon: boolean;
language: string; // <--- Agora é global
}
const DEFAULT_SETTINGS: SyncthingPluginSettings = {
syncthingHost: '127.0.0.1',
syncthingPort: '8384',
useHttps: false,
syncthingApiKey: '',
syncthingFolderId: '',
syncthingFolderLabel: '',
updateInterval: 30,
showStatusBar: true,
showRibbonIcon: true,
language: 'auto'
}
type SyncStatus = 'conectado' | 'sincronizando' | 'escutando' | 'desconectado' | 'erro' | 'desconhecido';
export default class SyncthingController extends Plugin {
settings: SyncthingPluginSettings;
statusBarItem: HTMLElement | null = null;
ribbonIconEl: HTMLElement | null = null;
monitor: SyncthingEventMonitor;
public lastSyncTime: string = '--:--';
public connectedDevices: number = 0;
public currentStatus: SyncStatus = 'desconhecido';
get apiUrl(): string {
const protocol = this.settings.useHttps ? 'https://' : 'http://';
let host = this.settings.syncthingHost.replace(/^https?:\/\//, '').replace(/\/$/, '');
// Correção para Android:
if (host === 'localhost' && Platform.isMobileApp) {
host = '127.0.0.1';
}
return `${protocol}${host}:${this.settings.syncthingPort}`;
}
async onload() {
await this.loadSettings();
// Aplica o idioma salvo no data.json
setLanguage(this.settings.language);
this.registerView(
VIEW_TYPE_SYNCTHING,
(leaf) => new SyncthingView(leaf, this)
);
if (this.settings.showStatusBar) {
this.statusBarItem = this.addStatusBarItem();
this.statusBarItem.addClass('mod-clickable');
this.statusBarItem.setAttribute('aria-label', 'Syncthing Controller');
this.statusBarItem.addEventListener('click', async () => {
await this.forcarSincronizacao();
});
}
if (this.settings.showRibbonIcon) {
this.ribbonIconEl = this.addRibbonIcon('refresh-cw', t('ribbon_tooltip'), () => {
this.activateView();
});
}
this.addCommand({
id: 'open-syncthing-view',
name: t('cmd_open_panel'),
callback: () => { this.activateView(); }
});
this.addCommand({
id: 'syncthing-force-scan',
name: t('cmd_force_sync'),
icon: 'refresh-cw',
callback: async () => { await this.forcarSincronizacao(); }
});
this.addCommand({
id: 'test-syncthing-connection',
name: t('cmd_debug_connect'),
callback: async () => { await this.verificarConexao(true); }
});
this.addCommand({
id: 'debug-syncthing-connection',
name: 'Debug: Testar Conexão (Detalhado)',
callback: async () => {
// 1. Pega a URL calculada (já com a correção de IP se você aplicou)
const urlAlvo = this.apiUrl;
// 2. Mostra na tela qual URL está sendo usada
// Isso vai te confirmar se o "localhost" virou "127.0.0.1"
new Notice(`Tentando conectar em:\n${urlAlvo}`, 5000); // Fica 5s na tela
console.log(`[DEBUG] URL Alvo: ${urlAlvo}`);
try {
// 3. Tenta fazer o request básico de status
const status = await SyncthingAPI.getStatus(urlAlvo, this.settings.syncthingApiKey);
// 4. Se der certo, mostra o ID do dispositivo
new Notice(`✅ SUCESSO!\nConectado ao Device ID:\n${status.myID.substring(0, 10)}...`, 5000);
console.log('[DEBUG] Sucesso:', status);
} catch (error) {
// 5. Se der erro, mostra a mensagem exata do erro na tela
let msgErro = 'Erro desconhecido';
if (error instanceof Error) msgErro = error.message;
new Notice(`❌ FALHA:\n${msgErro}`, 10000); // Fica 10s na tela
console.error('[DEBUG] Erro detalhado:', error);
}
}
});
this.monitor = new SyncthingEventMonitor(this);
this.addSettingTab(new SyncthingSettingTab(this.app, this));
this.atualizarTodosVisuais();
await this.verificarConexao(false);
await this.atualizarContagemDispositivos();
this.monitor.start();
}
onunload() {
if (this.monitor) this.monitor.stop();
}
async activateView() {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_SYNCTHING);
if (leaves.length > 0) {
leaf = leaves[0];
} else {
leaf = workspace.getRightLeaf(false);
if (leaf) await leaf.setViewState({ type: VIEW_TYPE_SYNCTHING, active: true });
}
if (leaf) workspace.revealLeaf(leaf);
}
atualizarTodosVisuais() {
this.atualizarStatusBar(this.currentStatus);
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_SYNCTHING);
leaves.forEach((leaf) => {
if (leaf.view instanceof SyncthingView) leaf.view.updateView();
});
}
async testarApiApenas() {
try {
new Notice('Ping...');
const status = await SyncthingAPI.getStatus(this.apiUrl, this.settings.syncthingApiKey);
new Notice(`${t('notice_success_conn')} ${status.myID.substring(0, 5)}...`);
} catch (error) {
new Notice(t('notice_fail_conn'));
console.error(error);
}
}
async atualizarContagemDispositivos() {
try {
const connections = await SyncthingAPI.getConnections(this.apiUrl, this.settings.syncthingApiKey);
const devices = connections.connections || {};
const count = Object.values(devices).filter((d: any) => d.connected).length;
this.connectedDevices = count;
this.atualizarTodosVisuais();
} catch (e) {
console.error("Erro ao buscar dispositivos:", e);
}
}
async forcarSincronizacao() {
if (!this.settings.syncthingApiKey) {
new Notice(t('notice_config_first'));
return;
}
if ((this.app as any).commands) {
(this.app as any).commands.executeCommandById('editor:save-file');
}
new Notice(t('notice_syncing'));
this.currentStatus = 'sincronizando';
this.atualizarTodosVisuais();
try {
await SyncthingAPI.forceScan(
this.apiUrl,
this.settings.syncthingApiKey,
this.settings.syncthingFolderId
);
await this.atualizarContagemDispositivos();
} catch (error) {
console.error('Erro ao forçar scan:', error);
new Notice('Erro ao solicitar Sync.');
this.currentStatus = 'erro';
this.atualizarTodosVisuais();
}
}
async verificarConexao(showNotice: boolean = false) {
try {
const systemStatus = await SyncthingAPI.getStatus(this.apiUrl, this.settings.syncthingApiKey);
if (this.settings.syncthingFolderId) {
const folderStats = await SyncthingAPI.getFolderStats(this.apiUrl, this.settings.syncthingApiKey, this.settings.syncthingFolderId);
const state = folderStats.state;
const needBytes = folderStats.needBytes;
if (state === 'scanning' || state === 'syncing' || needBytes > 0) {
this.currentStatus = 'sincronizando';
if (showNotice) new Notice(t('notice_syncing'));
} else if (state === 'idle' && needBytes === 0) {
this.lastSyncTime = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
this.currentStatus = 'conectado';
if (showNotice) new Notice(t('status_synced'));
} else {
this.currentStatus = 'erro';
if (showNotice) new Notice(`Status: ${state}`);
}
} else {
this.currentStatus = 'conectado';
if (showNotice) new Notice(`${t('notice_success_conn')} ${systemStatus.myID.substring(0, 5)}...`);
}
this.atualizarTodosVisuais();
} catch (error) {
console.error(error);
if (error instanceof Error && error.message.includes('403')) {
this.currentStatus = 'erro';
if (showNotice) new Notice(t('notice_error_auth'));
} else {
this.currentStatus = 'desconectado';
if (showNotice) new Notice(t('notice_offline'));
}
this.atualizarTodosVisuais();
}
}
atualizarStatusBar(status: SyncStatus) {
this.currentStatus = status;
let text = t('status_unknown');
let color = 'var(--text-muted)';
let color_bg = 'currentColor';
switch (status) {
case 'conectado':
text = t('status_synced'); color = 'var(--text-success)'; break;
case 'sincronizando':
text = t('status_syncing'); color = 'var(--text-warning)'; break;
case 'desconectado':
text = t('status_offline'); color = 'var(--text-muted)'; break;
case 'erro':
text = t('status_error'); color = 'var(--text-error)'; break;
}
const tooltipInfo = `${text}\n\n${t('info_last_sync')}: ${this.lastSyncTime}\n${t('info_devices')}: ${this.connectedDevices}`;
const customSvg = `<svg viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg" fill="none" style="width: 100%; height: 100%;"><path d="M161.785 101.327a66 66 0 0 1-4.462 19.076m-49.314 40.495A66 66 0 0 1 96 162a66 66 0 0 1-45.033-17.75M31.188 83.531A66 66 0 0 1 96 30a66 66 0 0 1 39.522 13.141" stroke="${color_bg}" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M146.887 147.005a9 9 0 0 1-9 9 9 9 0 0 1-9-9 9 9 0 0 1 9-9 9 9 0 0 1 9-9 9 9 0 0 1 9 9zm18.25-78.199a9 9 0 0 1-9 9 9 9 0 0 1-9-9 9 9 0 0 1 9-9 9 9 0 0 1 9 9zM118.5 105a9 9 0 0 1-9 9 9 9 0 0 1-9-9 9 9 0 0 1 9-9 9 9 0 0 1 9 9zm-76.248 11.463a9 9 0 0 1-9 9 9 9 0 0 1-9-9 9 9 0 0 1 9-9 9 9 0 0 1 9 9zm113.885-68.656a21 21 0 0 0-21 21 21 21 0 0 0 1.467 7.564l-14.89 11.555A21 21 0 0 0 109.5 84a21 21 0 0 0-20.791 18.057l-36.45 5.48a21 21 0 0 0-19.007-12.074 21 21 0 0 0-21 21 21 21 0 0 0 21 21 21 21 0 0 0 20.791-18.059l36.463-5.48A21 21 0 0 0 109.5 126a21 21 0 0 0 6.283-.988l5.885 8.707a21 21 0 0 0-4.781 13.287 21 21 0 0 0 21 21 21 21 0 0 0 21-21 21 21 0 0 0-21-21 21 21 0 0 0-6.283.986l-5.883-8.707A21 21 0 0 0 130.5 105a21 21 0 0 0-1.428-7.594l14.885-11.552a21 21 0 0 0 12.18 3.953 21 21 0 0 0 21-21 21 21 0 0 0-21-21z" fill="currentColor" fill-rule="evenodd"/></svg>`;
if (this.statusBarItem) {
this.statusBarItem.empty();
const iconSpan = this.statusBarItem.createSpan({ cls: 'status-bar-item-icon' });
iconSpan.innerHTML = customSvg.replace('style="width: 100%; height: 100%;"', 'width="20" height="20"');
this.statusBarItem.style.color = color;
this.statusBarItem.setAttribute('aria-label', tooltipInfo);
}
if (this.ribbonIconEl) {
this.ribbonIconEl.empty();
const iconContainer = this.ribbonIconEl.createDiv({ cls: 'ribbon-icon-svg' });
iconContainer.innerHTML = customSvg;
this.ribbonIconEl.style.color = color;
this.ribbonIconEl.setAttribute('aria-label', tooltipInfo);
}
}
async loadSettings() {
// 1. Carrega tudo do data.json (incluindo o Idioma)
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
// Sanitização: Limpa placeholders antigos se ainda existirem no data.json
if ((this.settings.syncthingHost as string) === 'device-specific') this.settings.syncthingHost = '';
if ((this.settings.syncthingPort as string) === 'device-specific') this.settings.syncthingPort = '';
// 2. Sobrescreve APENAS os dados de conexão com o LocalStorage
const localHost = window.localStorage.getItem(LS_KEY_HOST);
const localPort = window.localStorage.getItem(LS_KEY_PORT);
const localHttps = window.localStorage.getItem(LS_KEY_HTTPS);
const localKey = window.localStorage.getItem(LS_KEY_API);
const localFolder = window.localStorage.getItem(LS_KEY_FOLDER);
const localFolderLabel = window.localStorage.getItem(LS_KEY_FOLDER_LABEL);
if (localHost) this.settings.syncthingHost = localHost;
if (localPort) this.settings.syncthingPort = localPort;
if (localHttps !== null) this.settings.useHttps = (localHttps === 'true');
if (localKey) this.settings.syncthingApiKey = localKey;
if (localFolder) this.settings.syncthingFolderId = localFolder;
if (localFolderLabel) this.settings.syncthingFolderLabel = localFolderLabel;
}
async saveSettings() {
// 1. Salva no data.json: Configurações GERAIS e IDIOMA
const sharedSettings = {
updateInterval: this.settings.updateInterval,
showStatusBar: this.settings.showStatusBar,
showRibbonIcon: this.settings.showRibbonIcon,
language: this.settings.language, // <--- SALVO NO JSON COMPARTILHADO
// Placeholders para não vazar dados locais
syncthingHost: 'device-specific',
syncthingPort: 'device-specific',
useHttps: false,
syncthingApiKey: 'device-specific',
syncthingFolderId: 'device-specific',
syncthingFolderLabel: 'device-specific'
};
await this.saveData(sharedSettings);
// 2. Salva no LocalStorage: Apenas dados locais e sensíveis
window.localStorage.setItem(LS_KEY_HOST, this.settings.syncthingHost);
window.localStorage.setItem(LS_KEY_PORT, this.settings.syncthingPort);
window.localStorage.setItem(LS_KEY_HTTPS, String(this.settings.useHttps));
window.localStorage.setItem(LS_KEY_API, this.settings.syncthingApiKey);
window.localStorage.setItem(LS_KEY_FOLDER, this.settings.syncthingFolderId);
window.localStorage.setItem(LS_KEY_FOLDER_LABEL, this.settings.syncthingFolderLabel);
}
}

View file

@ -0,0 +1,64 @@
import { App, TFile, Notice } from 'obsidian';
export interface ConflictFile {
file: TFile;
baseName: string;
date: string;
path: string;
}
export class ConflictManager {
app: App;
constructor(app: App) {
this.app = app;
}
getConflicts(): ConflictFile[] {
const allFiles = this.app.vault.getFiles();
const conflicts: ConflictFile[] = [];
const regex = /(.+)\.sync-conflict-(\d{8}-\d{6})-(.+)(\.[^.]+)$/;
allFiles.forEach(file => {
if (file.name.includes('.sync-conflict-')) {
const match = file.name.match(regex);
conflicts.push({
file: file,
baseName: match ? match[1] + match[4] : file.name,
date: match ? match[2] : 'Unknown',
path: file.path
});
}
});
return conflicts;
}
async deleteConflict(conflict: ConflictFile) {
try {
await this.app.vault.delete(conflict.file);
new Notice(`Conflito removido: ${conflict.file.name}`);
} catch (e) {
new Notice('Erro ao apagar arquivo.');
console.error(e);
}
}
async acceptConflict(conflict: ConflictFile) {
const originalPath = conflict.path.replace(conflict.file.name, conflict.baseName);
const originalFile = this.app.vault.getAbstractFileByPath(originalPath);
try {
if (originalFile && originalFile instanceof TFile) {
await this.app.vault.delete(originalFile);
}
await this.app.vault.rename(conflict.file, originalPath);
new Notice(`Versão de conflito restaurada: ${conflict.baseName}`);
} catch (e) {
new Notice('Erro ao restaurar conflito.');
console.error(e);
}
}
}

View file

@ -0,0 +1,131 @@
import { requestUrl, Notice } from 'obsidian';
import SyncthingController from '../main';
export class SyncthingEventMonitor {
plugin: SyncthingController;
running: boolean = false;
lastEventId: number = 0;
constructor(plugin: SyncthingController) {
this.plugin = plugin;
}
async start() {
if (this.running) return;
try {
// MUDANÇA AQUI: Usa o getter this.plugin.apiUrl
const url = `${this.plugin.apiUrl}/rest/events?limit=1`;
const response = await requestUrl({
url: url,
method: 'GET',
headers: { 'X-API-Key': this.plugin.settings.syncthingApiKey }
});
if (response.status === 200 && Array.isArray(response.json) && response.json.length > 0) {
const lastEvent = response.json[response.json.length - 1];
this.lastEventId = lastEvent.id;
}
} catch (e) {
console.error("Erro ao buscar ID inicial:", e);
}
this.running = true;
this.loop();
}
stop() {
this.running = false;
}
private async loop() {
while (this.running) {
try {
if (!this.plugin.settings.syncthingApiKey) {
await this.sleep(5000);
continue;
}
// MUDANÇA AQUI: Usa o getter this.plugin.apiUrl
const url = `${this.plugin.apiUrl}/rest/events?since=${this.lastEventId}&timeout=60`;
const response = await requestUrl({
url: url,
method: 'GET',
headers: { 'X-API-Key': this.plugin.settings.syncthingApiKey }
});
if (response.status === 200) {
const events = response.json;
if (Array.isArray(events) && events.length > 0) {
for (const event of events) {
this.lastEventId = event.id;
this.processEvent(event);
}
}
}
} catch (error) {
await this.sleep(2000);
}
}
}
// ... MANTENHA O RESTO DO ARQUIVO (processEvent, updateStatusFromState, sleep) IGUAL ...
// Copie do arquivo anterior, a lógica de processamento de eventos não mudou.
private processEvent(event: any) {
const targetFolder = this.plugin.settings.syncthingFolderId;
if (!targetFolder) return;
// 1. Dispositivos
if (event.type === 'DeviceConnected' || event.type === 'DeviceDisconnected') {
this.plugin.atualizarContagemDispositivos();
}
// 2. FolderCompletion
if (event.type === 'FolderCompletion') {
const data = event.data;
if (data.folder === targetFolder) {
if (data.completion < 100 || data.needBytes > 0) {
this.plugin.atualizarStatusBar('sincronizando');
} else {
this.plugin.lastSyncTime = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
this.plugin.atualizarStatusBar('conectado');
}
}
}
// 3. StateChanged
if (event.type === 'StateChanged') {
const data = event.data;
if (data.folder === targetFolder) {
if (data.to === 'scanning' || data.to === 'syncing') {
this.plugin.atualizarStatusBar('sincronizando');
} else if (data.to === 'idle') {
this.plugin.lastSyncTime = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
this.plugin.atualizarStatusBar('conectado');
} else if (data.to === 'error') {
this.plugin.atualizarStatusBar('erro');
}
}
}
// 4. FolderSummary
if (event.type === 'FolderSummary') {
const data = event.data;
if (data.folder === targetFolder) {
if (data.summary.needBytes > 0) {
this.plugin.atualizarStatusBar('sincronizando');
} else {
this.plugin.lastSyncTime = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
this.plugin.atualizarStatusBar('conectado');
}
}
}
}
private sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

131
src/ui/conflict-modal.ts Normal file
View file

@ -0,0 +1,131 @@
import { App, Modal, Setting, TFile, ButtonComponent, Notice } from 'obsidian';
import { ConflictManager, ConflictFile } from '../services/conflict-manager';
import { t } from '../lang/lang';
export class ConflictModal extends Modal {
manager: ConflictManager;
conflicts: ConflictFile[];
onCloseCallback: () => void;
constructor(app: App, manager: ConflictManager, onCloseCallback: () => void) {
super(app);
this.manager = manager;
this.onCloseCallback = onCloseCallback;
this.conflicts = manager.getConflicts();
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
this.modalEl.style.width = '80vw';
this.modalEl.style.maxWidth = '1000px';
contentEl.createEl('h2', { text: `${t('modal_conflict_title')} (${this.conflicts.length})` });
if (this.conflicts.length === 0) {
contentEl.createEl('p', { text: t('modal_conflict_empty') });
return;
}
contentEl.createEl('p', { text: t('modal_conflict_desc') });
this.conflicts.forEach(conflict => {
const container = contentEl.createDiv({ cls: 'conflict-item-box' });
container.createEl('h4', {
text: conflict.baseName,
attr: { style: 'margin-bottom: 5px;' }
});
container.createEl('div', { text: `Data: ${conflict.date}`, cls: 'conflict-meta' });
container.createEl('div', { text: conflict.path, cls: 'conflict-meta-path' });
const previewContainer = container.createDiv();
const actionsContainer = container.createDiv({ cls: 'conflict-actions' });
// Botão de Comparar
new ButtonComponent(actionsContainer)
.setButtonText(t('btn_compare'))
.setIcon('eye')
.onClick(async () => {
if (previewContainer.hasChildNodes()) {
previewContainer.empty();
return;
}
await this.renderContentPreview(previewContainer, conflict);
});
actionsContainer.createSpan({ attr: { style: 'flex-grow: 1;' } });
// Botão Manter Original
new ButtonComponent(actionsContainer)
.setButtonText(t('btn_keep_original'))
.setTooltip(t('tooltip_keep_original'))
.onClick(async () => {
await this.manager.deleteConflict(conflict);
this.refresh();
});
// Botão Aceitar Conflito
new ButtonComponent(actionsContainer)
.setButtonText(t('btn_keep_conflict'))
.setCta()
.setTooltip(t('tooltip_keep_conflict'))
.onClick(async () => {
await this.manager.acceptConflict(conflict);
this.refresh();
});
});
}
async renderContentPreview(container: HTMLElement, conflict: ConflictFile) {
container.empty();
const diffWrapper = container.createDiv({ cls: 'st-diff-container' });
// Lado Esquerdo (Original)
const leftBox = diffWrapper.createDiv({ cls: 'st-diff-box' });
leftBox.createDiv({ cls: 'st-diff-header', text: t('diff_original_header') });
const leftContent = leftBox.createDiv({ cls: 'st-diff-content st-diff-original' });
leftContent.setText(t('diff_loading'));
// Lado Direito (Conflito)
const rightBox = diffWrapper.createDiv({ cls: 'st-diff-box' });
rightBox.createDiv({ cls: 'st-diff-header', text: `${t('diff_conflict_header')} (${conflict.date})` });
const rightContent = rightBox.createDiv({ cls: 'st-diff-content st-diff-conflict' });
rightContent.setText(t('diff_loading'));
try {
// Lê o arquivo de conflito
const conflictText = await this.app.vault.read(conflict.file);
rightContent.setText(conflictText);
// Tenta achar e ler o original
const originalPath = conflict.path.replace(conflict.file.name, conflict.baseName);
const originalFile = this.app.vault.getAbstractFileByPath(originalPath);
if (originalFile instanceof TFile) {
const originalText = await this.app.vault.read(originalFile);
leftContent.setText(originalText);
} else {
leftContent.setText(t('diff_original_missing'));
}
} catch (error) {
console.error(error);
leftContent.setText(t('diff_read_error'));
rightContent.setText(t('diff_read_error'));
}
}
refresh() {
this.conflicts = this.manager.getConflicts();
this.onOpen();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
this.onCloseCallback();
}
}

181
src/ui/settings.ts Normal file
View file

@ -0,0 +1,181 @@
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
import SyncthingController from '../main';
import { SyncthingAPI } from '../api/syncthing-api';
import { t, setLanguage } from '../lang/lang';
export class SyncthingSettingTab extends PluginSettingTab {
plugin: SyncthingController;
constructor(app: App, plugin: SyncthingController) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// --- CONEXÃO ---
containerEl.createEl('h4', { text: t('setting_header_conn') });
new Setting(containerEl)
.setName(t('setting_host_name'))
.setDesc(t('setting_host_desc'))
.addText(text => {
text
.setPlaceholder('127.0.0.1')
.setValue(this.plugin.settings.syncthingHost)
.onChange(async (value) => {
this.plugin.settings.syncthingHost = value;
await this.plugin.saveSettings();
});
text.inputEl.style.width = '100%';
});
new Setting(containerEl)
.setName(t('setting_port_name'))
.setDesc(t('setting_port_desc'))
.addText(text => {
text
.setPlaceholder('8384')
.setValue(this.plugin.settings.syncthingPort)
.onChange(async (value) => {
this.plugin.settings.syncthingPort = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(t('setting_https_name'))
.setDesc(t('setting_https_desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.useHttps)
.onChange(async (value) => {
this.plugin.settings.useHttps = value;
await this.plugin.saveSettings();
}));
const api_key_Setting = new Setting(containerEl)
.setName(t('setting_api_name'))
.setDesc(t('setting_api_desc'))
.addText(text => {
text
.setPlaceholder('...')
.setValue(this.plugin.settings.syncthingApiKey)
.onChange(async (value) => {
this.plugin.settings.syncthingApiKey = value;
await this.plugin.saveSettings();
});
text.inputEl.type = 'password';
text.inputEl.style.width = '100%';
});
api_key_Setting.addButton(button => button
.setButtonText(t('btn_test_conn'))
.setCta()
.onClick(async () => {
await this.plugin.testarApiApenas();
}));
// --- PASTA ---
containerEl.createEl('br');
containerEl.createEl('h4', { text: t('setting_header_folder') });
const folderSetting = new Setting(containerEl)
.setName(t('setting_folder_name'))
.setDesc(t('setting_folder_desc'));
folderSetting.addDropdown(async (dropdown) => {
const currentId = this.plugin.settings.syncthingFolderId;
const currentLabel = this.plugin.settings.syncthingFolderLabel;
const display = currentLabel || currentId || t('dropdown_none');
dropdown.addOption(currentId, display);
dropdown.setValue(currentId);
dropdown.onChange(async (value) => {
this.plugin.settings.syncthingFolderId = value;
const index = dropdown.selectEl.selectedIndex;
if (index >= 0) {
const label = dropdown.selectEl.options[index].text;
this.plugin.settings.syncthingFolderLabel = label;
} else {
this.plugin.settings.syncthingFolderLabel = '';
}
await this.plugin.saveSettings();
this.plugin.verificarConexao(false);
});
});
folderSetting.addExtraButton(btn => btn
.setIcon('search')
.setTooltip(t('btn_search_folders'))
.onClick(async () => {
try {
new Notice(t('notice_searching'));
const folders = await SyncthingAPI.getFolders(this.plugin.apiUrl, this.plugin.settings.syncthingApiKey);
const selectEl = folderSetting.controlEl.querySelector('select');
if (selectEl) selectEl.innerHTML = '';
const optionDefault = document.createElement('option');
optionDefault.value = '';
optionDefault.text = t('dropdown_default');
selectEl?.appendChild(optionDefault);
folders.forEach((folder: any) => {
const option = document.createElement('option');
option.value = folder.id;
option.text = folder.label || folder.id;
option.selected = (folder.id === this.plugin.settings.syncthingFolderId);
selectEl?.appendChild(option);
});
new Notice(`${folders.length} ${t('notice_folders_found')}`);
} catch (error) {
new Notice(t('notice_fail_conn'));
console.error(error);
}
}));
// --- INTERFACE ---
containerEl.createEl('br');
containerEl.createEl('h4', { text: t('setting_header_interface') });
new Setting(containerEl)
.setName(t('setting_lang_name'))
.setDesc(t('setting_lang_desc'))
.addDropdown(dropDown => {
dropDown.addOption('auto', 'Auto');
dropDown.addOption('en', 'English');
dropDown.addOption('pt', 'Português');
dropDown.setValue(this.plugin.settings.language);
dropDown.onChange(async (value) => {
this.plugin.settings.language = value;
await this.plugin.saveSettings();
setLanguage(value); // Atualiza imediatamente
this.display(); // Recarrega a interface para aplicar a tradução
});
});
new Setting(containerEl)
.setName(t('setting_status_bar_name'))
.setDesc(t('setting_status_bar_desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showStatusBar)
.onChange(async (value) => {
this.plugin.settings.showStatusBar = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName(t('setting_ribbon_name'))
.setDesc(t('setting_ribbon_desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showRibbonIcon)
.onChange(async (value) => {
this.plugin.settings.showRibbonIcon = value;
await this.plugin.saveSettings();
}));
}
}

133
src/ui/view.ts Normal file
View file

@ -0,0 +1,133 @@
import { ItemView, WorkspaceLeaf, setIcon } from 'obsidian';
import SyncthingController from '../main';
import { t } from '../lang/lang';
import { ConflictManager } from '../services/conflict-manager';
import { ConflictModal } from '../ui/conflict-modal';
export const VIEW_TYPE_SYNCTHING = 'syncthing-view';
export class SyncthingView extends ItemView {
plugin: SyncthingController;
conflictManager: ConflictManager;
constructor(leaf: WorkspaceLeaf, plugin: SyncthingController) {
super(leaf);
this.plugin = plugin;
this.conflictManager = new ConflictManager(plugin.app);
}
getViewType() { return VIEW_TYPE_SYNCTHING; }
getDisplayText() { return 'Syncthing Controller'; }
getIcon() { return 'refresh-cw'; }
async onOpen() { this.render(); }
async onClose() {}
render() {
const container = this.containerEl.children[1];
container.empty();
container.addClass('syncthing-view-container');
// 1. Conflitos
const conflicts = this.conflictManager.getConflicts();
if (conflicts.length > 0) {
const alertBox = container.createDiv({ cls: 'st-conflict-alert' });
// Estilos diretos no elemento são permitidos (o erro era passar no objeto {})
alertBox.style.backgroundColor = 'var(--background-modifier-error)';
alertBox.style.color = 'var(--text-on-accent)';
alertBox.style.padding = '12px';
alertBox.style.borderRadius = '6px';
alertBox.style.marginBottom = '20px';
alertBox.style.textAlign = 'center';
alertBox.style.fontWeight = 'bold';
alertBox.style.cursor = 'pointer';
alertBox.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
const iconSpan = alertBox.createSpan();
setIcon(iconSpan, 'alert-octagon');
alertBox.createSpan({ text: ` ${conflicts.length} Conflito(s) Detectado(s)!` });
// Aqui usamos a CLASSE do CSS que você já copiou
alertBox.createDiv({
text: 'Clique aqui para resolver',
cls: 'st-conflict-subtext'
});
alertBox.addEventListener('click', () => {
new ConflictModal(this.app, this.conflictManager, () => {
this.render();
}).open();
});
}
// 2. Status
const statusBox = container.createDiv({ cls: 'st-status-box' });
const iconDiv = statusBox.createDiv({ cls: 'st-big-icon' });
const currentStatus = this.plugin.currentStatus;
let color = 'var(--text-muted)';
let statusText = t('status_unknown');
switch (currentStatus) {
case 'conectado':
color = 'var(--text-success)';
statusText = t('status_synced');
setIcon(iconDiv, 'check-circle');
break;
case 'sincronizando':
color = 'var(--text-warning)';
statusText = t('status_syncing');
setIcon(iconDiv, 'loader');
break;
case 'desconectado':
color = 'var(--text-muted)';
statusText = t('status_offline');
setIcon(iconDiv, 'wifi-off');
break;
case 'erro':
color = 'var(--text-error)';
statusText = t('status_error');
setIcon(iconDiv, 'alert-triangle');
break;
}
iconDiv.style.color = color;
statusBox.createDiv({ cls: 'st-status-text', text: statusText }).style.color = color;
// 3. Tabela
const infoContainer = container.createDiv({ cls: 'st-info-container' });
this.createRow(infoContainer, 'clock', t('info_last_sync'), this.plugin.lastSyncTime);
this.createRow(infoContainer, 'monitor', t('info_devices'), this.plugin.connectedDevices.toString());
const folderDisplay = this.plugin.settings.syncthingFolderLabel || 'Default';
this.createRow(infoContainer, 'folder', t('info_folder'), folderDisplay);
// 4. Botão
const btnContainer = container.createDiv({ cls: 'st-btn-container' });
const btn = btnContainer.createEl('button', { cls: 'mod-cta', text: t('btn_sync_now') });
btn.addEventListener('click', async () => {
btn.setText(t('btn_requesting'));
btn.disabled = true;
await this.plugin.forcarSincronizacao();
});
}
createRow(container: HTMLElement, icon: string, label: string, value: string) {
const row = container.createDiv({ cls: 'st-info-row' });
const left = row.createDiv({ cls: 'st-info-left' });
setIcon(left.createSpan({ cls: 'st-info-icon' }), icon);
left.createSpan({ text: label });
const valueDiv = row.createDiv({ cls: 'st-info-value', text: value });
valueDiv.style.maxWidth = '150px';
valueDiv.style.whiteSpace = 'nowrap';
valueDiv.style.overflow = 'hidden';
valueDiv.style.textOverflow = 'ellipsis';
}
updateView() {
this.render();
}
}

175
styles.css Normal file
View file

@ -0,0 +1,175 @@
/* --- PAINEL LATERAL (VIEW) --- */
.syncthing-view-container {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Caixa de Status */
.st-status-box {
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 10px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.st-big-icon svg {
width: 48px;
height: 48px;
}
.st-status-text {
font-size: 1.2em;
font-weight: bold;
}
/* Tabela de Informações */
.st-info-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.st-info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid var(--background-modifier-border);
}
.st-info-left {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
}
.st-info-icon svg {
width: 16px;
height: 16px;
}
.st-info-value {
font-weight: bold;
color: var(--text-normal);
}
/* Botões */
.st-btn-container button {
width: 100%;
margin-top: 15px;
height: 40px;
}
/* --- ALERTA DE CONFLITO (VIEW) --- */
.st-conflict-alert {
background-color: var(--background-modifier-error);
color: var(--text-on-accent);
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
text-align: center;
font-weight: bold;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transition: opacity 0.2s ease;
}
.st-conflict-alert:hover {
opacity: 0.9;
}
/* Classe que resolve o erro do VS Code */
.st-conflict-subtext {
font-size: 0.8em;
font-weight: normal;
margin-top: 4px;
opacity: 0.9;
}
/* --- MODAL DE RESOLUÇÃO DE CONFLITOS --- */
.conflict-item-box {
border: 1px solid var(--background-modifier-border);
padding: 15px;
margin-bottom: 10px;
border-radius: 8px;
background-color: var(--background-primary);
}
.conflict-meta {
font-size: 0.85em;
color: var(--text-muted);
margin-top: 4px;
}
.conflict-meta-path {
font-size: 0.8em;
color: var(--text-faint);
margin-bottom: 10px;
font-family: var(--font-monospace);
}
.conflict-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid var(--background-modifier-border);
padding-top: 10px;
}
/* --- COMPARAÇÃO DE CONTEÚDO (DIFF) --- */
.st-diff-container {
display: flex;
gap: 10px;
margin-top: 15px;
margin-bottom: 15px;
border-top: 1px solid var(--background-modifier-border);
padding-top: 10px;
}
.st-diff-box {
flex: 1;
display: flex;
flex-direction: column;
width: 50%;
}
.st-diff-header {
font-weight: bold;
margin-bottom: 5px;
color: var(--text-muted);
font-size: 0.8em;
text-transform: uppercase;
}
.st-diff-content {
background-color: var(--background-primary-alt);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 10px;
height: 200px;
overflow-y: auto;
font-family: var(--font-monospace);
font-size: 0.8em;
white-space: pre-wrap;
user-select: text;
}
.st-diff-original {
border-left: 3px solid var(--text-success);
}
.st-diff-conflict {
border-left: 3px solid var(--text-warning);
}

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}