mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-29 22:12:31 +00:00
Add: vaultSelector (webapp)
This commit is contained in:
@@ -55,8 +55,8 @@ The built files will be in the `dist` directory.
|
|||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
1. Open the webapp in your browser
|
1. Open the webapp in your browser (`webapp.html`)
|
||||||
2. Grant directory access when prompted
|
2. Select a vault from history or grant access to a new directory
|
||||||
3. Configure CouchDB connection by editing `.livesync/settings.json` in your vault
|
3. Configure CouchDB connection by editing `.livesync/settings.json` in your vault
|
||||||
- You can also copy data.json from Obsidian's plug-in folder.
|
- You can also copy data.json from Obsidian's plug-in folder.
|
||||||
|
|
||||||
@@ -98,8 +98,11 @@ webapp/
|
|||||||
│ ├── ServiceFileAccessImpl.ts
|
│ ├── ServiceFileAccessImpl.ts
|
||||||
│ ├── DatabaseFileAccess.ts
|
│ ├── DatabaseFileAccess.ts
|
||||||
│ └── FSAPIServiceModules.ts
|
│ └── FSAPIServiceModules.ts
|
||||||
├── main.ts # Application entry point
|
├── bootstrap.ts # Vault picker + startup orchestration
|
||||||
├── index.html # HTML entry
|
├── main.ts # LiveSync core bootstrap (after vault selected)
|
||||||
|
├── vaultSelector.ts # FileSystem handle history and permission flow
|
||||||
|
├── webapp.html # Main HTML entry
|
||||||
|
├── index.html # Redirect entry for compatibility
|
||||||
├── package.json
|
├── package.json
|
||||||
├── vite.config.ts
|
├── vite.config.ts
|
||||||
└── README.md
|
└── README.md
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { LiveSyncWebApp } from "./main";
|
||||||
|
import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector";
|
||||||
|
|
||||||
|
const historyStore = new VaultHistoryStore();
|
||||||
|
let app: LiveSyncWebApp | null = null;
|
||||||
|
|
||||||
|
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Missing element: #${id}`);
|
||||||
|
}
|
||||||
|
return element as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(kind: "info" | "warning" | "error" | "success", message: string): void {
|
||||||
|
const statusEl = getRequiredElement<HTMLDivElement>("status");
|
||||||
|
statusEl.className = kind;
|
||||||
|
statusEl.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusyState(isBusy: boolean): void {
|
||||||
|
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
|
||||||
|
pickNewBtn.disabled = isBusy;
|
||||||
|
|
||||||
|
const historyButtons = document.querySelectorAll<HTMLButtonElement>(".vault-item button");
|
||||||
|
historyButtons.forEach((button) => {
|
||||||
|
button.disabled = isBusy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastUsed(unixMillis: number): string {
|
||||||
|
if (!unixMillis) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
return new Date(unixMillis).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHistoryList(): Promise<VaultHistoryItem[]> {
|
||||||
|
const listEl = getRequiredElement<HTMLDivElement>("vault-history-list");
|
||||||
|
const emptyEl = getRequiredElement<HTMLParagraphElement>("vault-history-empty");
|
||||||
|
|
||||||
|
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
emptyEl.style.display = items.length > 0 ? "none" : "block";
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "vault-item";
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "vault-item-info";
|
||||||
|
|
||||||
|
const name = document.createElement("div");
|
||||||
|
name.className = "vault-item-name";
|
||||||
|
name.textContent = item.name;
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "vault-item-meta";
|
||||||
|
const label = item.id === lastUsedId ? "Last used" : "Used";
|
||||||
|
meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`;
|
||||||
|
|
||||||
|
info.append(name, meta);
|
||||||
|
|
||||||
|
const useButton = document.createElement("button");
|
||||||
|
useButton.type = "button";
|
||||||
|
useButton.textContent = "Use this vault";
|
||||||
|
useButton.addEventListener("click", () => {
|
||||||
|
void startWithHistory(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(info, useButton);
|
||||||
|
listEl.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWithHandle(handle: FileSystemDirectoryHandle): Promise<void> {
|
||||||
|
setStatus("info", `Starting LiveSync with vault: ${handle.name}`);
|
||||||
|
app = new LiveSyncWebApp(handle);
|
||||||
|
await app.initialize();
|
||||||
|
|
||||||
|
const selectorEl = getRequiredElement<HTMLDivElement>("vault-selector");
|
||||||
|
selectorEl.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWithHistory(item: VaultHistoryItem): Promise<void> {
|
||||||
|
setBusyState(true);
|
||||||
|
try {
|
||||||
|
const handle = await historyStore.activateHistoryItem(item);
|
||||||
|
await startWithHandle(handle);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Directory] Failed to open history vault:", error);
|
||||||
|
setStatus("error", `Failed to open saved vault: ${String(error)}`);
|
||||||
|
setBusyState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWithNewPicker(): Promise<void> {
|
||||||
|
setBusyState(true);
|
||||||
|
try {
|
||||||
|
const handle = await historyStore.pickNewVault();
|
||||||
|
await startWithHandle(handle);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Directory] Failed to pick vault:", error);
|
||||||
|
setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`);
|
||||||
|
setBusyState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeVaultSelector(): Promise<void> {
|
||||||
|
setStatus("info", "Select a vault folder to start LiveSync.");
|
||||||
|
|
||||||
|
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
|
||||||
|
pickNewBtn.addEventListener("click", () => {
|
||||||
|
void startWithNewPicker();
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderHistoryList();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("load", async () => {
|
||||||
|
try {
|
||||||
|
await initializeVaultSelector();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize vault selector:", error);
|
||||||
|
setStatus("error", `Initialization failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
void app?.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).livesyncApp = {
|
||||||
|
getApp: () => app,
|
||||||
|
historyStore,
|
||||||
|
};
|
||||||
+3
-200
@@ -3,207 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Self-hosted LiveSync WebApp</title>
|
<title>Self-hosted LiveSync WebApp Launcher</title>
|
||||||
<style>
|
<meta http-equiv="refresh" content="0; url=./webapp.html">
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
||||||
padding: 40px;
|
|
||||||
max-width: 600px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status {
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status.error {
|
|
||||||
background: #fee;
|
|
||||||
color: #c33;
|
|
||||||
border: 1px solid #fcc;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status.warning {
|
|
||||||
background: #ffeaa7;
|
|
||||||
color: #d63031;
|
|
||||||
border: 1px solid #fdcb6e;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status.success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status.info {
|
|
||||||
background: #d1ecf1;
|
|
||||||
color: #0c5460;
|
|
||||||
border: 1px solid #bee5eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section {
|
|
||||||
margin-top: 30px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section h2 {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section ul {
|
|
||||||
list-style: none;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section li {
|
|
||||||
padding: 8px 0;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section li::before {
|
|
||||||
content: "•";
|
|
||||||
color: #667eea;
|
|
||||||
font-weight: bold;
|
|
||||||
display: inline-block;
|
|
||||||
width: 1em;
|
|
||||||
margin-left: -1em;
|
|
||||||
padding-right: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-list {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-list h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background: #e9ecef;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
margin-top: 30px;
|
|
||||||
text-align: center;
|
|
||||||
color: #999;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a {
|
|
||||||
color: #667eea;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-link {
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.container {
|
|
||||||
padding: 30px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<p>Redirecting to <a href="./webapp.html">WebApp</a>...</p>
|
||||||
<h1>🔄 Self-hosted LiveSync</h1>
|
|
||||||
<p class="subtitle">Browser-based Self-hosted LiveSync using FileSystem API</p>
|
|
||||||
|
|
||||||
<div id="status" class="info">
|
|
||||||
Initialising...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-section">
|
|
||||||
<h2>About This Application</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Runs entirely in your browser</li>
|
|
||||||
<li>Uses FileSystem API to access your local vault</li>
|
|
||||||
<li>Syncs with CouchDB server (like Obsidian plugin)</li>
|
|
||||||
<li>Settings stored in <code>.livesync/settings.json</code></li>
|
|
||||||
<li>Real-time file watching with FileSystemObserver (Chrome 124+)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-section">
|
|
||||||
<h2>How to Use</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Grant directory access when prompted</li>
|
|
||||||
<li>Create <code>.livesync/settings.json</code> in your vault folder. (Compatible with Obsidian's Self-hosted LiveSync)</li>
|
|
||||||
<li>Add your CouchDB connection details</li>
|
|
||||||
<li>Your files will be synced automatically</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="console-link">
|
|
||||||
💡 Open browser console (F12) for detailed logs
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<p>
|
|
||||||
Powered by
|
|
||||||
<a href="https://github.com/vrtmrz/obsidian-livesync" target="_blank">
|
|
||||||
Self-hosted LiveSync
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module" src="./main.ts"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+7
-119
@@ -13,9 +13,7 @@ import type { InjectableSettingService } from "@lib/services/implements/injectab
|
|||||||
import { useOfflineScanner } from "@lib/serviceFeatures/offlineScanner";
|
import { useOfflineScanner } from "@lib/serviceFeatures/offlineScanner";
|
||||||
import { useRedFlagFeatures } from "@/serviceFeatures/redFlag";
|
import { useRedFlagFeatures } from "@/serviceFeatures/redFlag";
|
||||||
import { useCheckRemoteSize } from "@lib/serviceFeatures/checkRemoteSize";
|
import { useCheckRemoteSize } from "@lib/serviceFeatures/checkRemoteSize";
|
||||||
import { SetupManager } from "@/modules/features/SetupManager";
|
|
||||||
// import { ModuleObsidianSettingsAsMarkdown } from "@/modules/features/ModuleObsidianSettingAsMarkdown";
|
// import { ModuleObsidianSettingsAsMarkdown } from "@/modules/features/ModuleObsidianSettingAsMarkdown";
|
||||||
import { ModuleSetupObsidian } from "@/modules/features/ModuleSetupObsidian";
|
|
||||||
// import { ModuleObsidianMenu } from "@/modules/essentialObsidian/ModuleObsidianMenu";
|
// import { ModuleObsidianMenu } from "@/modules/essentialObsidian/ModuleObsidianMenu";
|
||||||
|
|
||||||
const SETTINGS_DIR = ".livesync";
|
const SETTINGS_DIR = ".livesync";
|
||||||
@@ -47,21 +45,18 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class LiveSyncWebApp {
|
class LiveSyncWebApp {
|
||||||
private rootHandle: FileSystemDirectoryHandle | null = null;
|
private rootHandle: FileSystemDirectoryHandle;
|
||||||
private core: LiveSyncBaseCore<ServiceContext, any> | null = null;
|
private core: LiveSyncBaseCore<ServiceContext, any> | null = null;
|
||||||
private serviceHub: BrowserServiceHub<ServiceContext> | null = null;
|
private serviceHub: BrowserServiceHub<ServiceContext> | null = null;
|
||||||
|
|
||||||
|
constructor(rootHandle: FileSystemDirectoryHandle) {
|
||||||
|
this.rootHandle = rootHandle;
|
||||||
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
console.log("Self-hosted LiveSync WebApp");
|
console.log("Self-hosted LiveSync WebApp");
|
||||||
console.log("Initializing...");
|
console.log("Initializing...");
|
||||||
|
|
||||||
// Request directory access
|
|
||||||
await this.requestDirectoryAccess();
|
|
||||||
|
|
||||||
if (!this.rootHandle) {
|
|
||||||
throw new Error("Failed to get directory access");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Vault directory: ${this.rootHandle.name}`);
|
console.log(`Vault directory: ${this.rootHandle.name}`);
|
||||||
|
|
||||||
// Create service context and hub
|
// Create service context and hub
|
||||||
@@ -102,14 +97,12 @@ class LiveSyncWebApp {
|
|||||||
this.core = new LiveSyncBaseCore(
|
this.core = new LiveSyncBaseCore(
|
||||||
this.serviceHub,
|
this.serviceHub,
|
||||||
(core, serviceHub) => {
|
(core, serviceHub) => {
|
||||||
return initialiseServiceModulesFSAPI(this.rootHandle!, core, serviceHub);
|
return initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub);
|
||||||
},
|
},
|
||||||
(core) => [
|
(core) => [
|
||||||
// new ModuleObsidianEvents(this, core),
|
// new ModuleObsidianEvents(this, core),
|
||||||
// new ModuleObsidianSettingDialogue(this, core),
|
// new ModuleObsidianSettingDialogue(this, core),
|
||||||
// new ModuleObsidianMenu(core),
|
// new ModuleObsidianMenu(core),
|
||||||
new ModuleSetupObsidian(core),
|
|
||||||
new SetupManager(core),
|
|
||||||
// new ModuleObsidianSettingsAsMarkdown(core),
|
// new ModuleObsidianSettingsAsMarkdown(core),
|
||||||
// new ModuleLog(this, core),
|
// new ModuleLog(this, core),
|
||||||
// new ModuleObsidianDocumentHistory(this, core),
|
// new ModuleObsidianDocumentHistory(this, core),
|
||||||
@@ -133,8 +126,6 @@ class LiveSyncWebApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise<void> {
|
private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||||
if (!this.rootHandle) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create .livesync directory if it doesn't exist
|
// Create .livesync directory if it doesn't exist
|
||||||
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true });
|
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true });
|
||||||
@@ -151,8 +142,6 @@ class LiveSyncWebApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadSettingsFromFile(): Promise<Partial<ObsidianLiveSyncSettings> | null> {
|
private async loadSettingsFromFile(): Promise<Partial<ObsidianLiveSyncSettings> | null> {
|
||||||
if (!this.rootHandle) return null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR);
|
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR);
|
||||||
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE);
|
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE);
|
||||||
@@ -165,90 +154,6 @@ class LiveSyncWebApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestDirectoryAccess() {
|
|
||||||
try {
|
|
||||||
// Check if we have a cached directory handle
|
|
||||||
const cached = await this.loadCachedDirectoryHandle();
|
|
||||||
if (cached) {
|
|
||||||
// Verify permission (cast to any for compatibility)
|
|
||||||
try {
|
|
||||||
const permission = await (cached as any).queryPermission({ mode: "readwrite" });
|
|
||||||
if (permission === "granted") {
|
|
||||||
this.rootHandle = cached;
|
|
||||||
console.log("[Directory] Using cached directory handle");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// queryPermission might not be supported, try to use anyway
|
|
||||||
console.log("[Directory] Could not verify permission, requesting new access");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request new directory access
|
|
||||||
console.log("[Directory] Requesting directory access...");
|
|
||||||
this.rootHandle = await (window as any).showDirectoryPicker({
|
|
||||||
mode: "readwrite",
|
|
||||||
startIn: "documents",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save the handle for next time
|
|
||||||
await this.saveCachedDirectoryHandle(this.rootHandle);
|
|
||||||
console.log("[Directory] Directory access granted");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Directory] Failed to get directory access:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveCachedDirectoryHandle(handle: FileSystemDirectoryHandle) {
|
|
||||||
try {
|
|
||||||
// Use IndexedDB to store the directory handle
|
|
||||||
const db = await this.openHandleDB();
|
|
||||||
const transaction = db.transaction(["handles"], "readwrite");
|
|
||||||
const store = transaction.objectStore("handles");
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const request = store.put(handle, "rootHandle");
|
|
||||||
request.onsuccess = resolve;
|
|
||||||
request.onerror = reject;
|
|
||||||
});
|
|
||||||
db.close();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Directory] Failed to cache handle:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadCachedDirectoryHandle(): Promise<FileSystemDirectoryHandle | null> {
|
|
||||||
try {
|
|
||||||
const db = await this.openHandleDB();
|
|
||||||
const transaction = db.transaction(["handles"], "readonly");
|
|
||||||
const store = transaction.objectStore("handles");
|
|
||||||
const handle = await new Promise<FileSystemDirectoryHandle | null>((resolve, reject) => {
|
|
||||||
const request = store.get("rootHandle");
|
|
||||||
request.onsuccess = () => resolve(request.result || null);
|
|
||||||
request.onerror = reject;
|
|
||||||
});
|
|
||||||
db.close();
|
|
||||||
return handle;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Directory] Failed to load cached handle:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async openHandleDB(): Promise<IDBDatabase> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const request = indexedDB.open("livesync-webapp-handles", 1);
|
|
||||||
request.onerror = () => reject(request.error);
|
|
||||||
request.onsuccess = () => resolve(request.result);
|
|
||||||
request.onupgradeneeded = (event) => {
|
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
|
||||||
if (!db.objectStoreNames.contains("handles")) {
|
|
||||||
db.createObjectStore("handles");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async start() {
|
private async start() {
|
||||||
if (!this.core) {
|
if (!this.core) {
|
||||||
throw new Error("Core not initialized");
|
throw new Error("Core not initialized");
|
||||||
@@ -333,21 +238,4 @@ class LiveSyncWebApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize on load
|
export { LiveSyncWebApp };
|
||||||
const app = new LiveSyncWebApp();
|
|
||||||
|
|
||||||
window.addEventListener("load", async () => {
|
|
||||||
try {
|
|
||||||
await app.initialize();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to initialize:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle page unload
|
|
||||||
window.addEventListener("beforeunload", () => {
|
|
||||||
void app.shutdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export for debugging
|
|
||||||
(window as any).livesyncApp = app;
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
const HANDLE_DB_NAME = "livesync-webapp-handles";
|
||||||
|
const HANDLE_STORE_NAME = "handles";
|
||||||
|
const LAST_USED_KEY = "meta:lastUsedVaultId";
|
||||||
|
const VAULT_KEY_PREFIX = "vault:";
|
||||||
|
const MAX_HISTORY_COUNT = 10;
|
||||||
|
|
||||||
|
export type VaultHistoryItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
handle: FileSystemDirectoryHandle;
|
||||||
|
lastUsedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VaultHistoryValue = VaultHistoryItem;
|
||||||
|
|
||||||
|
function makeVaultKey(id: string): string {
|
||||||
|
return `${VAULT_KEY_PREFIX}${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVaultId(key: string): string | null {
|
||||||
|
if (!key.startsWith(VAULT_KEY_PREFIX)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return key.slice(VAULT_KEY_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomId(): string {
|
||||||
|
const n = Math.random().toString(36).slice(2, 10);
|
||||||
|
return `${Date.now()}-${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasReadWritePermission(handle: FileSystemDirectoryHandle, requestIfNeeded: boolean): Promise<boolean> {
|
||||||
|
const h = handle as any;
|
||||||
|
if (typeof h.queryPermission === "function") {
|
||||||
|
const queried = await h.queryPermission({ mode: "readwrite" });
|
||||||
|
if (queried === "granted") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!requestIfNeeded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof h.requestPermission === "function") {
|
||||||
|
const requested = await h.requestPermission({ mode: "readwrite" });
|
||||||
|
return requested === "granted";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VaultHistoryStore {
|
||||||
|
private async openHandleDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(HANDLE_DB_NAME, 1);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
if (!db.objectStoreNames.contains(HANDLE_STORE_NAME)) {
|
||||||
|
db.createObjectStore(HANDLE_STORE_NAME);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withStore<T>(
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
task: (store: IDBObjectStore) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const db = await this.openHandleDB();
|
||||||
|
try {
|
||||||
|
const tx = db.transaction([HANDLE_STORE_NAME], mode);
|
||||||
|
const store = tx.objectStore(HANDLE_STORE_NAME);
|
||||||
|
return await task(store);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestAsPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLastUsedVaultId(): Promise<string | null> {
|
||||||
|
return this.withStore("readonly", async (store) => {
|
||||||
|
const value = await this.requestAsPromise(store.get(LAST_USED_KEY));
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVaultHistory(): Promise<VaultHistoryItem[]> {
|
||||||
|
return this.withStore("readonly", async (store) => {
|
||||||
|
const keys = (await this.requestAsPromise(store.getAllKeys())) as IDBValidKey[];
|
||||||
|
const values = (await this.requestAsPromise(store.getAll())) as unknown[];
|
||||||
|
const items: VaultHistoryItem[] = [];
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const key = String(keys[i]);
|
||||||
|
const id = parseVaultId(key);
|
||||||
|
const value = values[i] as Partial<VaultHistoryValue> | undefined;
|
||||||
|
if (!id || !value || !value.handle || !value.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
name: String(value.name),
|
||||||
|
handle: value.handle,
|
||||||
|
lastUsedAt: Number(value.lastUsedAt || 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSelectedVault(handle: FileSystemDirectoryHandle): Promise<VaultHistoryItem> {
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = await this.getVaultHistory();
|
||||||
|
|
||||||
|
let matched: VaultHistoryItem | null = null;
|
||||||
|
for (const item of existing) {
|
||||||
|
try {
|
||||||
|
if (await item.handle.isSameEntry(handle)) {
|
||||||
|
matched = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore handles that cannot be compared, keep scanning.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const item: VaultHistoryItem = {
|
||||||
|
id: matched?.id ?? randomId(),
|
||||||
|
name: handle.name,
|
||||||
|
handle,
|
||||||
|
lastUsedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.withStore("readwrite", async (store): Promise<void> => {
|
||||||
|
await this.requestAsPromise(store.put(item, makeVaultKey(item.id)));
|
||||||
|
await this.requestAsPromise(store.put(item.id, LAST_USED_KEY));
|
||||||
|
|
||||||
|
const merged = [...existing.filter((v) => v.id !== item.id), item].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
||||||
|
const stale = merged.slice(MAX_HISTORY_COUNT);
|
||||||
|
for (const old of stale) {
|
||||||
|
await this.requestAsPromise(store.delete(makeVaultKey(old.id)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateHistoryItem(item: VaultHistoryItem): Promise<FileSystemDirectoryHandle> {
|
||||||
|
const granted = await hasReadWritePermission(item.handle, true);
|
||||||
|
if (!granted) {
|
||||||
|
throw new Error("Vault permissions were not granted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const activated: VaultHistoryItem = {
|
||||||
|
...item,
|
||||||
|
lastUsedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.withStore("readwrite", async (store): Promise<void> => {
|
||||||
|
await this.requestAsPromise(store.put(activated, makeVaultKey(activated.id)));
|
||||||
|
await this.requestAsPromise(store.put(activated.id, LAST_USED_KEY));
|
||||||
|
});
|
||||||
|
|
||||||
|
return item.handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async pickNewVault(): Promise<FileSystemDirectoryHandle> {
|
||||||
|
const picker = (window as any).showDirectoryPicker;
|
||||||
|
if (typeof picker !== "function") {
|
||||||
|
throw new Error("FileSystem API showDirectoryPicker is not supported in this browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = (await picker({
|
||||||
|
mode: "readwrite",
|
||||||
|
startIn: "documents",
|
||||||
|
})) as FileSystemDirectoryHandle;
|
||||||
|
|
||||||
|
const granted = await hasReadWritePermission(handle, true);
|
||||||
|
if (!granted) {
|
||||||
|
throw new Error("Vault permissions were not granted");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveSelectedVault(handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,11 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: path.resolve(__dirname, "index.html"),
|
index: path.resolve(__dirname, "index.html"),
|
||||||
|
webapp: path.resolve(__dirname, "webapp.html"),
|
||||||
},
|
},
|
||||||
|
external:[
|
||||||
|
"crypto"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Self-hosted LiveSync WebApp</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 700px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.error {
|
||||||
|
background: #fee;
|
||||||
|
color: #c33;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.warning {
|
||||||
|
background: #ffeaa7;
|
||||||
|
color: #d63031;
|
||||||
|
border: 1px solid #fdcb6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.info {
|
||||||
|
background: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-selector {
|
||||||
|
border: 1px solid #e6e9f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fbfcff;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-selector h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-selector p {
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-item {
|
||||||
|
border: 1px solid #d9deee;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-item-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2a44;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-item-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #63708f;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #2f5ae5;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #1e4ad6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section li {
|
||||||
|
padding: 7px 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section li::before {
|
||||||
|
content: "•";
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
margin-left: -1em;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 28px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Self-hosted LiveSync</h1>
|
||||||
|
<p class="subtitle">Browser-based Self-hosted LiveSync using FileSystem API</p>
|
||||||
|
|
||||||
|
<div id="status" class="info">Initialising...</div>
|
||||||
|
|
||||||
|
<div id="vault-selector" class="vault-selector">
|
||||||
|
<h2>Select Vault Folder</h2>
|
||||||
|
<p>Open a vault you already used, or pick a new folder.</p>
|
||||||
|
|
||||||
|
<div id="vault-history-list" class="vault-list"></div>
|
||||||
|
<p id="vault-history-empty" class="empty-note">No saved vaults yet.</p>
|
||||||
|
<button id="pick-new-vault" type="button">Choose new vault folder</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-section">
|
||||||
|
<h2>How to Use</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Select a vault folder and grant permission</li>
|
||||||
|
<li>Create <code>.livesync/settings.json</code> in your vault folder</li>
|
||||||
|
<li>Add your CouchDB connection details</li>
|
||||||
|
<li>Your files will be synced automatically</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>
|
||||||
|
Powered by
|
||||||
|
<a href="https://github.com/vrtmrz/obsidian-livesync" target="_blank">Self-hosted LiveSync</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./bootstrap.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user