436a9631fc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
399 lines
16 KiB
JavaScript
399 lines
16 KiB
JavaScript
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} слов: ${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(); |