A Chrome extension to quickly capture URLs into Semble Collections at https://semble.so semble.so
at-proto semble chrome-extension

Compare changes

Choose any two refs to compare.

+176 -21
+5 -3
README.md
··· 2 2 3 3 A Chrome extension for quickly capturing and organizing URLs into Semble Collections using your Bluesky account. 4 4 5 - > [!WARNING] 6 - > This extension currently only supports accounts hosted by **Bluesky** 7 - 8 5 ## About 9 6 10 7 Save interesting web pages directly into your Semble Collections. Simply click the extension icon, add an optional note, select a collection, and save—all without leaving your current page. ··· 13 10 14 11 - **One-Click Capture**: Save the current tab's URL with a single click 15 12 - **Bluesky Integration**: Secure authentication using Bluesky App Passwords 13 + - **Custom PDS Support**: Works with both Bluesky and custom PDS servers 16 14 - **Collection Management**: Choose from your existing Semble Collections 17 15 - **Add Notes**: Include optional notes with your captured URLs 18 16 - **Clean Interface**: Simple, intuitive design that stays out of your way ··· 45 43 2. Sign in with your Bluesky handle and App Password 46 44 - Don't have an App Password? Generate one at [Bluesky Settings](https://bsky.app/settings/app-passwords) 47 45 - Don't have a Bluesky account? [Sign up here](https://bsky.app) 46 + 3. (Optional) If you use a custom PDS server, enter your PDS server URL 47 + - For Bluesky accounts, leave this blank (defaults to `bsky.social`) 48 + - For custom PDS: enter the full URL (e.g., `https://my-pds.example.com`) 49 + - The extension supports both HTTP (localhost only) and HTTPS servers 48 50 49 51 ### Capturing URLs 50 52
+14 -3
background/background.js
··· 60 60 61 61 /** 62 62 * Authenticate with Semble using Bluesky credentials 63 + * @param {string} identifier - User handle 64 + * @param {string} password - App password 65 + * @param {string} [service] - Optional PDS service URL 63 66 */ 64 - async function authenticate(identifier, password) { 67 + async function authenticate(identifier, password, service) { 65 68 try { 66 - const sessionData = await createSession(identifier, password); 69 + console.log('Authenticating with:', { 70 + identifier, 71 + hasPassword: !!password, 72 + service: service || 'default (bsky.social)' 73 + }); 74 + 75 + const sessionData = await createSession(identifier, password, service); 67 76 await saveSession(sessionData); 77 + 78 + console.log('Authentication successful'); 68 79 return { success: true, session: sessionData }; 69 80 } catch (error) { 70 81 console.error('Authentication failed:', error); ··· 148 159 149 160 switch (request.action) { 150 161 case 'authenticate': 151 - authenticate(request.identifier, request.password) 162 + authenticate(request.identifier, request.password, request.service) 152 163 .then(sendResponse); 153 164 return true; // Keep channel open for async response 154 165
+29 -6
lib/atproto.js
··· 10 10 * Login with Bluesky app password to get Semble tokens 11 11 * @param {string} identifier - User handle (e.g., user.bsky.social) 12 12 * @param {string} password - Bluesky app password 13 + * @param {string} [service] - Optional PDS service URL (e.g., https://bsky.social) 13 14 * @returns {Promise<{accessToken: string, refreshToken: string}>} 14 15 */ 15 - async function createSession(identifier, password) { 16 + async function createSession(identifier, password, service) { 17 + const payload = { 18 + identifier, 19 + appPassword: password, 20 + }; 21 + 22 + // Include service parameter if provided (for custom PDS servers) 23 + if (service) { 24 + payload.service = service; 25 + } 26 + 16 27 const response = await fetch(`${SEMBLE_API_URL}/api/users/login/app-password`, { 17 28 method: 'POST', 18 29 headers: { 19 30 'Content-Type': 'application/json', 20 31 }, 21 - body: JSON.stringify({ 22 - identifier, 23 - appPassword: password, 24 - }), 32 + body: JSON.stringify(payload), 25 33 }); 26 34 27 35 if (!response.ok) { 28 36 const error = await response.json().catch(() => ({})); 29 - throw new Error(`Authentication failed: ${error.message || response.statusText}`); 37 + 38 + // Enhanced error handling 39 + let errorMessage = error.message || response.statusText; 40 + 41 + // Provide more specific error messages 42 + if (response.status === 401) { 43 + errorMessage = 'Invalid identifier or password'; 44 + } else if (response.status === 400) { 45 + errorMessage = error.message || 'Invalid request. Please check your inputs.'; 46 + } else if (response.status === 500) { 47 + errorMessage = 'Server error. Please try again later.'; 48 + } else if (response.status === 503) { 49 + errorMessage = 'Service temporarily unavailable. Please try again later.'; 50 + } 51 + 52 + throw new Error(`Authentication failed: ${errorMessage}`); 30 53 } 31 54 32 55 return await response.json();
+11 -1
popup/popup.html
··· 38 38 </p> 39 39 </div> 40 40 41 + <div class="form-group"> 42 + <label class="form-label">PDS Server (optional)</label> 43 + <input type="text" id="loginService" placeholder="https://bsky.social" class="input-filled" 44 + autocomplete="off"> 45 + <p class="form-hint"> 46 + Leave blank for default (bsky.social). Only change if you use a custom PDS server. 47 + </p> 48 + </div> 49 + 41 50 <button id="loginButton" type="button" class="btn btn-primary"> 42 - Sign in 51 + <span id="loginButtonText">Sign in</span> 52 + <span id="loginButtonSpinner" class="btn-spinner hidden"></span> 43 53 </button> 44 54 45 55 <div id="loginError" class="alert alert-error hidden"></div>
+106 -8
popup/popup.js
··· 5 5 6 6 // DOM elements 7 7 let loginScreen, captureScreen, loadingScreen; 8 - let loginHandle, loginPassword, loginButton, loginError; 8 + let loginHandle, loginPassword, loginService, loginButton, loginButtonText, loginButtonSpinner, loginError; 9 9 let currentUrl, noteInput, collectionSelect, submitButton, statusMessage, logoutButton; 10 10 11 11 // State ··· 23 23 loadingScreen = document.getElementById('loadingScreen'); 24 24 loginHandle = document.getElementById('loginHandle'); 25 25 loginPassword = document.getElementById('loginPassword'); 26 + loginService = document.getElementById('loginService'); 26 27 loginButton = document.getElementById('loginButton'); 28 + loginButtonText = document.getElementById('loginButtonText'); 29 + loginButtonSpinner = document.getElementById('loginButtonSpinner'); 27 30 loginError = document.getElementById('loginError'); 28 31 currentUrl = document.getElementById('currentUrl'); 29 32 noteInput = document.getElementById('noteInput'); ··· 177 180 submitButton.disabled = !collectionSelect.value; 178 181 } 179 182 183 + /** 184 + * Validate URL format 185 + */ 186 + function isValidUrl(string) { 187 + try { 188 + const url = new URL(string); 189 + return url.protocol === 'http:' || url.protocol === 'https:'; 190 + } catch (_) { 191 + return false; 192 + } 193 + } 194 + 195 + /** 196 + * Validate and normalize PDS service URL 197 + */ 198 + function validateAndNormalizePDS(service) { 199 + if (!service) { 200 + return null; // Use default 201 + } 202 + 203 + const trimmed = service.trim(); 204 + 205 + // If it's just a domain without protocol, add https:// 206 + let normalized = trimmed; 207 + if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) { 208 + normalized = 'https://' + trimmed; 209 + } 210 + 211 + // Validate URL format 212 + if (!isValidUrl(normalized)) { 213 + throw new Error('Invalid PDS server URL format'); 214 + } 215 + 216 + const url = new URL(normalized); 217 + 218 + // Must be https (or http for localhost/127.0.0.1) 219 + if (url.protocol === 'http:') { 220 + const hostname = url.hostname.toLowerCase(); 221 + if (hostname !== 'localhost' && hostname !== '127.0.0.1' && !hostname.startsWith('192.168.')) { 222 + throw new Error('PDS server must use HTTPS (except for localhost)'); 223 + } 224 + } 225 + 226 + return normalized; 227 + } 228 + 180 229 /** 181 230 * Handle login 182 231 */ 183 232 async function handleLogin() { 184 233 const handle = loginHandle.value.trim(); 185 234 const password = loginPassword.value.trim(); 235 + const service = loginService.value.trim(); 186 236 237 + // Validation 187 238 if (!handle || !password) { 188 239 showLoginError('Please enter both handle and password'); 189 240 return; 190 241 } 191 242 243 + // Validate handle format 244 + if (!handle.includes('.')) { 245 + showLoginError('Invalid handle format. Use format: user.domain.com'); 246 + return; 247 + } 248 + 249 + // Validate and normalize PDS service URL 250 + let normalizedService = null; 251 + try { 252 + normalizedService = validateAndNormalizePDS(service); 253 + } catch (error) { 254 + showLoginError(error.message); 255 + return; 256 + } 257 + 258 + // Show progress indicator 192 259 loginButton.disabled = true; 193 - loginButton.textContent = 'Signing in...'; 260 + loginButtonText.textContent = 'Signing in...'; 261 + loginButtonSpinner.classList.remove('hidden'); 194 262 hideLoginError(); 195 263 196 264 try { ··· 198 266 action: 'authenticate', 199 267 identifier: handle, 200 268 password: password, 269 + service: normalizedService, 201 270 }); 202 271 203 272 if (result.success) { 204 273 // Authentication successful 205 274 await loadCaptureScreen(); 206 275 } else { 207 - showLoginError(result.error || 'Authentication failed'); 208 - loginButton.disabled = false; 209 - loginButton.textContent = 'Sign In'; 276 + // Provide helpful error messages 277 + let errorMessage = result.error || 'Authentication failed'; 278 + 279 + // Enhanced error messaging 280 + if (errorMessage.includes('Invalid identifier or password')) { 281 + errorMessage = 'Invalid handle or app password. Please check your credentials.'; 282 + } else if (errorMessage.includes('Network')) { 283 + errorMessage = 'Network error. Please check your connection and try again.'; 284 + } else if (errorMessage.includes('timeout')) { 285 + errorMessage = 'Request timeout. The PDS server may be slow or unreachable.'; 286 + } else if (normalizedService && errorMessage.includes('fetch')) { 287 + errorMessage = 'Cannot reach PDS server. Please verify the server URL.'; 288 + } 289 + 290 + showLoginError(errorMessage); 291 + resetLoginButton(); 210 292 } 211 293 } catch (error) { 212 294 console.error('Login error:', error); 213 - showLoginError('An error occurred. Please try again.'); 214 - loginButton.disabled = false; 215 - loginButton.textContent = 'Sign In'; 295 + let errorMessage = 'An unexpected error occurred. Please try again.'; 296 + 297 + if (error.message && error.message.includes('Network')) { 298 + errorMessage = 'Network error. Please check your internet connection.'; 299 + } 300 + 301 + showLoginError(errorMessage); 302 + resetLoginButton(); 216 303 } 217 304 } 218 305 306 + /** 307 + * Reset login button to initial state 308 + */ 309 + function resetLoginButton() { 310 + loginButton.disabled = false; 311 + loginButtonText.textContent = 'Sign in'; 312 + loginButtonSpinner.classList.add('hidden'); 313 + } 314 + 219 315 /** 220 316 * Handle logout 221 317 */ ··· 225 321 showScreen('login'); 226 322 loginHandle.value = ''; 227 323 loginPassword.value = ''; 324 + loginService.value = ''; 325 + resetLoginButton(); 228 326 } catch (error) { 229 327 console.error('Logout error:', error); 230 328 }
+11
popup/styles.css
··· 386 386 } 387 387 } 388 388 389 + /* ========== Button Spinner ========== */ 390 + .btn-spinner { 391 + display: inline-block; 392 + width: 16px; 393 + height: 16px; 394 + border: 2px solid rgba(255, 255, 255, 0.3); 395 + border-top-color: white; 396 + border-radius: 50%; 397 + animation: spin 0.6s linear infinite; 398 + } 399 + 389 400 /* ========== Utility Classes ========== */ 390 401 .hidden { 391 402 display: none !important;