A fork of Woomarks that saves to pds

Migrate app storage to Atproto PDS

https://ampcode.com/threads/T-28f4c047-5e09-46a0-8eb0-04d0e7d7dd7c

Amp af1ada9e 389cf042

Changed files
+535 -551
lexicons
com
woomarks
community
lexicon
bookmarks
+46 -58
README.md
··· 1 1 2 2 ![favicon](favicon.svg) 3 - # woomarks 4 - woomarks is an app that let's you save links in your browser storage, no account needed. 3 + # woomarks (AT Protocol Edition) 4 + woomarks is an app that lets you save bookmarks to your AT Protocol Personal Data Server (PDS). 5 5 6 6 ![screenshot](screenshot.png) 7 7 8 - It's only frontend code, no database, backend server needed. 8 + This version stores bookmarks directly on the AT Protocol network using a custom lexicon, making your bookmarks portable across the decentralized web. 9 9 10 10 11 11 12 - It also can import/export to csv files or local storage. 13 - 14 - 15 - ## Demos 16 - #### [Creator's personal boomarks page](https://roberto.fyi/bookmarks/). 17 - - Saving is closed to the public. 18 - - Saved links are visible to the public, as they are loaded from a csv file in the server. 19 - 20 - #### [woomarks public app](https://woomarks.com). 21 - - Saving is open to the public. 22 - - The saved links are private and saved in the browser local storage of the users. 23 - 24 12 ## Features 25 - - Add/Delete links 26 - - Search 27 - - Tags 28 - - Bookmarklet (useful for a 2-click-save) 29 - - Data reads from: 30 - - csv file in server (these links are public) 31 - - local storage in browser (these links are visible just for the user) 32 - - Local storage saving. 33 - - Import to local storage from csv file 34 - - Export to csv from local storage. 35 - - Export to csv from csv file (useful when links are "deleted" using the app and just hidden using a local storage blacklist). 36 - - Export to csv from both places. 37 - - No external libraries. 38 - - Vanilla css code. 39 - - Vanilla js code. 13 + - Add/Delete bookmarks stored on AT Protocol 14 + - Search and filter by tags 15 + - Decentralized storage on your Personal Data Server 16 + - Bookmarklet support for easy saving 17 + - Responsive design with dynamic colors and fonts 18 + - No backend server needed - connects directly to AT Protocol 19 + - Open lexicon format for interoperability 40 20 41 - ## Install 21 + ## Prerequisites 22 + - An AT Protocol account (e.g., Bluesky account) 23 + - A Personal Data Server (PDS) that supports custom lexicons 42 24 43 - #### BASIC INSTALL (3min) 44 - - Copy the contents of this repository to an online directory. That's all. You can start saving links. 25 + ## AT Protocol Integration 26 + This app uses the standard `community.lexicon.bookmarks.bookmark` lexicon from [lexicon.community](https://github.com/lexicon-community/lexicon) to store bookmark records on your PDS. Each bookmark contains: 27 + - URI (required) - The bookmarked URL 28 + - Title (optional) - The title of the bookmarked page 29 + - Tags (optional array) - Organizational tags 30 + - Creation timestamp 45 31 46 - #### SHOWCASE YOUR LINKS 47 - - If you want to showcase your saved links, update the **mybookmarks.csv** file 32 + Using the community standard lexicon means your bookmarks are: 33 + - Interoperable with other AT Protocol bookmark apps 34 + - Portable across different implementations 35 + - Following established community standards 36 + - Owned and controlled by you on your PDS 48 37 49 - #### IMPORT FROM POCKET 50 - - Go to this Pocket page to export your links. https://getpocket.com/export.php? 38 + ## Installation 51 39 52 - **Option 1** (If you want your links public) 53 - - Replace the mybookmarks.csv witha the content of your Pocket csv file. 40 + 1. **Deploy the App** 41 + - Copy the contents of this repository to a web server 42 + - Or use a static hosting service like GitHub Pages, Netlify, or Vercel 54 43 55 - **Option 2** (If you want your links saved on your browser's local storage) 56 - - Add > Bulk Transfer > Paste the contents. 44 + 2. **Login with AT Protocol** 45 + - Open the app in your browser 46 + - Enter your AT Protocol handle (e.g., `username.bsky.social`) 47 + - Enter your app password (generate one in your AT Protocol client) 48 + - Click Login 57 49 58 - #### CREATE BOOKMARKLET 59 - To be able to easily save bookmarks with the form prefilled, create a bookmarklet: 60 - - In your browser, create a new bookmark with "add woomark" () as Name 61 - - Paste the next code as URL. 62 - ``` 50 + 3. **Start Bookmarking** 51 + - Use the Add button to save new bookmarks 52 + - Tag and organize your bookmarks 53 + - Search and filter your collection 54 + 55 + ## Bookmarklet Setup 56 + Create a bookmarklet for easy saving: 57 + - Create a new bookmark in your browser 58 + - Set the name to "Save to woomarks" 59 + - Use this as the URL: 60 + ```javascript 63 61 javascript:(function(){ 64 62 const url = encodeURIComponent(window.location.href); 65 63 const title = encodeURIComponent(document.title); 66 - window.open(`https://YOURDOMAINGOESHERE.com/?title=${title}&url=${url}`, '_blank'); 64 + window.open(`https://YOURDOMAINHERE.com/?title=${title}&url=${url}`, '_blank'); 67 65 })(); 68 66 ``` 69 67 70 - #### HIDE SAVE BUTTON 71 - - If you are using this for your personal use (you don't want anyone else saving on your page), you can uncomment this line in **script.js** file 72 - and add the code you want here AND as a variable in your browser's local storage. 73 - 74 - ` 75 - // const appcode = "notsosecretcode"; 76 - ` 77 - 78 - ![screenshot](screenshot_appcode.png) 79 - 80 68 81 69 ## Design 82 70 This design is inspired by Pocket's UI, which was very good for showing a list of articles to read later. Native bookmarking feels more utilitarian, suited for recurrent links, woomarks is more suited for read later links. 83 71 84 72 ## Philosophy 85 - I had all my bookmarks in Pocket and it's shutting down. Same thing happened to del.icio.us. So I decided to keep the web cool and decentralized and make this little thing. The code is open and you can use it on your own website forever. 73 + I had all my bookmarks in Pocket and it's shutting down. Same thing happened to del.icio.us. So I decided to keep the web cool and decentralized by building on AT Protocol. Now your bookmarks live on a decentralized network that you control, not in some company's database that might disappear. 86 74 87 75 ## License 88 76 Do whatever you want with this, personal or commercial. No warranties are given.
+33 -5
index.html
··· 14 14 </head> 15 15 16 16 <body> 17 + <!-- Login Modal --> 18 + <dialog id="loginDialog" class="param-dialog"> 19 + <form method="dialog" class="param-form"> 20 + <h2>Connect to AT Protocol</h2> 21 + <div class="param-group"> 22 + <label for="handleInput" class="param-label">Handle</label> 23 + <input 24 + type="text" 25 + id="handleInput" 26 + class="param-input" 27 + placeholder="user.bsky.social" 28 + /> 29 + </div> 30 + <div class="param-group"> 31 + <label for="passwordInput" class="param-label">Password</label> 32 + <input 33 + type="password" 34 + id="passwordInput" 35 + class="param-input" 36 + /> 37 + </div> 38 + <menu class="param-menu"> 39 + <button id="loginBtn" type="button" class="param-btn dark">Login</button> 40 + </menu> 41 + </form> 42 + </dialog> 43 + 17 44 <div class="topbar"> 18 45 <div style="flex-grow: 1"> 19 46 <b><a id="headerTitle" href="">woomarks</a></b> 20 47 <a href="./faq.html">FAQ</a> 48 + <span id="connectionStatus" class="connection-status"></span> 21 49 </div> 50 + <button id="logoutBtn" class="param-btn" style="display: none;">Logout</button> 22 51 <button id="openEmptyDialogBtn" data-umami-event="Open creation modal" class="param-btn"><span class="btn-text">Add</span> ➕</button> 23 52 24 53 <button id="sortToggleBtn" data-umami-event="Sort" class="param-btn"><span class="btn-text">Sort</span> ▲</button> ··· 72 101 <p id="paramDialogCount" class="param-count"></p> 73 102 74 103 <menu class="param-menu"> 75 - <div class="menu-left"> 76 - <a id="importBtn" href="./transfer_page.html" class="import-link" 77 - >Bulk Transfer</a 78 - > 79 - </div> 80 104 <div class="menu-right"> 81 105 <button 82 106 id="cancelBtn" ··· 100 124 </form> 101 125 </dialog> 102 126 </body> 127 + <script type="module"> 128 + import { AtpAgent } from 'https://unpkg.com/@atproto/api@0.12.21/dist/index.js'; 129 + window.AtpAgent = AtpAgent; 130 + </script> 103 131 <script async src="./script.js"></script> 104 132 </html>
+42
lexicons/com/woomarks/bookmark.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.woomarks.bookmark", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A bookmark record for woomarks", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["title", "url", "createdAt"], 12 + "properties": { 13 + "title": { 14 + "type": "string", 15 + "maxLength": 500, 16 + "description": "The title of the bookmarked page" 17 + }, 18 + "url": { 19 + "type": "string", 20 + "format": "uri", 21 + "maxLength": 2000, 22 + "description": "The URL being bookmarked" 23 + }, 24 + "tags": { 25 + "type": "array", 26 + "items": { 27 + "type": "string", 28 + "maxLength": 50 29 + }, 30 + "maxLength": 20, 31 + "description": "Tags associated with the bookmark" 32 + }, 33 + "createdAt": { 34 + "type": "string", 35 + "format": "datetime", 36 + "description": "When the bookmark was created" 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+47
lexicons/community/lexicon/bookmarks/bookmark.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "community.lexicon.bookmarks.bookmark", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A bookmark record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["uri", "createdAt"], 12 + "properties": { 13 + "uri": { 14 + "type": "string", 15 + "format": "uri", 16 + "maxLength": 2000, 17 + "description": "The URI being bookmarked" 18 + }, 19 + "title": { 20 + "type": "string", 21 + "maxLength": 500, 22 + "description": "The title of the bookmarked resource" 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxLength": 2000, 27 + "description": "A description of the bookmarked resource" 28 + }, 29 + "tags": { 30 + "type": "array", 31 + "items": { 32 + "type": "string", 33 + "maxLength": 50 34 + }, 35 + "maxLength": 20, 36 + "description": "Tags associated with the bookmark" 37 + }, 38 + "createdAt": { 39 + "type": "string", 40 + "format": "datetime", 41 + "description": "When the bookmark was created" 42 + } 43 + } 44 + } 45 + } 46 + } 47 + }
+3 -7
mybookmarks.csv
··· 1 - title,url,time_added,tags,status 2 - "My other project is publishing classic books. | BookWormHole", https://bookwormhole.co,1750558616,books,unread 3 - In your website you can make public bookmarks | Like this demo, https://roberto.fyi/bookmarks,1750688281,woomarks,unread 4 - "If you have a website, you can install woomarks there. | Github project",https://github.com,1750707578,woomarks,unread 5 - Transfer your Pocket links | Bulk Transfer,/transfer_page.html,1750685963,woomarks,unread 6 - "Install the bookmarklet so saving takes you 2 clicks. | Bookmarklet",/faq.html#faq_bookmarklet,1750685963,woomarks,unread 7 - "woomarks let you save links. | No account needed. | Click the “Add” button." ,/,1750859818,woomarks,unread 1 + # This file is no longer used in the AT Protocol edition 2 + # Bookmarks are now stored on your Personal Data Server 3 + # Please use the main app to log in with your AT Protocol credentials
+332 -434
script.js
··· 1 - // ====== Constants & Globals ====== 2 - const LOCAL_GLOW = true; // adds a glow to differentiate items stored locally in the browser from those stored in csv file 3 - const EXPORT = "all"; // choose export type "all", "csv", "local" 4 - // const appcode = "notsosecretcode"; 1 + // ====== AT Protocol & Constants ====== 2 + // AtpAgent is loaded via window.AtpAgent from the module import 5 3 4 + // Bookmark lexicon definition (using community standard) 5 + const BOOKMARK_LEXICON = "community.lexicon.bookmarks.bookmark"; 6 + 7 + const LOCAL_GLOW = false; // No local storage differentiation needed 6 8 const MAX_CHARS_PER_LINE = 15; 7 9 const MAX_LINES = 4; 8 10 const EST_CHAR_WIDTH = 0.6; // em ··· 26 28 ]; 27 29 28 30 // State variables 29 - let originalRows = []; 30 - let csvRows = []; 31 - let storedRows = []; 32 - let storedRowHashes = new Set(); 31 + let atpAgent = null; 32 + let userDid = null; 33 + let bookmarks = []; 33 34 let reversedOrder = false; 34 - let deleted = JSON.parse(localStorage.getItem("deleted_csv_rows") || "[]"); 35 - 36 35 37 36 // ====== DOM Elements ====== 37 + const loginDialog = document.getElementById("loginDialog"); 38 + const handleInput = document.getElementById("handleInput"); 39 + const passwordInput = document.getElementById("passwordInput"); 40 + const loginBtn = document.getElementById("loginBtn"); 41 + const logoutBtn = document.getElementById("logoutBtn"); 42 + const connectionStatus = document.getElementById("connectionStatus"); 38 43 39 44 const dialog = document.getElementById("paramDialog"); 40 45 const titleInput = document.getElementById("paramTitle"); ··· 43 48 const saveBtn = document.getElementById("saveBtn"); 44 49 const cancelBtn = document.getElementById("cancelBtn"); 45 50 const openEmptyDialogBtn = document.getElementById("openEmptyDialogBtn"); 46 - const appcodeInput = document.getElementById("appcode"); 47 - const modalOverlay = document.getElementById("modalOverlay"); 48 - const searchInput =document.getElementById("searchInput"); 51 + const searchInput = document.getElementById("searchInput"); 49 52 const sortToggleBtn = document.getElementById("sortToggleBtn"); 50 - const exportBtn = document.getElementById("exportBtn") 51 - const importArea = document.getElementById("importArea") 52 53 54 + // ====== AT Protocol Functions ====== 53 55 54 - // ====== Utility Functions ====== 56 + /** 57 + * Initialize AT Protocol agent with stored session 58 + */ 59 + async function initializeATProto() { 60 + const session = localStorage.getItem("atproto_session"); 61 + if (!session) { 62 + showLoginDialog(); 63 + return false; 64 + } 65 + 66 + try { 67 + atpAgent = new window.AtpAgent({ 68 + service: "https://bsky.social", 69 + }); 70 + 71 + await atpAgent.resumeSession(JSON.parse(session)); 72 + userDid = atpAgent.session.did; 73 + 74 + updateConnectionStatus("connected"); 75 + showMainUI(); 76 + await loadBookmarks(); 77 + return true; 78 + } catch (error) { 79 + console.error("Failed to resume session:", error); 80 + localStorage.removeItem("atproto_session"); 81 + showLoginDialog(); 82 + return false; 83 + } 84 + } 55 85 56 86 /** 57 - * Hashes a string to a non-negative 32-bit integer. 58 - * @param {string} str 59 - * @returns {number} 87 + * Login to AT Protocol 60 88 */ 61 - function hashString(str) { 62 - let hash = 0; 63 - for (let i = 0; i < str.length; i++) { 64 - hash = (hash << 5) - hash + str.charCodeAt(i); 65 - hash |= 0; // Convert to 32-bit int 89 + async function login() { 90 + const handle = handleInput.value.trim(); 91 + const password = passwordInput.value.trim(); 92 + 93 + if (!handle || !password) return; 94 + 95 + updateConnectionStatus("connecting"); 96 + 97 + try { 98 + atpAgent = new window.AtpAgent({ 99 + service: "https://bsky.social", 100 + }); 101 + 102 + await atpAgent.login({ 103 + identifier: handle, 104 + password: password, 105 + }); 106 + 107 + userDid = atpAgent.session.did; 108 + localStorage.setItem("atproto_session", JSON.stringify(atpAgent.session)); 109 + 110 + updateConnectionStatus("connected"); 111 + loginDialog.close(); 112 + showMainUI(); 113 + await loadBookmarks(); 114 + } catch (error) { 115 + console.error("Login failed:", error); 116 + updateConnectionStatus("disconnected"); 117 + alert("Login failed. Please check your credentials."); 66 118 } 67 - return Math.abs(hash); 68 119 } 69 120 70 121 /** 71 - * Get a color pair deterministically by title. 72 - * @param {string} title 73 - * @param {Array<Array<string>>} pairs 74 - * @returns {[string, string]} [backgroundColor, fontColor] 122 + * Logout from AT Protocol 75 123 */ 76 - function getColorPairByTitle(title, pairs) { 77 - const hash = hashString(title); 78 - const idx = hash % pairs.length; 79 - const [bg, fg] = pairs[idx]; 80 - return (hash % 2 === 0) ? [bg, fg] : [fg, bg]; 124 + async function logout() { 125 + if (atpAgent) { 126 + try { 127 + await atpAgent.com.atproto.session.delete(); 128 + } catch (error) { 129 + console.error("Logout error:", error); 130 + } 131 + } 132 + 133 + atpAgent = null; 134 + userDid = null; 135 + bookmarks = []; 136 + localStorage.removeItem("atproto_session"); 137 + updateConnectionStatus("disconnected"); 138 + showLoginDialog(); 81 139 } 82 140 83 141 /** 84 - * Get a font family deterministically by title. 85 - * @param {string} title 86 - * @param {string[]} fonts 87 - * @returns {string} 142 + * Load bookmarks from PDS 88 143 */ 89 - function getFontByTitle(title, fonts) { 90 - return fonts[hashString(title) % fonts.length]; 144 + async function loadBookmarks() { 145 + if (!atpAgent || !userDid) return; 146 + 147 + try { 148 + updateConnectionStatus("connecting"); 149 + 150 + const response = await atpAgent.com.atproto.repo.listRecords({ 151 + repo: userDid, 152 + collection: BOOKMARK_LEXICON, 153 + }); 154 + 155 + bookmarks = response.data.records.map(record => ({ 156 + uri: record.uri, 157 + cid: record.cid, 158 + ...record.value 159 + })); 160 + 161 + renderBookmarks(); 162 + updateConnectionStatus("connected"); 163 + } catch (error) { 164 + console.error("Failed to load bookmarks:", error); 165 + updateConnectionStatus("disconnected"); 166 + } 167 + } 168 + 169 + /** 170 + * Save a bookmark to PDS 171 + */ 172 + async function saveBookmark() { 173 + const title = titleInput.value.trim(); 174 + const url = urlInput.value.trim(); 175 + const rawTags = tagsInput.value.trim(); 176 + 177 + if (!title || !url || !atpAgent || !userDid) return; 178 + 179 + const tags = rawTags.split(",").map(t => t.trim()).filter(Boolean); 180 + 181 + const bookmarkRecord = { 182 + $type: BOOKMARK_LEXICON, 183 + uri: url, 184 + title, 185 + tags, 186 + createdAt: new Date().toISOString(), 187 + }; 188 + 189 + try { 190 + updateConnectionStatus("connecting"); 191 + 192 + const response = await atpAgent.com.atproto.repo.createRecord({ 193 + repo: userDid, 194 + collection: BOOKMARK_LEXICON, 195 + record: bookmarkRecord, 196 + }); 197 + 198 + // Add to local array 199 + bookmarks.push({ 200 + uri: response.data.uri, 201 + cid: response.data.cid, 202 + ...bookmarkRecord 203 + }); 204 + 205 + renderBookmarks(); 206 + dialog.close(); 207 + updateConnectionStatus("connected"); 208 + 209 + // Clear URL params and reload to clean state 210 + window.history.replaceState({}, document.title, window.location.pathname); 211 + } catch (error) { 212 + console.error("Failed to save bookmark:", error); 213 + updateConnectionStatus("disconnected"); 214 + alert("Failed to save bookmark. Please try again."); 215 + } 91 216 } 92 217 93 218 /** 94 - * Parses CSV text into array of rows with cells. 95 - * Handles quoted commas and newlines. 96 - * @param {string} text CSV text 97 - * @returns {string[][]} 219 + * Delete a bookmark from PDS 98 220 */ 99 - function parseCSV(text) { 100 - const rows = []; 101 - let row = []; 102 - let cell = ""; 103 - let insideQuotes = false; 221 + async function deleteBookmark(uri) { 222 + if (!atpAgent || !userDid) return; 104 223 105 - for (let i = 0; i < text.length; i++) { 106 - const char = text[i]; 224 + try { 225 + updateConnectionStatus("connecting"); 226 + 227 + const rkey = uri.split("/").pop(); 228 + await atpAgent.com.atproto.repo.deleteRecord({ 229 + repo: userDid, 230 + collection: BOOKMARK_LEXICON, 231 + rkey, 232 + }); 107 233 108 - if (char === '"') { 109 - if (insideQuotes && text[i + 1] === '"') { 110 - cell += '"'; 111 - i++; 112 - } else { 113 - insideQuotes = !insideQuotes; 114 - } 115 - } else if (char === "," && !insideQuotes) { 116 - row.push(cell); 117 - cell = ""; 118 - } else if ((char === "\n" || char === "\r") && !insideQuotes) { 119 - if (cell || row.length) row.push(cell); 120 - if (row.length) rows.push(row); 121 - row = []; 122 - cell = ""; 123 - if (char === "\r" && text[i + 1] === "\n") i++; 124 - } else { 125 - cell += char; 126 - } 234 + // Remove from local array 235 + bookmarks = bookmarks.filter(bookmark => bookmark.uri !== uri); 236 + renderBookmarks(); 237 + updateConnectionStatus("connected"); 238 + } catch (error) { 239 + console.error("Failed to delete bookmark:", error); 240 + updateConnectionStatus("disconnected"); 127 241 } 242 + } 128 243 129 - if (cell || row.length) { 130 - row.push(cell); 131 - rows.push(row); 244 + // ====== UI Functions ====== 245 + 246 + function updateConnectionStatus(status) { 247 + connectionStatus.className = `connection-status ${status}`; 248 + switch (status) { 249 + case "connected": 250 + connectionStatus.textContent = "Connected"; 251 + break; 252 + case "connecting": 253 + connectionStatus.textContent = "Connecting..."; 254 + break; 255 + case "disconnected": 256 + connectionStatus.textContent = "Disconnected"; 257 + break; 132 258 } 259 + } 133 260 134 - return rows; 261 + function showLoginDialog() { 262 + loginDialog.showModal(); 263 + openEmptyDialogBtn.style.display = "none"; 264 + sortToggleBtn.style.display = "none"; 265 + searchInput.style.display = "none"; 266 + logoutBtn.style.display = "none"; 135 267 } 136 268 137 - /** 138 - * Retrieves bookmarks stored in localStorage. 139 - * Returns parsed array of rows. 140 - */ 141 - function getBookmarks() { 142 - const csvString = localStorage.getItem("strd_bookmarks"); 143 - if (!csvString) return []; 144 - return parseCSV(csvString.trim()); 269 + function showMainUI() { 270 + openEmptyDialogBtn.style.display = "inline-block"; 271 + sortToggleBtn.style.display = "inline-block"; 272 + searchInput.style.display = "inline-block"; 273 + logoutBtn.style.display = "inline-block"; 145 274 } 146 275 276 + // ====== Utility Functions ====== 147 277 148 278 /** 149 - * Escapes CSV cell content if needed. 150 - * @param {string} cell 151 - * @returns {string} 279 + * Hashes a string to a non-negative 32-bit integer. 152 280 */ 153 - function escapeCSVCell(cell) { 154 - if (cell.includes(",") || cell.includes('"')) { 155 - return `"${cell.replace(/"/g, '""')}"`; 281 + function hashString(str) { 282 + let hash = 0; 283 + for (let i = 0; i < str.length; i++) { 284 + hash = (hash << 5) - hash + str.charCodeAt(i); 285 + hash |= 0; 156 286 } 157 - return cell; 287 + return Math.abs(hash); 158 288 } 159 289 160 290 /** 161 - * Converts rows array to CSV string. 162 - * @param {string[][]} rows 163 - * @returns {string} 291 + * Get a color pair deterministically by title. 164 292 */ 165 - function rowsToCSV(rows) { 166 - return rows.map(row => row.map(escapeCSVCell).join(",")).join("\n"); 293 + function getColorPairByTitle(title, pairs) { 294 + const hash = hashString(title); 295 + const idx = hash % pairs.length; 296 + const [bg, fg] = pairs[idx]; 297 + return (hash % 2 === 0) ? [bg, fg] : [fg, bg]; 167 298 } 168 299 169 300 /** 170 - * Updates the deleted rows stored in localStorage. 171 - * @param {string[]} currentHashes Set of hashes currently present in CSV 301 + * Get a font family deterministically by title. 172 302 */ 173 - function syncDeletedRows(currentHashes) { 174 - deleted = deleted.filter(hash => currentHashes.has(hash)); 175 - localStorage.setItem("deleted_csv_rows", JSON.stringify(deleted)); 303 + function getFontByTitle(title, fonts) { 304 + return fonts[hashString(title) % fonts.length]; 176 305 } 177 306 178 - // ====== Rendering & UI Functions ====== 307 + // ====== Rendering Functions ====== 179 308 180 309 /** 181 - * Renders bookmark containers based on rows. 182 - * @param {string[][]} rows 183 - * @param {Set<string>} storedHashes 310 + * Renders bookmark containers 184 311 */ 185 - function renderContainers(rows, storedHashes) { 312 + function renderBookmarks() { 186 313 const containerWrapper = document.querySelector(".containers"); 187 314 containerWrapper.innerHTML = ""; 188 315 189 316 const fragment = document.createDocumentFragment(); 317 + const displayBookmarks = reversedOrder ? bookmarks : [...bookmarks].reverse(); 190 318 191 - rows.forEach(row => { 192 - const titleRaw = row[0]?.trim(); 193 - const url = row[1]?.trim(); 194 - const tagsRaw = row[3]?.trim(); 195 - 196 - if (!titleRaw || !url) return; 197 - 198 - const hashKey = hashString(titleRaw + url).toString(); 319 + displayBookmarks.forEach(bookmark => { 320 + const title = bookmark.title; 321 + const url = bookmark.uri; 322 + const tags = bookmark.tags || []; 199 323 200 - if (deleted.includes(hashKey)) return; 324 + if (!title || !url) return; 201 325 202 - const title = titleRaw.replace(/^https?:\/\/(www\.)?/i, ""); 326 + const displayTitle = title.replace(/^https?:\/\/(www\.)?/i, ""); 203 327 const [bgColor, fontColor] = getColorPairByTitle(title, COLOR_PAIRS); 204 328 const fontFamily = getFontByTitle(title, FONT_LIST); 205 329 206 330 const container = document.createElement("div"); 207 - container.className = 208 - "container" + (LOCAL_GLOW && storedHashes.has(hashKey) ? " local-container" : ""); 331 + container.className = "container"; 209 332 container.style.backgroundColor = bgColor; 210 333 container.style.color = fontColor; 211 334 container.style.fontFamily = `'${fontFamily}', sans-serif`; 212 - container.dataset.id = hashKey; 213 335 214 336 // Delete Button 215 337 const closeBtn = document.createElement("button"); 216 338 closeBtn.className = "delete-btn"; 217 339 closeBtn.textContent = "x"; 218 340 closeBtn.title = "Delete this bookmark"; 219 - closeBtn.setAttribute("data-umami-event", "Delete bookmark"); 220 - closeBtn.addEventListener("click", e => handleDelete(e, row, container, storedHashes)); 341 + closeBtn.addEventListener("click", e => { 342 + e.stopPropagation(); 343 + e.preventDefault(); 344 + if (confirm("Delete this bookmark?")) { 345 + deleteBookmark(bookmark.uri); 346 + } 347 + }); 221 348 container.appendChild(closeBtn); 222 349 223 350 // Anchor (bookmark link) 224 351 const anchor = document.createElement("a"); 225 352 anchor.href = url; 226 353 anchor.target = "_blank"; 227 - anchor.innerHTML = `<span style="font-size: 5vw;"><span>${title}</span></span>`; 354 + anchor.innerHTML = `<span style="font-size: 5vw;"><span>${displayTitle}</span></span>`; 228 355 container.appendChild(anchor); 229 356 230 357 // Tags 231 - if (tagsRaw) { 232 - const tags = tagsRaw.split(",").map(t => t.trim()).filter(Boolean); 233 - if (tags.length > 0) { 234 - const wrapper = document.createElement("div"); 235 - wrapper.className = "tags-wrapper"; 358 + if (tags.length > 0) { 359 + const wrapper = document.createElement("div"); 360 + wrapper.className = "tags-wrapper"; 236 361 237 - tags.forEach(tag => { 238 - const tagDiv = document.createElement("div"); 239 - tagDiv.className = "tags tag-style"; 240 - tagDiv.textContent = `#${tag}`; 241 - tagDiv.addEventListener("click", () => filterByTag(tag)); 242 - wrapper.appendChild(tagDiv); 243 - }); 362 + tags.forEach(tag => { 363 + const tagDiv = document.createElement("div"); 364 + tagDiv.className = "tags tag-style"; 365 + tagDiv.textContent = `#${tag}`; 366 + tagDiv.addEventListener("click", () => filterByTag(tag)); 367 + wrapper.appendChild(tagDiv); 368 + }); 244 369 245 - container.appendChild(wrapper); 246 - } 370 + container.appendChild(wrapper); 247 371 } 248 372 249 373 fragment.appendChild(container); ··· 254 378 } 255 379 256 380 /** 257 - * Handles bookmark deletion. 258 - * @param {Event} e 259 - * @param {string[]} row 260 - * @param {HTMLElement} container 261 - * @param {Set<string>} storedHashes 262 - */ 263 - function handleDelete(e, row, container, storedHashes) { 264 - e.stopPropagation(); 265 - e.preventDefault(); 266 - 267 - const title = row[0]?.trim(); 268 - const url = row[1]?.trim(); 269 - const key = hashString(title + url).toString(); 270 - 271 - const isLocal = storedHashes.has(key); 272 - 273 - if (isLocal) { 274 - let csvData = localStorage.getItem("strd_bookmarks") || ""; 275 - const rows = parseCSV(csvData.trim()); 276 - 277 - // Filter out matching row 278 - const filteredRows = rows.filter(r => r[0]?.trim() !== title || r[1]?.trim() !== url); 279 - 280 - // Convert back to CSV 281 - const updatedCSV = rowsToCSV(filteredRows) + "\n"; 282 - localStorage.setItem("strd_bookmarks", updatedCSV); 283 - } else { 284 - if (!deleted.includes(key)) { 285 - deleted.push(key); 286 - localStorage.setItem("deleted_csv_rows", JSON.stringify(deleted)); 287 - } 288 - } 289 - 290 - container.remove(); 291 - } 292 - 293 - /** 294 - * Filter the bookmarks by clicking on a tag. 295 - * @param {string} tag 381 + * Filter bookmarks by tag 296 382 */ 297 383 function filterByTag(tag) { 298 - const searchInput = document.getElementById("searchInput"); 299 384 searchInput.value = `#${tag}`; 300 385 searchInput.dispatchEvent(new Event("input")); 301 386 } 302 387 303 388 /** 304 - * Formats text inside containers after rendering. 389 + * Formats text inside containers after rendering 305 390 */ 306 391 function runTextFormatting() { 307 392 document.querySelectorAll(".container").forEach(container => { ··· 314 399 315 400 anchor.innerHTML = ""; 316 401 317 - // Replace certain separators with <hr/> 318 402 const formattedText = originalText.replace(/(\s\|\s|\s-\s|\s–\s|\/,)/g, "<hr/>"); 319 403 const [firstPart, ...restParts] = formattedText.split("<hr/>"); 320 404 const secondPart = restParts.join("<hr/>"); ··· 332 416 333 417 const firstSpan = document.createElement("span"); 334 418 firstSpan.innerHTML = firstPart; 335 - 336 419 span.appendChild(firstSpan); 337 420 338 421 if (restParts.length) { ··· 351 434 }); 352 435 } 353 436 354 - // ====== Event Handlers ====== 437 + // ====== Search & Event Handlers ====== 355 438 356 439 /** 357 - * Debounce utility. 358 - * @param {Function} fn 359 - * @param {number} delay 440 + * Debounce utility 360 441 */ 361 442 function debounce(fn, delay) { 362 443 let timeout; ··· 366 447 }; 367 448 } 368 449 369 - if(searchInput){ 370 - searchInput.addEventListener( 371 - "input", 372 - debounce(e => { 373 - const searchTerm = e.target.value.trim(); 374 - updateURLSearchParam("search", searchTerm); 375 - runSearch(searchTerm); 376 - }, 150) 377 - ); 378 - } 379 - 380 450 /** 381 - * Updates URL search params without reloading page. 382 - * @param {string} key 383 - * @param {string} value 384 - */ 385 - function updateURLSearchParam(key, value) { 386 - const params = new URLSearchParams(window.location.search); 387 - if (value) params.set(key, value); 388 - else params.delete(key); 389 - history.replaceState(null, "", `${location.pathname}?${params.toString()}`); 390 - } 391 - 392 - /** 393 - * Search functionality for bookmarks. 394 - * @param {string} term 451 + * Search functionality for bookmarks 395 452 */ 396 453 function runSearch(term) { 397 454 const searchTerm = term.toLowerCase(); ··· 411 468 }); 412 469 } 413 470 414 - // Sort toggle button 415 - if(sortToggleBtn){ 416 - sortToggleBtn.addEventListener("click", () => { 417 - reversedOrder = !reversedOrder; 418 - 419 - if (reversedOrder) { 420 - renderContainers(originalRows, storedRowHashes); 421 - sortToggleBtn.lastChild.textContent = " ▼"; 422 - } else { 423 - renderContainers([...originalRows].reverse(), storedRowHashes); 424 - sortToggleBtn.lastChild.textContent = " ▲"; 425 - 426 - } 427 - }); 428 - } 429 - 430 - 431 - // ====== Dialog Logic ====== 432 - 471 + /** 472 + * Show dialog with URL params if present 473 + */ 433 474 function showParamsIfPresent() { 434 - if (!dialog) return; 475 + if (!dialog || !atpAgent) return; 476 + 435 477 const params = new URLSearchParams(window.location.search); 436 478 const title = params.get("title"); 437 479 const url = params.get("url"); ··· 441 483 urlInput.value = url; 442 484 dialog.showModal(); 443 485 } 444 - 445 - saveBtn.onclick = saveBookmark; 446 486 } 447 487 448 - function saveBookmark() { 449 - const newTitle = titleInput.value.trim(); 450 - const newUrl = urlInput.value.trim(); 451 - const rawTags = tagsInput.value.trim(); 452 - 453 - if (!newTitle || !newUrl) return; // Basic validation 454 - 455 - const timestamp = Math.floor(Date.now() / 1000); 456 - const status = "unread"; 457 - 458 - // Normalize tags 459 - const normalizedTags = rawTags.split(",").map(t => t.trim()).filter(Boolean).join(","); 460 - 461 - // Escape for CSV 462 - const safeTitle = escapeCSVCell(newTitle); 463 - const safeTags = escapeCSVCell(normalizedTags); 464 - 465 - const line = `${safeTitle},${newUrl},${timestamp},${safeTags},${status}`; 466 - 467 - let csvData = localStorage.getItem("strd_bookmarks") || ""; 468 - if (csvData && !csvData.endsWith("\n")) csvData += "\n"; 469 - csvData += line + "\n"; 470 - 471 - localStorage.setItem("strd_bookmarks", csvData); 488 + // ====== Event Listeners ====== 472 489 473 - // Save appcode if changed 474 - const appcodeValue = appcodeInput?.value.trim(); 475 - if (appcodeValue && localStorage.getItem("appcode") !== appcodeValue) { 476 - localStorage.setItem("appcode", appcodeValue); 477 - } 490 + // Login/logout 491 + loginBtn.addEventListener("click", login); 492 + logoutBtn.addEventListener("click", logout); 478 493 494 + // Dialog 495 + saveBtn.addEventListener("click", saveBookmark); 496 + cancelBtn?.addEventListener("click", () => { 479 497 dialog.close(); 480 - window.location.href = window.location.pathname; // Reload page to re-render 481 - } 498 + window.history.replaceState({}, document.title, window.location.pathname); 499 + }); 482 500 483 - if(cancelBtn){ 484 - cancelBtn.onclick = () => { 485 - dialog.close(); 486 - window.location.href = window.location.pathname; 487 - }; 488 - } 501 + // Main UI 502 + openEmptyDialogBtn?.addEventListener("click", () => { 503 + if (!atpAgent) return; 504 + 505 + titleInput.value = ""; 506 + urlInput.value = ""; 507 + tagsInput.value = ""; 508 + 509 + const countInfo = document.getElementById("paramDialogCount"); 510 + countInfo.innerHTML = `${bookmarks.length} bookmarks in PDS`; 511 + 512 + dialog.showModal(); 513 + }); 489 514 490 - // Open dialog button logic with counts 491 - if(openEmptyDialogBtn){ 515 + // Search 516 + searchInput?.addEventListener( 517 + "input", 518 + debounce(e => { 519 + const searchTerm = e.target.value.trim(); 520 + const params = new URLSearchParams(window.location.search); 521 + if (searchTerm) params.set("search", searchTerm); 522 + else params.delete("search"); 523 + history.replaceState(null, "", `${location.pathname}?${params.toString()}`); 524 + runSearch(searchTerm); 525 + }, 150) 526 + ); 492 527 493 - console.log('!!! appcode', typeof appcode) 494 - openEmptyDialogBtn.style.display = ( typeof appcode === "undefined" || (typeof appcode !== "undefined" && localStorage.getItem("appcode") === appcode)) ? "inline-block" : "none"; 528 + // Sort toggle 529 + sortToggleBtn?.addEventListener("click", () => { 530 + reversedOrder = !reversedOrder; 531 + renderBookmarks(); 495 532 496 - openEmptyDialogBtn.addEventListener("click", () => { 497 - titleInput.value = ""; 498 - urlInput.value = ""; 499 - 500 - const deletedHashes = JSON.parse(localStorage.getItem("deleted_csv_rows") || "[]"); 501 - 502 - const csvCount = csvRows.filter(row => { 503 - const title = row[0]?.trim(); 504 - const url = row[1]?.trim(); 505 - if (!title || !url) return false; 506 - const key = hashString(title + url).toString(); 507 - return !deletedHashes.includes(key); 508 - }).length; 509 - 510 - const deletedCount = csvRows.length - csvCount; 511 - 512 - const countInfo = document.getElementById("paramDialogCount"); 513 - const parts = [`${csvCount} bookmarks from .csv`]; 514 - if (storedRows.length > 0) parts.push(`<span style="color: green;">${storedRows.length} new</span>`); 515 - if (deletedCount > 0) parts.push(`<span style="color: red;">${deletedCount} deleted</span>`); 516 - 517 - countInfo.innerHTML = parts.join(" | "); 518 - 519 - dialog.showModal(); 520 - }); 521 - } 522 - // Export button logic 523 - if(exportBtn){ 524 - exportBtn.addEventListener("click", () => { 525 - 526 - // get the rows shown 527 - const deletedHashes = JSON.parse(localStorage.getItem("deleted_csv_rows") || "[]"); 528 - 529 - const visibleCSVRows = csvRows.filter(row => { 530 - const title = row[0]?.trim(); 531 - const url = row[1]?.trim(); 532 - if (!title || !url) return false; 533 - const key = hashString(title + url).toString(); 534 - return !deletedHashes.includes(key); 535 - }); 536 - 537 - 538 - let allRows = []; 539 - if (EXPORT === "csv") { 540 - allRows = visibleCSVRows; 541 - } else if (EXPORT === "local") { 542 - allRows = storedRows; 543 - } else if (EXPORT === "all") { 544 - allRows = [...visibleCSVRows, ...storedRows]; 545 - } 546 - 547 - // create csv 548 - const header = "title,url,timestamp,tags,status"; 549 - const csvString = [header, ...allRows.map(row => row.map(escapeCSVCell).join(","))].join("\n"); 550 - 551 - const blob = new Blob([csvString], { type: "text/csv;charset=utf-8;" }); 552 - const url = URL.createObjectURL(blob); 553 - 554 - const a = document.createElement("a"); 555 - a.href = url; 556 - a.download = "mybookmarks.csv"; 557 - a.style.display = "none"; 558 - document.body.appendChild(a); 559 - a.click(); 560 - document.body.removeChild(a); 561 - URL.revokeObjectURL(url); 562 - 563 - // clear deleted hashes after export 564 - localStorage.removeItem("deleted_csv_rows"); 565 - }); 566 - } 567 - 568 - 569 - 570 - // Import logic 571 - document.addEventListener("DOMContentLoaded", () => { 572 - const saveBtn = document.getElementById("importSaveBtn"); 573 - 574 - console.log('!!! loaded') 575 - if (importArea) { 576 - console.log('!! import area') 577 - 578 - importArea.addEventListener("blur", () => { 579 - const csv = importArea.value.trim(); 580 - if (!csv) return; 581 - 582 - const rows = parseCSV(csv); 583 - const valid = rows.filter(row => 584 - Array.isArray(row) && 585 - row.length >= 5 && 586 - row[0].trim() && 587 - row[1].trim() && 588 - !isNaN(Number(row[2])) && 589 - typeof row[4] === "string" 590 - ); 591 - 592 - if (!valid.length) { 593 - alert("No valid CSV rows found. Expecting title,url,timestamp,tags,status"); 594 - return; 595 - } 596 - 597 - const existing = localStorage.getItem("strd_bookmarks") || ""; 598 - const existingLines = existing.trim() ? existing.trim().split("\n") : []; 599 - 600 - const cleanedRows = valid.map(row => 601 - row.map(escapeCSVCell).join(",") 602 - ); 603 - 604 - const updated = [...existingLines, ...cleanedRows].join("\n") + "\n"; 605 - localStorage.setItem("strd_bookmarks", updated); 606 - alert(`${cleanedRows.length} valid rows added to localStorage.`); 607 - }); 608 - } 609 - 610 - if (importArea && saveBtn) { 611 - saveBtn.addEventListener("click", () => { 612 - importArea.dispatchEvent(new Event("blur")); 613 - }); 533 + if (reversedOrder) { 534 + sortToggleBtn.lastChild.textContent = " ▼"; 535 + } else { 536 + sortToggleBtn.lastChild.textContent = " ▲"; 614 537 } 615 538 }); 616 - 617 539 618 540 // ====== Initialization ====== 619 541 620 - fetch("mybookmarks.csv") 621 - .then(response => { 622 - if (!response.ok) throw new Error("Failed to load CSV"); 623 - return response.text(); 624 - }) 625 - .then(csv => { 626 - const allRows = parseCSV(csv.trim()); 627 - csvRows = allRows.slice(1); // remove header 628 - 629 - const currentCSVHashes = new Set( 630 - csvRows.map(row => { 631 - const title = row[0]?.trim(); 632 - const url = row[1]?.trim(); 633 - return title && url ? hashString(title + url).toString() : null; 634 - }).filter(Boolean) 635 - ); 636 - 637 - // Sync deleted rows with current CSV content 638 - syncDeletedRows(currentCSVHashes); 639 - 640 - storedRows = getBookmarks().filter(Boolean); 641 - storedRowHashes = new Set(storedRows.map(r => hashString((r[0]?.trim() || "") + (r[1]?.trim() || "")).toString())); 642 - 643 - originalRows = [...csvRows, ...storedRows]; 644 - renderContainers([...originalRows].reverse(), storedRowHashes); 645 - 542 + document.addEventListener("DOMContentLoaded", async () => { 543 + updateConnectionStatus("disconnected"); 544 + 545 + // Wait for AtpAgent to be loaded 546 + let attempts = 0; 547 + while (!window.AtpAgent && attempts < 50) { 548 + await new Promise(resolve => setTimeout(resolve, 100)); 549 + attempts++; 550 + } 551 + 552 + if (!window.AtpAgent) { 553 + console.error("Failed to load AtpAgent"); 554 + updateConnectionStatus("disconnected"); 555 + return; 556 + } 557 + 558 + const initialized = await initializeATProto(); 559 + if (initialized) { 560 + showParamsIfPresent(); 561 + 646 562 // Restore search from URL 647 563 const initialSearch = new URLSearchParams(window.location.search).get("search"); 648 564 if (initialSearch) { 649 - const searchInput = document.getElementById("searchInput"); 650 565 searchInput.value = initialSearch; 651 566 runSearch(initialSearch); 652 567 } 653 - }) 654 - .catch(console.error); 655 - 656 - // Show or hide appcode input based on localStorage 657 - const savedAppcode = localStorage.getItem("appcode"); 658 - 659 - /** 660 - * Enable or disable save button based on appcode input state. 661 - */ 662 - function updateSaveButtonState() { 663 - const localCode = localStorage.getItem("appcode") || ""; 664 - const inputCode = appcodeInput?.value?.trim() || ""; 665 - saveBtn.disabled = !(localCode === appcode || inputCode === appcode); 666 - } 667 - 668 - showParamsIfPresent(); 669 - updateSaveButtonState(); 670 - 671 - appcodeInput?.addEventListener("input", updateSaveButtonState); 568 + } 569 + });
+23
style.css
··· 324 324 } 325 325 } 326 326 327 + /* Connection status styles */ 328 + .connection-status { 329 + font-size: 0.8em; 330 + margin-left: 10px; 331 + padding: 4px 8px; 332 + border-radius: 4px; 333 + } 334 + 335 + .connection-status.connected { 336 + background-color: #2ecc71; 337 + color: white; 338 + } 339 + 340 + .connection-status.disconnected { 341 + background-color: #e74c3c; 342 + color: white; 343 + } 344 + 345 + .connection-status.connecting { 346 + background-color: #f39c12; 347 + color: white; 348 + } 349 + 327 350
+9 -47
transfer_page.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>transfer page | woomarks</title> 7 - <link 8 - href="https://fonts.googleapis.com/css2?family=Doto&family=Alfa+Slab+One&family=Bebas+Neue&family=Bree+Serif&family=Caveat&family=Courier+Prime&family=Dosis&family=EB+Garamond&family=Permanent+Marker&family=Sedan+SC&family=Ultra&display=swap" 9 - rel="stylesheet" 10 - /> 11 - <link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" /> 12 - <link rel="stylesheet" href="style.css" /> 6 + <title>woomarks - AT Protocol Edition</title> 7 + <script> 8 + // Redirect to main page since we no longer use CSV import/export 9 + window.location.href = '/index.html'; 10 + </script> 13 11 </head> 14 - 15 12 <body> 16 - <div class="topbar"> 17 - <div style="flex-grow: 1"> 18 - <b><a id="headerTitle" href="/index.html">woomarks</a></b> 19 - </div> 20 - </div> 21 - 22 - <div class="page"> 23 - <h1>Transfer Page</h1> 24 - <textarea placeholder="Paste CSV contents" id="importArea"></textarea> 25 - 26 - <div style="display: flex; justify-content: flex-end; gap: 30px;"> 27 - <button class="param-btn" id="exportBtn" class="export-link" data-umami-event="Export"> 28 - Export my links as csv 29 - </button> 30 - 31 - <button class="param-btn dark" id="importSaveBtn" data-umami-event="Import"> 32 - Import my links 33 - </button> 34 - </div> 35 - 36 - 37 - <br/><br/><br/><br/><br/><br/> 38 - 39 - <h3>How to import your bookmarks from Pocket</h3> 40 - <p>Download from their page <a href="https://getpocket.com/export">https://getpocket.com/export</a></p> 41 - <p>Open the file and paste its contents in the textarea. It won't upload the content, it will put it in the local storage of your browser</p> 42 - 43 - <h3>How to import your bookmarks from other places</h3> 44 - <p>Make a csv file with this formatting and paste its contents in the textarea. You can copy paste this sample to test it.</p> 45 - <pre><code>title,url,time_added,tags,status 46 - A Parliament of Owls and a Murder of Crows: How Groups of Birds Got Their N,https://www.themarginalian.org/2024/01/04/brian-wildsmith-birds-company-terms/,1706544592,,unread 47 - 100 Best Books of the 21st Century - The New York Times,https://www.nytimes.com/interactive/2024/books/best-books-21st-century.html,1732713693,test,unreadSeven Goldfish,https://7goldfish.com/articles/2024_Hugo_Nominees.php,1750558532,books,unread 48 - Some Title,https://example.com,1720984873,tag1,unread 49 - "Title, with comma",https://example.com,1720984000,"tag1,tag2",read 50 - </code></pre> 51 - </div> 52 - </body> 53 - <script src="./script.js"></script> 54 - 13 + <p>Redirecting to main woomarks app...</p> 14 + <p>This AT Protocol edition no longer supports CSV import/export.</p> 15 + <p>If not redirected automatically, <a href="/index.html">click here</a>.</p> 16 + </body> 55 17 </html>