Files
ObsidianPad/renderer.js
T

399 lines
16 KiB
JavaScript
Raw Normal View History

2026-05-31 18:44:04 +08:00
const { ipcRenderer, shell } = require('electron');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const TurndownService = require('turndown');
const { marked } = require('marked');
// Глобальные сервисы
const turndownService = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
const rootPath = process.cwd();
const SETTINGS_PATH = path.join(rootPath, 'settings.json');
// Глобальное состояние
let currentFilePath = "";
let settings = {};
let translations = {};
let allNotes = [];
let isPreviewMode = false;
// --- 1. СИСТЕМНЫЕ ФУНКЦИИ (ОКНО) ---
window.closeApp = () => ipcRenderer.send('close-app');
window.minimizeApp = () => ipcRenderer.send('minimize-app');
// --- 2. ИНИЦИАЛИЗАЦИЯ ---
window.onload = () => {
loadSettingsFromFile();
loadLocalization();
applyTheme(settings.theme || 'dark');
loadSettingsIntoUI();
refreshFileList();
initDragAndDrop();
initContextMenu();
initHotkeys();
initScrollSync();
console.log("ObsidianPad Ready");
};
function loadSettingsFromFile() {
try {
if (fs.existsSync(SETTINGS_PATH)) {
settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
} else {
settings = {
theme: "dark", lang: "ru", obsFolder: "",
obsApiUrl: "http://127.0.0.1:27123", obsApiKey: "",
titleUrl: "", titleKey: "", titleModel: "",
transUrl: "", transKey: "", transModel: ""
};
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
}
} catch (e) { console.error(e); }
}
function loadLocalization() {
try {
const langPath = path.join(rootPath, 'lang.json');
if (fs.existsSync(langPath)) {
translations = JSON.parse(fs.readFileSync(langPath, 'utf8'));
updateUIStrings();
}
} catch (e) { console.error(e); }
}
function updateUIStrings() {
const lang = settings.lang || 'ru';
const dict = translations[lang];
if (!dict) return;
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (dict[key]) el.innerText = dict[key];
});
}
// --- 3. OBSIDIAN API ---
async function obsReq(urlPath, method = 'GET', data = null, headers = {}) {
if (!settings.obsApiUrl || !settings.obsApiKey) return null;
const baseUrl = settings.obsApiUrl.replace(/\/+$/, '');
try {
const config = {
url: `${baseUrl}${urlPath}`,
method, data,
headers: {
'Authorization': `Bearer ${settings.obsApiKey.trim()}`,
'Content-Type': 'text/markdown',
'Accept': 'application/json',
...headers
},
timeout: 5000
};
const res = await axios(config);
return res.data;
} catch (e) {
console.error("Obsidian API Connection Failed");
return null;
}
}
window.refreshFileList = async () => {
let folder = (settings.obsFolder || "").trim().replace(/^\/+|\/+$/g, '');
const data = await obsReq(folder === "" ? "/vault/" : `/vault/${folder}/`);
if (data && data.files) {
allNotes = data.files.filter(f => f.endsWith('.md') && f !== 'temp.md');
window.filterNotes(document.getElementById('note-search').value || "");
}
};
window.filterNotes = (query) => {
const select = document.getElementById('file-list');
const folder = (settings.obsFolder || "").trim().replace(/^\/+|\/+$/g, '');
const q = query.toLowerCase();
const lang = settings.lang || 'ru';
select.innerHTML = `<option value="">${translations[lang]?.select_note || "Заметки..."}</option>`;
allNotes.forEach(f => {
if (f.toLowerCase().includes(q)) {
const opt = document.createElement('option');
opt.value = folder === "" ? f : `${folder}/${f}`;
opt.textContent = f.replace('.md', '');
select.appendChild(opt);
}
});
if (currentFilePath) select.value = currentFilePath;
};
window.loadFile = async (path) => {
if (!path) return;
const content = await obsReq(`/vault/${path}`);
if (content !== null) {
document.getElementById('editor').value = (typeof content === 'string') ? content : JSON.stringify(content);
currentFilePath = path;
if (isPreviewMode) window.updatePreview();
window.updateStats();
document.getElementById('status').innerText = "Loaded: " + path.split('/').pop();
}
};
window.closeFile = () => {
currentFilePath = "";
document.getElementById('editor').value = "";
document.getElementById('file-list').value = "";
if (isPreviewMode) window.togglePreview();
window.updateStats();
document.getElementById('status').innerText = "Ready";
};
// --- 4. СОХРАНЕНИЕ И НОВАЯ ЗАМЕТКА ---
window.handleSaveAction = async () => {
if (!currentFilePath) window.openNewNoteModal();
else window.saveCurrentFile();
};
window.saveCurrentFile = async () => {
if (!currentFilePath) return;
const content = document.getElementById('editor').value;
const res = await obsReq(`/vault/${currentFilePath}`, 'PUT', content);
if (res !== null) {
document.getElementById('status').innerText = "Saved: " + new Date().toLocaleTimeString();
if (isPreviewMode) window.updatePreview();
}
};
window.openNewNoteModal = () => {
const content = document.getElementById('editor').value;
if (!content.trim()) return;
document.getElementById('new-note-modal').classList.remove('hidden');
document.getElementById('new-note-title').value = "";
document.getElementById('modal-status').innerText = "";
};
window.closeNewNoteModal = () => document.getElementById('new-note-modal').classList.add('hidden');
window.confirmCreateNote = async () => {
const title = document.getElementById('new-note-title').value.trim();
if (!title) return;
const content = document.getElementById('editor').value;
let folder = (settings.obsFolder || "").trim().replace(/^\/+|\/+$/g, '');
const fullPath = (folder ? folder + "/" : "") + title + ".md";
const res = await obsReq(`/vault/${fullPath}`, 'PUT', content);
if (res !== null) {
currentFilePath = fullPath;
await obsReq(`/vault/${folder ? folder + "/" : ""}temp.md`, 'PUT', "");
window.closeNewNoteModal();
await window.refreshFileList();
window.loadFile(fullPath);
}
};
// --- 5. ИИ ФУНКЦИИ ---
async function fetchAIChat(type, system, user) {
const url = settings[`${type}Url`];
const key = settings[`${type}Key`];
const model = settings[`${type}Model`];
if (!url || !model) return null;
try {
const res = await axios.post(`${url.replace(/\/models$/, '').replace(/\/+$/, '')}/chat/completions`, {
model, messages: [{role: "system", content: system}, {role: "user", content: user.substring(0, 2000)}]
}, { headers: { 'Authorization': `Bearer ${key}` } });
return res.data.choices[0].message.content.trim();
} catch (e) { return null; }
}
window.generateTitleForModal = async () => {
const content = document.getElementById('editor').value;
const status = document.getElementById('modal-status');
status.innerText = "AI Generation...";
const title = await fetchAIChat('title', "Create a 2-word title. Return ONLY text.", content);
if (title) {
document.getElementById('new-note-title').value = title.replace(/[\\/:"*?<>|]/g, "").trim();
status.innerText = "";
} else {
status.innerText = "AI Error";
}
};
window.generateTitleDirect = async () => {
if (!currentFilePath) return;
const title = await fetchAIChat('title', "Create 2-word title. Text only.", document.getElementById('editor').value);
if (title) {
const clean = title.replace(/[\\/:"*?<>|]/g, "").trim();
const folder = currentFilePath.includes('/') ? currentFilePath.substring(0, currentFilePath.lastIndexOf('/') + 1) : '';
const newPath = `${folder}${clean}.md`;
if (await obsReq(`/vault/${currentFilePath}`, 'PATCH', null, { 'X-File-Name': newPath })) {
currentFilePath = newPath; window.refreshFileList();
}
}
};
window.translateText = async () => {
const content = document.getElementById('editor').value;
if (!content) return;
document.getElementById('status').innerText = "Translating...";
const res = await fetchAIChat('trans', "Translate to Russian. Keep markdown.", content);
if (res) { document.getElementById('editor').value = res; window.saveCurrentFile(); }
};
// --- 6. РЕДАКТОР ---
window.insertText = (type) => {
const el = document.getElementById('editor');
const s = el.selectionStart, e = el.selectionEnd, sel = el.value.substring(s, e);
let r = "";
switch(type) {
case 'H1': r = `# ${sel}`; break;
case 'H2': r = `## ${sel}`; break;
case 'H3': r = `### ${sel}`; break;
case 'B': r = `**${sel}**`; break;
case 'I': r = `*${sel}*`; break;
case 'CODE': r = `\`\`\`\n${sel}\n\`\`\``; break;
case 'LINK': r = `[${sel}](url)`; break;
case 'UL': r = `- ${sel}`; break;
}
el.setRangeText(r, s, e, 'select');
el.focus();
window.updateStats();
};
window.togglePreview = () => {
isPreviewMode = !isPreviewMode;
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const btn = document.getElementById('preview-toggle');
if (isPreviewMode) {
window.updatePreview();
editor.classList.add('hidden');
preview.classList.remove('hidden');
btn.classList.add('active');
} else {
editor.classList.remove('hidden');
preview.classList.add('hidden');
btn.classList.remove('active');
}
};
window.updatePreview = () => {
document.getElementById('preview').innerHTML = marked.parse(document.getElementById('editor').value);
};
window.updateStats = () => {
const val = document.getElementById('editor').value;
const lines = val.split('\n').length;
const words = val.trim() ? val.trim().split(/\s+/).length : 0;
document.getElementById('stats').innerHTML = `стр: ${lines} &nbsp; слов: ${words}`;
};
// --- 7. НАСТРОЙКИ ---
window.toggleSettings = () => document.getElementById('settings').classList.toggle('hidden');
window.applyTheme = (t) => { document.body.className = t === 'light' ? 'light-theme' : 'dark-theme'; };
window.previewTheme = (t) => window.applyTheme(t);
window.changeLang = (l) => { settings.lang = l; updateUIStrings(); };
window.saveSettings = () => {
settings = {
theme: document.getElementById('app-theme').value,
lang: document.getElementById('app-lang').value,
obsFolder: document.getElementById('obs-folder').value,
obsApiUrl: document.getElementById('obs-api-url').value,
obsApiKey: document.getElementById('obs-api-key').value,
titleUrl: document.getElementById('ai-title-url').value,
titleKey: document.getElementById('ai-title-key').value,
titleModel: document.getElementById('ai-title-model').value,
transUrl: document.getElementById('ai-trans-url').value,
transKey: document.getElementById('ai-trans-key').value,
transModel: document.getElementById('ai-trans-model').value
};
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
window.applyTheme(settings.theme);
window.toggleSettings();
window.refreshFileList();
};
function loadSettingsIntoUI() {
const s = settings;
document.getElementById('app-theme').value = s.theme || "dark";
document.getElementById('app-lang').value = s.lang || "ru";
document.getElementById('obs-folder').value = s.obsFolder || "";
document.getElementById('obs-api-url').value = s.obsApiUrl || "";
document.getElementById('obs-api-key').value = s.obsApiKey || "";
document.getElementById('ai-title-url').value = s.titleUrl || "";
document.getElementById('ai-title-key').value = s.titleKey || "";
document.getElementById('ai-trans-url').value = s.transUrl || "";
document.getElementById('ai-trans-key').value = s.transKey || "";
if (s.titleModel) addOption('ai-title-model', s.titleModel);
if (s.transModel) addOption('ai-trans-model', s.transModel);
}
function addOption(id, val) {
const sel = document.getElementById(id);
if(sel) sel.innerHTML = `<option value="${val}">${val}</option>`;
}
window.fetchModels = async (type) => {
let url = document.getElementById(`ai-${type}-url`).value.trim();
const key = document.getElementById(`ai-${type}-key`).value.trim();
if (!url) return;
if (!url.endsWith('/models')) url = url.replace(/\/+$/, '') + '/models';
try {
const res = await axios.get(url, { headers: { 'Authorization': `Bearer ${key}` } });
const select = document.getElementById(`ai-${type}-model`);
select.innerHTML = "";
const models = res.data.data || res.data;
if (Array.isArray(models)) {
models.forEach(m => {
const id = m.id || m.name || m;
const opt = document.createElement('option');
opt.value = id; opt.textContent = id;
select.appendChild(opt);
});
}
} catch (e) { alert("AI Models Error"); }
};
// --- 8. ВСПОМОГАТЕЛЬНЫЕ ---
window.openAbout = () => {
try {
const aboutPath = path.join(rootPath, 'about.json');
const about = JSON.parse(fs.readFileSync(aboutPath, 'utf8'));
document.getElementById('about-name').innerText = about.appName;
document.getElementById('about-desc').innerText = about.description;
document.getElementById('about-ver').innerText = about.version;
document.getElementById('about-auth').innerText = about.author;
const link = document.getElementById('about-link');
link.onclick = (e) => { e.preventDefault(); shell.openExternal(about.github); };
document.getElementById('about-modal').classList.remove('hidden');
} catch(e) { alert("about.json error"); }
};
window.closeAbout = () => document.getElementById('about-modal').classList.add('hidden');
function initContextMenu() {
const menu = document.getElementById('context-menu');
if(!menu) return;
window.oncontextmenu = (e) => { e.preventDefault(); menu.style.top = e.pageY+'px'; menu.style.left = e.pageX+'px'; menu.classList.remove('hidden'); };
window.onclick = () => menu.classList.add('hidden');
}
function initDragAndDrop() {
const ed = document.getElementById('editor');
ed.ondrop = async (e) => {
e.preventDefault();
const html = e.dataTransfer.getData('text/html');
if (html) { ed.setRangeText(turndownService.turndown(html), ed.selectionStart, ed.selectionEnd, 'end'); }
};
}
function initHotkeys() {
window.onkeydown = (e) => {
if (e.ctrlKey) {
if (e.key === 's') { e.preventDefault(); window.handleSaveAction(); }
if (e.key === 'n') { e.preventDefault(); window.openNewNoteModal(); }
if (e.key === 'w') { e.preventDefault(); window.closeFile(); }
if (e.key === 'p') { e.preventDefault(); window.togglePreview(); }
}
};
}
function initScrollSync() {
const ed = document.getElementById('editor');
const pr = document.getElementById('preview');
ed.onscroll = () => { if (!isPreviewMode) pr.scrollTop = (ed.scrollTop / (ed.scrollHeight - ed.clientHeight)) * (pr.scrollHeight - pr.clientHeight); };
}
window.insertImageClick = () => document.getElementById('image-input').click();