LinkSyncServer: - Fix app.py imports, add CORS middleware, lifespan events - Create api/routes.py router aggregator - Create config/settings.py for centralized configuration - Rewrite models/base.py with proper relationships and serialization - Rewrite all API endpoints with real DB integration (auth, links, collections, sync, queries, tags) - Add admin endpoints (user management, stats, audit log) - Complete query parser with recursive descent and proper precedence - Complete query executor with set operations and field filters - Set up Alembic migrations with initial schema - Create web interface (templates, CSS, JS) - Add 42 passing tests (auth, links, collections, queries) - Add deploy.ps1 and deploy.sh scripts - Update README with deployment workflow LinkSyncExtension: - Create utils/api.js (REST client with retries, auth, error handling) - Create utils/sync.js (3 sync modes + conflict detection) - Create utils/collection.js (collection management) - Create utils/query-engine.js (client-side query parser) - Rewrite background.js (sync loop, bookmark events, message routing) - Rewrite popup.js (tabs, settings modal, notifications, CRUD) - Update popup.html (tabbed interface, query builder, modal) - Update popup.css (full redesign) - Create content/content.js (page metadata extraction) - Create options.html/js (dedicated settings page) - Generate icons (48x48, 96x96) - Update manifest.json (host permissions, content scripts, options) - Create AGENTS.md
366 lines
14 KiB
JavaScript
366 lines
14 KiB
JavaScript
// LinkSync Popup Script
|
|
// Handles bookmark management, sync, collections, and queries
|
|
|
|
const Popup = {
|
|
bookmarks: [],
|
|
collections: [],
|
|
|
|
async init() {
|
|
this.setupTabs();
|
|
this.setupEventListeners();
|
|
await this.loadSettings();
|
|
await this.loadBookmarks();
|
|
await this.loadCollections();
|
|
this.updateSyncStatus();
|
|
},
|
|
|
|
setupTabs() {
|
|
document.querySelectorAll(".tab").forEach((tab) => {
|
|
tab.addEventListener("click", () => {
|
|
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
|
|
document.querySelectorAll(".tab-content").forEach((c) => c.classList.remove("active"));
|
|
tab.classList.add("active");
|
|
document.getElementById(`tab-${tab.dataset.tab}`).classList.add("active");
|
|
});
|
|
});
|
|
},
|
|
|
|
setupEventListeners() {
|
|
document.getElementById("add-bookmark-form").addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
this.addBookmark();
|
|
});
|
|
|
|
document.getElementById("search").addEventListener("input", (e) => {
|
|
this.filterBookmarks(e.target.value);
|
|
});
|
|
|
|
document.getElementById("sync-btn").addEventListener("click", () => this.syncNow());
|
|
document.getElementById("settings-btn").addEventListener("click", () => this.openSettings());
|
|
document.getElementById("close-settings").addEventListener("click", () => this.closeSettings());
|
|
document.getElementById("settings-form").addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
this.saveSettings();
|
|
});
|
|
document.getElementById("toggle-key").addEventListener("click", () => this.toggleApiKey());
|
|
document.getElementById("test-connection").addEventListener("click", () => this.testConnection());
|
|
|
|
document.getElementById("parse-btn").addEventListener("click", () => this.parseQuery());
|
|
document.getElementById("execute-btn").addEventListener("click", () => this.executeQuery());
|
|
|
|
document.getElementById("settings-modal").addEventListener("click", (e) => {
|
|
if (e.target.id === "settings-modal") this.closeSettings();
|
|
});
|
|
},
|
|
|
|
async loadSettings() {
|
|
const settings = await browser.storage.local.get([
|
|
"linksync_server_url",
|
|
"linksync_api_key",
|
|
"linksync_sync_mode",
|
|
"linksync_deletions",
|
|
"linksync_auto_sync",
|
|
]);
|
|
|
|
document.getElementById("server-url").value = settings.linksync_server_url || "http://localhost:5000";
|
|
document.getElementById("api-key").value = settings.linksync_api_key || "";
|
|
document.getElementById("sync-mode").value = settings.linksync_sync_mode || "bi-directional";
|
|
document.getElementById("deletions").checked = settings.linksync_deletions === true;
|
|
document.getElementById("auto-sync").checked = settings.linksync_auto_sync === true;
|
|
},
|
|
|
|
async openSettings() {
|
|
document.getElementById("settings-modal").classList.add("open");
|
|
},
|
|
|
|
closeSettings() {
|
|
document.getElementById("settings-modal").classList.remove("open");
|
|
},
|
|
|
|
toggleApiKey() {
|
|
const input = document.getElementById("api-key");
|
|
const btn = document.getElementById("toggle-key");
|
|
if (input.type === "password") {
|
|
input.type = "text";
|
|
btn.textContent = "Hide";
|
|
} else {
|
|
input.type = "password";
|
|
btn.textContent = "Show";
|
|
}
|
|
},
|
|
|
|
async saveSettings() {
|
|
const settings = {
|
|
linksync_server_url: document.getElementById("server-url").value.replace(/\/+$/, ""),
|
|
linksync_api_key: document.getElementById("api-key").value,
|
|
linksync_sync_mode: document.getElementById("sync-mode").value,
|
|
linksync_deletions: document.getElementById("deletions").checked,
|
|
linksync_auto_sync: document.getElementById("auto-sync").checked,
|
|
};
|
|
|
|
await browser.storage.local.set(settings);
|
|
await API.init();
|
|
this.closeSettings();
|
|
this.notify("Settings saved", "success");
|
|
await this.loadBookmarks();
|
|
},
|
|
|
|
async testConnection() {
|
|
try {
|
|
const result = await browser.runtime.sendMessage({ type: "TEST_CONNECTION" });
|
|
if (result.success) {
|
|
this.notify("Connection successful", "success");
|
|
} else {
|
|
this.notify(`Connection failed: ${result.error}`, "error");
|
|
}
|
|
} catch (e) {
|
|
this.notify(`Connection failed: ${e.message}`, "error");
|
|
}
|
|
},
|
|
|
|
async loadBookmarks() {
|
|
const container = document.getElementById("bookmarks-container");
|
|
container.innerHTML = '<p class="empty-state">Loading bookmarks...</p>';
|
|
|
|
try {
|
|
const response = await browser.runtime.sendMessage({ type: "GET_BOOKMARKS", data: { limit: 50 } });
|
|
this.bookmarks = response || [];
|
|
this.renderBookmarks(this.bookmarks);
|
|
} catch (e) {
|
|
container.innerHTML = `<p class="empty-state">Error: ${e.message}</p>`;
|
|
}
|
|
},
|
|
|
|
renderBookmarks(bookmarks) {
|
|
const container = document.getElementById("bookmarks-container");
|
|
|
|
if (!bookmarks || bookmarks.length === 0) {
|
|
container.innerHTML = '<p class="empty-state">No bookmarks found</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = bookmarks
|
|
.map(
|
|
(bm) => `
|
|
<div class="bookmark-item" data-id="${bm.id}">
|
|
<a href="${this.escapeHtml(bm.url)}" target="_blank">${this.escapeHtml(bm.url)}</a>
|
|
<div class="bm-title">${this.escapeHtml(bm.title)}</div>
|
|
${bm.description ? `<div class="bm-desc">${this.escapeHtml(bm.description)}</div>` : ""}
|
|
${bm.tags && bm.tags.length > 0
|
|
? `<div class="bm-tags">${bm.tags.map((t) => `<span class="bm-tag">${this.escapeHtml(t)}</span>`).join("")}</div>`
|
|
: ""}
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
},
|
|
|
|
filterBookmarks(query) {
|
|
const q = query.toLowerCase();
|
|
const filtered = this.bookmarks.filter(
|
|
(b) =>
|
|
(b.title && b.title.toLowerCase().includes(q)) ||
|
|
(b.url && b.url.toLowerCase().includes(q)) ||
|
|
(b.description && b.description.toLowerCase().includes(q)) ||
|
|
(b.tags && b.tags.some((t) => t.toLowerCase().includes(q)))
|
|
);
|
|
this.renderBookmarks(filtered);
|
|
},
|
|
|
|
async addBookmark() {
|
|
const data = {
|
|
url: document.getElementById("url").value,
|
|
title: document.getElementById("title").value,
|
|
description: document.getElementById("description").value,
|
|
notes: document.getElementById("notes").value,
|
|
tags: this.formatTags(document.getElementById("tags").value),
|
|
path: document.getElementById("folder").value,
|
|
};
|
|
|
|
try {
|
|
const result = await browser.runtime.sendMessage({ type: "CREATE_BOOKMARK", data });
|
|
document.getElementById("add-bookmark-form").reset();
|
|
this.notify("Bookmark added", "success");
|
|
await this.loadBookmarks();
|
|
} catch (e) {
|
|
this.notify(`Failed to add bookmark: ${e.message}`, "error");
|
|
}
|
|
},
|
|
|
|
formatTags(tagString) {
|
|
if (!tagString) return [];
|
|
return tagString
|
|
.split(",")
|
|
.map((t) => t.trim())
|
|
.filter((t) => t.length > 0);
|
|
},
|
|
|
|
async loadCollections() {
|
|
const container = document.getElementById("collections-container");
|
|
container.innerHTML = '<p class="empty-state">Loading collections...</p>';
|
|
|
|
try {
|
|
const response = await browser.runtime.sendMessage({ type: "GET_COLLECTIONS" });
|
|
this.collections = response || [];
|
|
this.renderCollections(this.collections);
|
|
} catch (e) {
|
|
container.innerHTML = `<p class="empty-state">Error: ${e.message}</p>`;
|
|
}
|
|
},
|
|
|
|
renderCollections(collections) {
|
|
const container = document.getElementById("collections-container");
|
|
|
|
if (!collections || collections.length === 0) {
|
|
container.innerHTML = '<p class="empty-state">No collections</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = collections
|
|
.map(
|
|
(c) => `
|
|
<div class="collection-item">
|
|
<h3>${this.escapeHtml(c.name)}</h3>
|
|
${c.description ? `<p>${this.escapeHtml(c.description)}</p>` : ""}
|
|
<span class="col-type">${c.query_type}</span>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
},
|
|
|
|
async syncNow() {
|
|
const indicator = document.getElementById("sync-indicator");
|
|
indicator.className = "syncing";
|
|
|
|
try {
|
|
const result = await browser.runtime.sendMessage({ type: "SYNC_NOW" });
|
|
if (result && result.success) {
|
|
indicator.className = "synced";
|
|
this.notify(`Sync completed: ${result.actions?.length || 0} actions`, "success");
|
|
} else {
|
|
indicator.className = "error";
|
|
this.notify(`Sync failed: ${result?.error || "Unknown error"}`, "error");
|
|
}
|
|
} catch (e) {
|
|
indicator.className = "error";
|
|
this.notify(`Sync error: ${e.message}`, "error");
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (indicator.className === "syncing") indicator.className = "";
|
|
}, 3000);
|
|
|
|
this.updateSyncStatus();
|
|
},
|
|
|
|
updateSyncStatus() {
|
|
browser.storage.local.get(["linksync_last_sync", "linksync_syncing"]).then((settings) => {
|
|
const indicator = document.getElementById("sync-indicator");
|
|
const lastSync = document.getElementById("last-sync");
|
|
|
|
if (settings.linksync_syncing) {
|
|
indicator.className = "syncing";
|
|
lastSync.textContent = "Syncing...";
|
|
return;
|
|
}
|
|
|
|
if (settings.linksync_last_sync) {
|
|
const date = new Date(settings.linksync_last_sync);
|
|
const mins = Math.floor((Date.now() - date.getTime()) / 60000);
|
|
if (mins < 1) {
|
|
indicator.className = "synced";
|
|
lastSync.textContent = "Just now";
|
|
} else if (mins < 60) {
|
|
indicator.className = "synced";
|
|
lastSync.textContent = `${mins}m ago`;
|
|
} else {
|
|
indicator.className = "";
|
|
lastSync.textContent = date.toLocaleDateString();
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
async parseQuery() {
|
|
const expression = document.getElementById("query-input").value.trim();
|
|
if (!expression) {
|
|
this.notify("Enter a query expression", "info");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await browser.runtime.sendMessage({ type: "PARSE_QUERY", data: { expression } });
|
|
const output = document.getElementById("query-result");
|
|
if (result.valid) {
|
|
output.innerHTML = `<pre style="white-space:pre-wrap;font-size:10px;">${JSON.stringify(result.parsed, null, 2)}</pre>`;
|
|
this.notify("Query parsed successfully", "success");
|
|
} else {
|
|
output.innerHTML = `<span style="color:var(--error)">Invalid: ${this.escapeHtml(result.error)}</span>`;
|
|
this.notify("Invalid query syntax", "error");
|
|
}
|
|
} catch (e) {
|
|
this.notify(`Parse error: ${e.message}`, "error");
|
|
}
|
|
},
|
|
|
|
async executeQuery() {
|
|
const expression = document.getElementById("query-input").value.trim();
|
|
if (!expression) {
|
|
this.notify("Enter a query expression", "info");
|
|
return;
|
|
}
|
|
|
|
const output = document.getElementById("query-result");
|
|
output.innerHTML = '<p class="empty-state">Executing...</p>';
|
|
|
|
try {
|
|
const results = await browser.runtime.sendMessage({
|
|
type: "EXECUTE_QUERY",
|
|
data: { expression, limit: 50 },
|
|
});
|
|
const items = results || [];
|
|
if (items.length === 0) {
|
|
output.innerHTML = '<p class="empty-state">No results</p>';
|
|
} else {
|
|
output.innerHTML = items
|
|
.map(
|
|
(bm) => `
|
|
<div class="bookmark-item" style="margin-bottom:4px;">
|
|
<a href="${this.escapeHtml(bm.url)}" target="_blank">${this.escapeHtml(bm.title || bm.url)}</a>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
this.notify(`Found ${items.length} results`, "success");
|
|
}
|
|
} catch (e) {
|
|
output.innerHTML = `<span style="color:var(--error)">Error: ${this.escapeHtml(e.message)}</span>`;
|
|
this.notify(`Query error: ${e.message}`, "error");
|
|
}
|
|
},
|
|
|
|
notify(message, type = "info") {
|
|
const container = document.getElementById("notification-container");
|
|
const el = document.createElement("div");
|
|
el.className = `notification ${type}`;
|
|
el.textContent = message;
|
|
container.appendChild(el);
|
|
|
|
setTimeout(() => {
|
|
el.style.opacity = "0";
|
|
el.style.transition = "opacity 0.3s";
|
|
setTimeout(() => el.remove(), 300);
|
|
}, 3000);
|
|
},
|
|
|
|
escapeHtml(str) {
|
|
if (!str) return "";
|
|
const div = document.createElement("div");
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
},
|
|
};
|
|
|
|
document.addEventListener("DOMContentLoaded", () => Popup.init());
|