Files
myworkspace/LinkSyncExtension/popup.js
DavidSaylor 09d30427f4 Complete LinkSyncServer and LinkSyncExtension implementation
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
2026-05-19 13:21:26 -05:00

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());