browser_sync
Browser Sync Module
Модуль для синхронизации локальных markdown файлов с сервером trip2g прямо из браузера.
Сборка
cd obsidian-sync
npm install
npm run build:browser
Результаты:
dist/browser-sync.mjs— ESM bundle (~30KB)assets/ui/sync/browser-sync.js— IIFE bundle для MAM (~30KB)
Подключение
Standalone (ESM)
<script type="module">
import { BrowserEnv, configureStorage } from './browser-sync.mjs';
</script>
MAM/$mol
Модуль автоматически доступен как $.$trip2g_sync:
// В вашем .view.ts
const env = new this.$.$trip2g_sync.BrowserEnv({
apiUrl: 'https://yoursite.com/graphql',
apiKey: 'your-api-key',
}, {
onProgress: (p) => console.log(p),
})
await env.init()
await env.selectDirectory() // user gesture required
const result = await env.sync()
Файлы:
assets/ui/sync/sync.ts— MAM wrapper (require)assets/ui/sync/browser-sync.js— IIFE bundleassets/ui/sync/browser-sync.bundle.d.ts— TypeScript типы
API
Конфигурация хранилища
import { configureStorage } from './browser-sync.mjs';
// Вызвать ДО создания BrowserEnv
configureStorage({
dbName: 'my-app-sync' // Имя IndexedDB базы (default: 'trip2g-sync')
});
Создание BrowserEnv
import { BrowserEnv, type UICallbacks } from './browser-sync.mjs';
const callbacks: UICallbacks = {
// Прогресс синхронизации
onProgress: (progress) => {
console.log(`${progress.step}: ${progress.current}/${progress.total}`);
// progress.path - текущий файл (опционально)
},
// Конфликты (локальный и серверный файл изменены)
onConflict: async (conflicts) => {
// conflicts: Array<{ path, localContent, remoteContent, localHash, remoteHash }>
// Показать UI выбора
return conflicts.map(() => 'keep_local'); // или 'keep_remote', 'keep_both', 'skip'
},
// Конфликты ассетов
onAssetConflict: async (conflicts) => {
// conflicts: Array<{ path, absolutePath, noteId, localHash, remoteHash, remoteUrl }>
return conflicts.map(() => 'keep_local'); // или 'keep_remote', 'skip'
},
// Файлы удалены на сервере
onServerDeleted: async (paths) => {
// Спросить пользователя: удалить локально?
return false; // true = удалить, false = оставить
},
// Подтверждение push
confirmPush: async (paths) => {
// Показать список файлов для загрузки
return true; // true = загрузить, false = отменить
},
// Логирование
onLog: (message, level) => {
console.log(`[${level}] ${message}`);
}
};
const env = new BrowserEnv({
apiUrl: 'https://yoursite.com/graphql',
apiKey: 'your-api-key',
twoWaySync: false, // default: false (только push)
publishField: 'publish', // опционально: фильтр по frontmatter
}, callbacks);
Методы BrowserEnv
Управление директорией
// Инициализация (загрузить состояние из IndexedDB)
await env.init();
// Проверить есть ли сохраненная директория с разрешениями
const hasDir = await env.hasStoredDirectory();
// Запросить разрешение для сохраненной директории (требует user gesture)
const granted = await env.requestStoredPermission();
// Выбрать новую директорию (требует user gesture)
const selected = await env.selectDirectory();
// Имя текущей директории
const name = env.getDirectoryName(); // null если не выбрана
// Готов ли env к синхронизации
const ready = env.isReady();
// Очистить директорию и состояние
await env.clearDirectory();
Синхронизация
// Получить план синхронизации (preview)
const plan = await env.getSyncPlan();
console.log('Will push:', plan.pushes.length);
console.log('Will pull:', plan.pulls.length);
console.log('Conflicts:', plan.conflicts.length);
// Выполнить синхронизацию
const result = await env.sync();
console.log('Pushed:', result.pushed);
console.log('Pulled:', result.pulled);
console.log('Errors:', result.errors);
UI Requirements
1. Выбор директории
// Кнопка "Выбрать папку" - ДОЛЖНА быть в user gesture handler
selectFolderButton.onclick = async () => {
const selected = await env.selectDirectory();
if (selected) {
updateUI(env.getDirectoryName());
}
};
2. Восстановление разрешений
При перезагрузке страницы File System Access API требует повторного разрешения:
async function initSync() {
await env.init();
if (await env.hasStoredDirectory()) {
// Директория сохранена и разрешение активно
showSyncButton();
} else if (await loadDirectoryHandle()) {
// Директория сохранена, но разрешение потеряно
showRequestPermissionButton();
} else {
// Директория не выбрана
showSelectFolderButton();
}
}
// Кнопка запроса разрешения - ДОЛЖНА быть в user gesture handler
requestPermissionButton.onclick = async () => {
const granted = await env.requestStoredPermission();
if (granted) {
showSyncButton();
}
};
3. Кнопка синхронизации
syncButton.onclick = async () => {
syncButton.disabled = true;
try {
const result = await env.sync();
showResult(result);
} catch (e) {
showError(e.message);
} finally {
syncButton.disabled = false;
}
};
4. Отображение прогресса
const callbacks: UICallbacks = {
onProgress: (progress) => {
const percent = Math.round((progress.current / progress.total) * 100);
progressBar.style.width = `${percent}%`;
progressLabel.textContent = `${progress.step}: ${progress.path || ''}`;
}
};
5. UI для конфликтов
const callbacks: UICallbacks = {
onConflict: async (conflicts) => {
// Показать модальное окно с diff для каждого конфликта
const resolutions = [];
for (const conflict of conflicts) {
const resolution = await showConflictModal({
path: conflict.path,
local: conflict.localContent,
remote: conflict.remoteContent,
});
resolutions.push(resolution);
}
return resolutions;
}
};
Типы
// Прогресс
interface Progress {
step: 'classify' | 'pull' | 'push' | 'upload_asset' | 'download_asset' | 'conflict' | 'commit';
current: number;
total: number;
path?: string;
}
// План синхронизации
interface SyncPlan {
pushes: FileClassification[]; // Локальные изменения → сервер
pulls: FileClassification[]; // Серверные изменения → локально
conflicts: FileClassification[]; // Оба изменены
localOnly: FileClassification[]; // Только локально (новые)
remoteOnly: FileClassification[]; // Только на сервере (новые)
localDeleted: FileClassification[]; // Удалены локально
serverDeleted: FileClassification[]; // Удалены на сервере
unchanged: number;
}
// Результат синхронизации
interface SyncResult {
pulled: number;
pushed: number;
conflictsResolved: number;
assetsUploaded: number;
assetsDownloaded: number;
errors: string[];
}
// Разрешение конфликта
type ConflictResolution = 'keep_local' | 'keep_remote' | 'keep_both' | 'skip';
// Разрешение конфликта ассета
type AssetConflictResolution = 'keep_local' | 'keep_remote' | 'skip';
Пример минимальной интеграции
<!DOCTYPE html>
<html>
<head>
<title>Trip2g Sync</title>
</head>
<body>
<div id="app">
<div id="no-folder">
<button id="select-folder">Выбрать папку</button>
</div>
<div id="need-permission" style="display:none">
<p>Папка: <span id="folder-name"></span></p>
<button id="request-permission">Разрешить доступ</button>
</div>
<div id="ready" style="display:none">
<p>Папка: <span id="current-folder"></span></p>
<button id="sync">Синхронизировать</button>
<button id="change-folder">Сменить папку</button>
<div id="progress"></div>
<div id="result"></div>
</div>
</div>
<script type="module">
import { BrowserEnv, configureStorage, loadDirectoryHandle } from './browser-sync.mjs';
configureStorage({ dbName: 'my-sync-app' });
const env = new BrowserEnv({
apiUrl: 'https://yoursite.com/graphql',
apiKey: 'YOUR_API_KEY',
twoWaySync: false,
}, {
onProgress: (p) => {
document.getElementById('progress').textContent =
`${p.step}: ${p.current}/${p.total} ${p.path || ''}`;
},
onLog: (msg, level) => console.log(`[${level}] ${msg}`),
});
async function init() {
await env.init();
if (await env.hasStoredDirectory()) {
showReady();
} else if (await loadDirectoryHandle()) {
showNeedPermission();
} else {
showNoFolder();
}
}
function showNoFolder() {
document.getElementById('no-folder').style.display = 'block';
document.getElementById('need-permission').style.display = 'none';
document.getElementById('ready').style.display = 'none';
}
function showNeedPermission() {
document.getElementById('no-folder').style.display = 'none';
document.getElementById('need-permission').style.display = 'block';
document.getElementById('ready').style.display = 'none';
document.getElementById('folder-name').textContent = env.getDirectoryName() || 'Unknown';
}
function showReady() {
document.getElementById('no-folder').style.display = 'none';
document.getElementById('need-permission').style.display = 'none';
document.getElementById('ready').style.display = 'block';
document.getElementById('current-folder').textContent = env.getDirectoryName();
}
document.getElementById('select-folder').onclick = async () => {
if (await env.selectDirectory()) {
showReady();
}
};
document.getElementById('request-permission').onclick = async () => {
if (await env.requestStoredPermission()) {
showReady();
}
};
document.getElementById('change-folder').onclick = async () => {
await env.clearDirectory();
showNoFolder();
};
document.getElementById('sync').onclick = async () => {
const btn = document.getElementById('sync');
btn.disabled = true;
try {
const result = await env.sync();
document.getElementById('result').textContent =
`Done! Pushed: ${result.pushed}, Pulled: ${result.pulled}`;
} catch (e) {
document.getElementById('result').textContent = `Error: ${e.message}`;
} finally {
btn.disabled = false;
}
};
init();
</script>
</body>
</html>
Browser Support
- Chrome/Edge 86+
- Firefox (через polyfill в browser-fs-access)
- Safari 15.2+ (частичная поддержка)
File System Access API не поддерживается в мобильных браузерах.
Безопасность
-
API Key — не храните в клиентском коде. Используйте:
- Прокси на бэкенде
- Short-lived токены
- OAuth flow
-
Publish Field — защита от случайной публикации приватных заметок:
- На уровне
filterPlan— фильтрация файлов без поля - Defense in depth в
pushNotes— проверка перед отправкой
- На уровне
-
IndexedDB — хранит только handle и sync state, не содержимое файлов