this repo has no description
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

First push

Roberto Martinez 2e776d9c

+1203
+26
faq.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>woomarks</title> 7 + <link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" /> 8 + <link rel="stylesheet" href="style.css" /> 9 + </head> 10 + 11 + <body> 12 + <div class="topbar"> 13 + <div style="flex-grow: 1"> 14 + <b><a id="headerTitle" href="/">woomarks</a></b> 15 + </div> 16 + </div> 17 + 18 + <div class="page"> 19 + <h3>Question?</h3> 20 + <p> 21 + Answer 22 + </p> 23 + 24 + </div> 25 + </body> 26 + </html>
favicon.ico

This is a binary file and will not be displayed.

+12
favicon.svg
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <svg width="136px" height="101px" viewBox="0 0 136 101" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> 3 + <!-- Generator: Sketch 63.1 (92452) - https://sketch.com --> 4 + <title>Rectangle</title> 5 + <desc>Created with Sketch.</desc> 6 + <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> 7 + <text id="w" font-family="CourierNewPS-BoldMT, Courier New" font-size="210" font-weight="bold" letter-spacing="2.58461" fill="#F2F0D8"> 8 + <tspan x="-27.8025589" y="250">w</tspan> 9 + </text> 10 + <path d="M134,-1.82907264 L90.1248898,54.5817834 L68,21.3944487 L45.8751102,54.5817834 L2,-1.82907264 L2,99 L134,99 L134,-1.82907264 Z" id="Rectangle" stroke="#EABC60" stroke-width="4" fill="#EABC60" stroke-linejoin="round"></path> 11 + </g> 12 + </svg>
+104
index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + 7 + <title>woomarks</title> 8 + <link 9 + 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" 10 + rel="stylesheet" 11 + /> 12 + <link rel="icon" href="favicon.svg" type="image/svg+xml" /> 13 + <link rel="stylesheet" href="style.css" /> 14 + </head> 15 + 16 + <body> 17 + <div class="topbar"> 18 + <div style="flex-grow: 1"> 19 + <b><a id="headerTitle" href="">woomarks</a></b> 20 + <a href="/faq.html">FAQ</a> 21 + </div> 22 + <button id="openEmptyDialogBtn" data-umami-event="Open creation modal" class="param-btn"><span class="btn-text">Add</span> ➕</button> 23 + 24 + <button id="sortToggleBtn" data-umami-event="Sort" class="param-btn"><span class="btn-text">Sort</span> ▲</button> 25 + <input 26 + type="text" 27 + id="searchInput" 28 + placeholder="'demo' '#books'" 29 + data-umami-event="Search" 30 + /> 31 + </div> 32 + 33 + <div class="containers"></div> 34 + 35 + <!-- LEAVE THIS THIS LINE --> 36 + <div class="footer">Made with <a href="">woomarks</a>.</div> 37 + 38 + <dialog id="paramDialog" class="param-dialog"> 39 + <form method="dialog" class="param-form"> 40 + <div class="param-group"> 41 + <label for="paramTitle" class="param-label">Title</label> 42 + <input 43 + type="text" 44 + id="paramTitle" 45 + name="paramTitle" 46 + class="param-input" 47 + /> 48 + </div> 49 + 50 + <div class="param-group"> 51 + <label for="paramUrl" class="param-label">URL</label> 52 + <input 53 + type="text" 54 + id="paramUrl" 55 + name="paramUrl" 56 + class="param-input" 57 + /> 58 + </div> 59 + 60 + <div class="param-group"> 61 + <label for="tagsInput" class="param-label" 62 + >Tags (comma-separated)</label 63 + > 64 + <input 65 + id="tagsInput" 66 + type="text" 67 + class="param-input" 68 + placeholder="e.g. work, music" 69 + /> 70 + </div> 71 + 72 + <p id="paramDialogCount" class="param-count"></p> 73 + 74 + <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 + <div class="menu-right"> 81 + <button 82 + id="cancelBtn" 83 + type="button" 84 + value="cancel" 85 + class="param-btn cancel" 86 + > 87 + Cancel 88 + </button> 89 + <button 90 + id="saveBtn" 91 + type="button" 92 + value="default" 93 + class="param-btn dark" 94 + data-umami-event="Add bookmark" 95 + > 96 + Add bookmark 97 + </button> 98 + </div> 99 + </menu> 100 + </form> 101 + </dialog> 102 + </body> 103 + <script async src="./script.js"></script> 104 + </html>
+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
+672
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"; 5 + const MAX_CHARS_PER_LINE = 15; 6 + const MAX_LINES = 4; 7 + const EST_CHAR_WIDTH = 0.6; // em 8 + const HYPHENATE_THRESHOLD = 12; 9 + const COLOR_PAIRS = [ 10 + ["#D1F257", "#0D0D0D"], ["#F2BBDF", "#D94E41"], ["#010D00", "#33A63B"], 11 + ["#F2E4E4", "#0D0C00"], ["#2561D9", "#F2FDFE"], ["#734c48", "#F2F2EB"], 12 + ["#8FBFAE", "#127357"], ["#3A8C5D", "#F2BFAC"], ["#8AA3A6", "#F2F0E4"], 13 + ["#F2C438", "#F23E2E"], ["#455919", "#F2D338"], ["#F2D8A7", "#F26363"], 14 + ["#260101", "#D93223"], ["#456EBF", "#F2F1E9"], ["#131E40", "#F2A413"], 15 + ["#F2F2F2", "#131E40"], ["#262626", "#F2EDDC"], ["#40593C", "#F2E6D0"], 16 + ["#F2F1DF", "#262416"], ["#F2CB05", "#0D0D0D"], ["#F2F2F2", "#F2CB05"], 17 + ["#F2E6D0", "#261C10"], ["#F2D7D0", "#262523"], ["#F2F0D8", "#F24535"], 18 + ["#191726", "#D9D9D9"], ["#F2E8D5", "#0C06BF"], ["#F2EFE9", "#45BFB3"], 19 + ["#F2C2C2", "#D93644"], ["#734C48", "#F2C2C2"], 20 + ]; 21 + 22 + const FONT_LIST = [ 23 + "Caveat", "Permanent Marker", "Courier", "Doto", "Bree Serif", 24 + "Ultra", "Alfa Slab One", "Sedan SC", "EB Garamond", "Bebas Neue", 25 + ]; 26 + 27 + // State variables 28 + let originalRows = []; 29 + let csvRows = []; 30 + let storedRows = []; 31 + let storedRowHashes = new Set(); 32 + let reversedOrder = false; 33 + let deleted = JSON.parse(localStorage.getItem("deleted_csv_rows") || "[]"); 34 + 35 + 36 + // ====== DOM Elements ====== 37 + 38 + const dialog = document.getElementById("paramDialog"); 39 + const titleInput = document.getElementById("paramTitle"); 40 + const urlInput = document.getElementById("paramUrl"); 41 + const tagsInput = document.getElementById("tagsInput"); 42 + const saveBtn = document.getElementById("saveBtn"); 43 + const cancelBtn = document.getElementById("cancelBtn"); 44 + const openEmptyDialogBtn = document.getElementById("openEmptyDialogBtn"); 45 + const appcodeGroup = document.getElementById("appcodeGroup"); 46 + const appcodeInput = document.getElementById("appcode"); 47 + const modalOverlay = document.getElementById("modalOverlay"); 48 + const searchInput =document.getElementById("searchInput"); 49 + const sortToggleBtn = document.getElementById("sortToggleBtn"); 50 + const exportBtn = document.getElementById("exportBtn") 51 + const importArea = document.getElementById("importArea") 52 + 53 + 54 + // ====== Utility Functions ====== 55 + 56 + /** 57 + * Hashes a string to a non-negative 32-bit integer. 58 + * @param {string} str 59 + * @returns {number} 60 + */ 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 66 + } 67 + return Math.abs(hash); 68 + } 69 + 70 + /** 71 + * Get a color pair deterministically by title. 72 + * @param {string} title 73 + * @param {Array<Array<string>>} pairs 74 + * @returns {[string, string]} [backgroundColor, fontColor] 75 + */ 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]; 81 + } 82 + 83 + /** 84 + * Get a font family deterministically by title. 85 + * @param {string} title 86 + * @param {string[]} fonts 87 + * @returns {string} 88 + */ 89 + function getFontByTitle(title, fonts) { 90 + return fonts[hashString(title) % fonts.length]; 91 + } 92 + 93 + /** 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[][]} 98 + */ 99 + function parseCSV(text) { 100 + const rows = []; 101 + let row = []; 102 + let cell = ""; 103 + let insideQuotes = false; 104 + 105 + for (let i = 0; i < text.length; i++) { 106 + const char = text[i]; 107 + 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 + } 127 + } 128 + 129 + if (cell || row.length) { 130 + row.push(cell); 131 + rows.push(row); 132 + } 133 + 134 + return rows; 135 + } 136 + 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()); 145 + } 146 + 147 + 148 + /** 149 + * Escapes CSV cell content if needed. 150 + * @param {string} cell 151 + * @returns {string} 152 + */ 153 + function escapeCSVCell(cell) { 154 + if (cell.includes(",") || cell.includes('"')) { 155 + return `"${cell.replace(/"/g, '""')}"`; 156 + } 157 + return cell; 158 + } 159 + 160 + /** 161 + * Converts rows array to CSV string. 162 + * @param {string[][]} rows 163 + * @returns {string} 164 + */ 165 + function rowsToCSV(rows) { 166 + return rows.map(row => row.map(escapeCSVCell).join(",")).join("\n"); 167 + } 168 + 169 + /** 170 + * Updates the deleted rows stored in localStorage. 171 + * @param {string[]} currentHashes Set of hashes currently present in CSV 172 + */ 173 + function syncDeletedRows(currentHashes) { 174 + deleted = deleted.filter(hash => currentHashes.has(hash)); 175 + localStorage.setItem("deleted_csv_rows", JSON.stringify(deleted)); 176 + } 177 + 178 + // ====== Rendering & UI Functions ====== 179 + 180 + /** 181 + * Renders bookmark containers based on rows. 182 + * @param {string[][]} rows 183 + * @param {Set<string>} storedHashes 184 + */ 185 + function renderContainers(rows, storedHashes) { 186 + const containerWrapper = document.querySelector(".containers"); 187 + containerWrapper.innerHTML = ""; 188 + 189 + const fragment = document.createDocumentFragment(); 190 + 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(); 199 + 200 + if (deleted.includes(hashKey)) return; 201 + 202 + const title = titleRaw.replace(/^https?:\/\/(www\.)?/i, ""); 203 + const [bgColor, fontColor] = getColorPairByTitle(title, COLOR_PAIRS); 204 + const fontFamily = getFontByTitle(title, FONT_LIST); 205 + 206 + const container = document.createElement("div"); 207 + container.className = 208 + "container" + (LOCAL_GLOW && storedHashes.has(hashKey) ? " local-container" : ""); 209 + container.style.backgroundColor = bgColor; 210 + container.style.color = fontColor; 211 + container.style.fontFamily = `'${fontFamily}', sans-serif`; 212 + container.dataset.id = hashKey; 213 + 214 + // Delete Button 215 + const closeBtn = document.createElement("button"); 216 + closeBtn.className = "delete-btn"; 217 + closeBtn.textContent = "x"; 218 + closeBtn.title = "Delete this bookmark"; 219 + closeBtn.setAttribute("data-umami-event", "Delete bookmark"); 220 + closeBtn.addEventListener("click", e => handleDelete(e, row, container, storedHashes)); 221 + container.appendChild(closeBtn); 222 + 223 + // Anchor (bookmark link) 224 + const anchor = document.createElement("a"); 225 + anchor.href = url; 226 + anchor.target = "_blank"; 227 + anchor.innerHTML = `<span style="font-size: 5vw;"><span>${title}</span></span>`; 228 + container.appendChild(anchor); 229 + 230 + // 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"; 236 + 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 + }); 244 + 245 + container.appendChild(wrapper); 246 + } 247 + } 248 + 249 + fragment.appendChild(container); 250 + }); 251 + 252 + containerWrapper.appendChild(fragment); 253 + runTextFormatting(); 254 + } 255 + 256 + /** 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 296 + */ 297 + function filterByTag(tag) { 298 + const searchInput = document.getElementById("searchInput"); 299 + searchInput.value = `#${tag}`; 300 + searchInput.dispatchEvent(new Event("input")); 301 + } 302 + 303 + /** 304 + * Formats text inside containers after rendering. 305 + */ 306 + function runTextFormatting() { 307 + document.querySelectorAll(".container").forEach(container => { 308 + const anchor = container.querySelector("a"); 309 + if (!anchor) return; 310 + 311 + const originalText = anchor.innerText.trim(); 312 + const href = anchor.href; 313 + if (!originalText || !href) return; 314 + 315 + anchor.innerHTML = ""; 316 + 317 + // Replace certain separators with <hr/> 318 + const formattedText = originalText.replace(/(\s\|\s|\s-\s|\s–\s|\/,)/g, "<hr/>"); 319 + const [firstPart, ...restParts] = formattedText.split("<hr/>"); 320 + const secondPart = restParts.join("<hr/>"); 321 + 322 + const span = document.createElement("span"); 323 + 324 + let fontSizeVW = 3; 325 + if (originalText.length < 9) fontSizeVW = 6; 326 + else if (originalText.length < 20) fontSizeVW = 5; 327 + else if (originalText.length < 35) fontSizeVW = 4; 328 + else if (originalText.length < 100) fontSizeVW = 3; 329 + else fontSizeVW = 2.5; 330 + 331 + span.style.fontSize = `${fontSizeVW}vw`; 332 + 333 + const firstSpan = document.createElement("span"); 334 + firstSpan.innerHTML = firstPart; 335 + 336 + span.appendChild(firstSpan); 337 + 338 + if (restParts.length) { 339 + const hr = document.createElement("hr"); 340 + hr.classList.add("invisible-hr"); 341 + 342 + const secondSpan = document.createElement("span"); 343 + secondSpan.innerHTML = secondPart; 344 + secondSpan.style.fontSize = `${(fontSizeVW * 2) / 3}vw`; 345 + 346 + span.appendChild(hr); 347 + span.appendChild(secondSpan); 348 + } 349 + 350 + anchor.appendChild(span); 351 + }); 352 + } 353 + 354 + // ====== Event Handlers ====== 355 + 356 + /** 357 + * Debounce utility. 358 + * @param {Function} fn 359 + * @param {number} delay 360 + */ 361 + function debounce(fn, delay) { 362 + let timeout; 363 + return (...args) => { 364 + clearTimeout(timeout); 365 + timeout = setTimeout(() => fn(...args), delay); 366 + }; 367 + } 368 + 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 + /** 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 395 + */ 396 + function runSearch(term) { 397 + const searchTerm = term.toLowerCase(); 398 + 399 + document.querySelectorAll(".container").forEach(container => { 400 + if (searchTerm.startsWith("#")) { 401 + const tagToSearch = searchTerm.slice(1); 402 + const tags = Array.from(container.querySelectorAll(".tags")) 403 + .map(el => el.textContent.toLowerCase().replace("#", "").trim()); 404 + 405 + container.style.display = tags.some(tag => tag.includes(tagToSearch)) ? "block" : "none"; 406 + } else { 407 + const anchor = container.querySelector("a"); 408 + const title = anchor?.innerText.toLowerCase() || ""; 409 + container.style.display = title.includes(searchTerm) ? "block" : "none"; 410 + } 411 + }); 412 + } 413 + 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 + 433 + function showParamsIfPresent() { 434 + const params = new URLSearchParams(window.location.search); 435 + const title = params.get("title"); 436 + const url = params.get("url"); 437 + 438 + if (title && url) { 439 + titleInput.value = title; 440 + urlInput.value = url; 441 + dialog.showModal(); 442 + } 443 + 444 + saveBtn.onclick = saveBookmark; 445 + } 446 + 447 + function saveBookmark() { 448 + const newTitle = titleInput.value.trim(); 449 + const newUrl = urlInput.value.trim(); 450 + const rawTags = tagsInput.value.trim(); 451 + 452 + if (!newTitle || !newUrl) return; // Basic validation 453 + 454 + const timestamp = Math.floor(Date.now() / 1000); 455 + const status = "unread"; 456 + 457 + // Normalize tags 458 + const normalizedTags = rawTags.split(",").map(t => t.trim()).filter(Boolean).join(","); 459 + 460 + // Escape for CSV 461 + const safeTitle = escapeCSVCell(newTitle); 462 + const safeTags = escapeCSVCell(normalizedTags); 463 + 464 + const line = `${safeTitle},${newUrl},${timestamp},${safeTags},${status}`; 465 + 466 + let csvData = localStorage.getItem("strd_bookmarks") || ""; 467 + if (csvData && !csvData.endsWith("\n")) csvData += "\n"; 468 + csvData += line + "\n"; 469 + 470 + localStorage.setItem("strd_bookmarks", csvData); 471 + 472 + // Save appcode if changed 473 + const appcodeValue = appcodeInput?.value.trim(); 474 + if (appcodeValue && localStorage.getItem("appcode") !== appcodeValue) { 475 + localStorage.setItem("appcode", appcodeValue); 476 + } 477 + 478 + dialog.close(); 479 + window.location.href = window.location.pathname; // Reload page to re-render 480 + } 481 + 482 + if(cancelBtn){ 483 + cancelBtn.onclick = () => { 484 + dialog.close(); 485 + window.location.href = window.location.pathname; 486 + }; 487 + } 488 + 489 + // Open dialog button logic with counts 490 + if(openEmptyDialogBtn){ 491 + 492 + console.log('!!! appcode', typeof appcode) 493 + openEmptyDialogBtn.style.display = ( typeof appcode === "undefined" || (typeof appcode !== "undefined" && localStorage.getItem("appcode") === appcode)) ? "inline-block" : "none"; 494 + 495 + openEmptyDialogBtn.addEventListener("click", () => { 496 + titleInput.value = ""; 497 + urlInput.value = ""; 498 + 499 + const deletedHashes = JSON.parse(localStorage.getItem("deleted_csv_rows") || "[]"); 500 + 501 + const csvCount = csvRows.filter(row => { 502 + const title = row[0]?.trim(); 503 + const url = row[1]?.trim(); 504 + if (!title || !url) return false; 505 + const key = hashString(title + url).toString(); 506 + return !deletedHashes.includes(key); 507 + }).length; 508 + 509 + const deletedCount = csvRows.length - csvCount; 510 + 511 + const countInfo = document.getElementById("paramDialogCount"); 512 + const parts = [`${csvCount} bookmarks from .csv`]; 513 + if (storedRows.length > 0) parts.push(`<span style="color: green;">${storedRows.length} new</span>`); 514 + if (deletedCount > 0) parts.push(`<span style="color: red;">${deletedCount} deleted</span>`); 515 + 516 + countInfo.innerHTML = parts.join(" | "); 517 + 518 + dialog.showModal(); 519 + }); 520 + } 521 + // Export button logic 522 + if(exportBtn){ 523 + exportBtn.addEventListener("click", () => { 524 + 525 + // get the rows shown 526 + const deletedHashes = JSON.parse(localStorage.getItem("deleted_csv_rows") || "[]"); 527 + 528 + const visibleCSVRows = csvRows.filter(row => { 529 + const title = row[0]?.trim(); 530 + const url = row[1]?.trim(); 531 + if (!title || !url) return false; 532 + const key = hashString(title + url).toString(); 533 + return !deletedHashes.includes(key); 534 + }); 535 + 536 + 537 + let allRows = []; 538 + if (EXPORT === "csv") { 539 + allRows = visibleCSVRows; 540 + } else if (EXPORT === "local") { 541 + allRows = storedRows; 542 + } else if (EXPORT === "all") { 543 + allRows = [...visibleCSVRows, ...storedRows]; 544 + } 545 + 546 + // create csv 547 + const header = "title,url,timestamp,tags,status"; 548 + const csvString = [header, ...allRows.map(row => row.map(escapeCSVCell).join(","))].join("\n"); 549 + 550 + const blob = new Blob([csvString], { type: "text/csv;charset=utf-8;" }); 551 + const url = URL.createObjectURL(blob); 552 + 553 + const a = document.createElement("a"); 554 + a.href = url; 555 + a.download = "mybookmarks.csv"; 556 + a.style.display = "none"; 557 + document.body.appendChild(a); 558 + a.click(); 559 + document.body.removeChild(a); 560 + URL.revokeObjectURL(url); 561 + 562 + // clear deleted hashes after export 563 + localStorage.removeItem("deleted_csv_rows"); 564 + }); 565 + } 566 + 567 + 568 + 569 + // Import logic 570 + document.addEventListener("DOMContentLoaded", () => { 571 + const saveBtn = document.getElementById("importSaveBtn"); 572 + 573 + console.log('!!! loaded') 574 + if (importArea) { 575 + console.log('!! import area') 576 + 577 + importArea.addEventListener("blur", () => { 578 + const csv = importArea.value.trim(); 579 + if (!csv) return; 580 + 581 + const rows = parseCSV(csv); 582 + const valid = rows.filter(row => 583 + Array.isArray(row) && 584 + row.length >= 5 && 585 + row[0].trim() && 586 + row[1].trim() && 587 + !isNaN(Number(row[2])) && 588 + typeof row[4] === "string" 589 + ); 590 + 591 + if (!valid.length) { 592 + alert("No valid CSV rows found. Expecting title,url,timestamp,tags,status"); 593 + return; 594 + } 595 + 596 + const existing = localStorage.getItem("strd_bookmarks") || ""; 597 + const existingLines = existing.trim() ? existing.trim().split("\n") : []; 598 + 599 + const cleanedRows = valid.map(row => 600 + row.map(escapeCSVCell).join(",") 601 + ); 602 + 603 + const updated = [...existingLines, ...cleanedRows].join("\n") + "\n"; 604 + localStorage.setItem("strd_bookmarks", updated); 605 + alert(`${cleanedRows.length} valid rows added to localStorage.`); 606 + }); 607 + } 608 + 609 + if (importArea && saveBtn) { 610 + saveBtn.addEventListener("click", () => { 611 + importArea.dispatchEvent(new Event("blur")); 612 + }); 613 + } 614 + }); 615 + 616 + 617 + // ====== Initialization ====== 618 + 619 + fetch("mybookmarks.csv") 620 + .then(response => { 621 + if (!response.ok) throw new Error("Failed to load CSV"); 622 + return response.text(); 623 + }) 624 + .then(csv => { 625 + const allRows = parseCSV(csv.trim()); 626 + csvRows = allRows.slice(1); // remove header 627 + 628 + const currentCSVHashes = new Set( 629 + csvRows.map(row => { 630 + const title = row[0]?.trim(); 631 + const url = row[1]?.trim(); 632 + return title && url ? hashString(title + url).toString() : null; 633 + }).filter(Boolean) 634 + ); 635 + 636 + // Sync deleted rows with current CSV content 637 + syncDeletedRows(currentCSVHashes); 638 + 639 + storedRows = getBookmarks().filter(Boolean); 640 + storedRowHashes = new Set(storedRows.map(r => hashString((r[0]?.trim() || "") + (r[1]?.trim() || "")).toString())); 641 + 642 + originalRows = [...csvRows, ...storedRows]; 643 + renderContainers([...originalRows].reverse(), storedRowHashes); 644 + 645 + // Restore search from URL 646 + const initialSearch = new URLSearchParams(window.location.search).get("search"); 647 + if (initialSearch) { 648 + const searchInput = document.getElementById("searchInput"); 649 + searchInput.value = initialSearch; 650 + runSearch(initialSearch); 651 + } 652 + }) 653 + .catch(console.error); 654 + 655 + // Show or hide appcode input based on localStorage 656 + const savedAppcode = localStorage.getItem("appcode"); 657 + if (!savedAppcode) appcodeGroup.style.display = "flex"; 658 + if (!appcode) appcodeGroup.style.display = "none"; 659 + 660 + /** 661 + * Enable or disable save button based on appcode input state. 662 + */ 663 + function updateSaveButtonState() { 664 + const localCode = localStorage.getItem("appcode") || ""; 665 + const inputCode = appcodeInput?.value?.trim() || ""; 666 + saveBtn.disabled = !(localCode === appcode || inputCode === appcode); 667 + } 668 + 669 + showParamsIfPresent(); 670 + updateSaveButtonState(); 671 + 672 + appcodeInput?.addEventListener("input", updateSaveButtonState);
+327
style.css
··· 1 + body { 2 + margin: 0; 3 + font-family: "Courier", monospace; 4 + color: #555; 5 + background: #fff; 6 + } 7 + p { 8 + margin: 0.5rem 0; 9 + } 10 + h3 { 11 + margin-top: 2rem; 12 + margin-bottom: 0px; 13 + } 14 + a { 15 + color: #555; 16 + } 17 + 18 + .footer { 19 + text-align: center; 20 + padding: 50px 0 70px; 21 + } 22 + 23 + .containers { 24 + display: flex; 25 + flex-wrap: wrap; 26 + gap: 1vw; 27 + padding: 3vw; 28 + box-sizing: border-box; 29 + } 30 + 31 + .container { 32 + width: 30vw; 33 + height: 30vw; 34 + background: #fefdfd; 35 + border: 0px; 36 + box-sizing: border-box; 37 + overflow: hidden; 38 + padding: 10px; 39 + position: relative; 40 + } 41 + 42 + .container a { 43 + display: flex; 44 + justify-content: center; 45 + align-items: center; 46 + text-align: center; 47 + width: 100%; 48 + height: 100%; 49 + color: inherit; 50 + text-decoration: none; 51 + cursor: pointer; 52 + } 53 + 54 + .container span { 55 + display: block; 56 + width: 100%; 57 + word-break: break-word; 58 + line-height: 1.2; 59 + } 60 + .local-container { 61 + box-shadow: 0 0 4px 4px rgba(94, 255, 180, 0.756); /* glowing green */ 62 + } 63 + 64 + .topbar { 65 + width: 100%; 66 + min-height: 57px; 67 + padding: 10px 20px; 68 + background-color: #ffffff; 69 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 70 + position: sticky; 71 + top: 0; 72 + z-index: 10; 73 + display: flex; 74 + box-sizing: border-box; 75 + align-items: center; 76 + color: #555; 77 + } 78 + #headerTitle { 79 + color: #555; 80 + text-decoration: none; 81 + margin-right: 1rem; 82 + white-space: nowrap; 83 + } 84 + 85 + #whatsThis { 86 + cursor: pointer; 87 + text-decoration: underline; 88 + } 89 + 90 + #searchInput { 91 + width: 140px; 92 + margin-left: 1vw; 93 + } 94 + 95 + hr { 96 + border: none; 97 + height: 2vw; 98 + margin: 0; 99 + visibility: hidden; 100 + } 101 + 102 + .modal-overlay { 103 + position: fixed; 104 + top: 0; 105 + left: 0; 106 + width: 100%; 107 + height: 100%; 108 + background: rgba(0, 0, 0, 0.4); 109 + display: flex; 110 + justify-content: center; 111 + align-items: flex-start; /* aligns modal to top */ 112 + padding: 40px 20px; /* top/bottom space */ 113 + overflow-y: auto; /* scrolls if modal content is too tall */ 114 + z-index: 100; 115 + } 116 + 117 + .modal-content { 118 + width: 70%; 119 + max-width: 1000px; 120 + background: white; 121 + padding: 30px; 122 + border-radius: 10px; 123 + box-shadow: 0 0 30px rgba(0, 0, 0, 0.2); 124 + box-sizing: border-box; 125 + max-height: calc( 126 + 100vh - 80px 127 + ); /* ensures modal never exceeds screen height */ 128 + overflow-y: auto; /* scroll inside modal if needed */ 129 + } 130 + 131 + .hidden { 132 + display: none; 133 + } 134 + 135 + .param-dialog { 136 + padding: 1.5rem; 137 + border: none; 138 + border-radius: 10px; 139 + width: 50%; 140 + font-family: "Courier", monospace; 141 + } 142 + 143 + .param-form { 144 + display: flex; 145 + flex-direction: column; 146 + gap: 1rem; 147 + } 148 + 149 + .param-group { 150 + display: flex; 151 + flex-direction: column; 152 + } 153 + 154 + .param-label { 155 + font-weight: bold; 156 + margin-bottom: 0.3rem; 157 + color: #555; 158 + } 159 + 160 + input { 161 + padding: 0.5rem; 162 + border: 1px solid #ccc; 163 + border-radius: 6px; 164 + font-size: 14px; 165 + font-family: "Courier", monospace; 166 + } 167 + 168 + .param-menu { 169 + display: flex; 170 + justify-content: space-between; 171 + align-items: center; 172 + padding: 0; 173 + margin-top: 12px; 174 + } 175 + 176 + .menu-left { 177 + flex: 1; 178 + } 179 + 180 + .menu-right { 181 + display: flex; 182 + gap: 12px; 183 + } 184 + 185 + .export-link { 186 + font-size: 0.9rem; 187 + color: #0077cc; 188 + text-decoration: underline; 189 + background: none; 190 + border: none; 191 + padding: 6px 0; 192 + cursor: pointer; 193 + } 194 + 195 + .export-link:hover { 196 + text-decoration: none; 197 + } 198 + 199 + .param-btn { 200 + border: none; 201 + border-radius: 5px; 202 + cursor: pointer; 203 + margin: 0 0.5vw 0 0; 204 + padding: 8px 16px 8px; 205 + font-size: 12px; 206 + color: #555; 207 + white-space: nowrap; 208 + font-family: "Courier", monospace; 209 + } 210 + 211 + .param-btn:hover { 212 + background: #e0e0e0; 213 + } 214 + 215 + .param-btn.cancel { 216 + background: #eee; 217 + color: #555; 218 + } 219 + 220 + .param-btn.dark { 221 + background: #555; 222 + color: white; 223 + } 224 + 225 + .tags-wrapper { 226 + display: flex; 227 + flex-wrap: wrap; 228 + gap: 0.3vw; 229 + margin-top: -1vw; 230 + } 231 + 232 + .tag-style { 233 + font-family: "Courier", monospace; 234 + font-size: 1.3vw; 235 + padding: 0 6px; 236 + opacity: 0.8; 237 + cursor: pointer; 238 + } 239 + .tag-style:hover { 240 + border: 1px solid currentColor; 241 + } 242 + 243 + .delete-btn { 244 + position: absolute; 245 + opacity: 0; 246 + 247 + bottom: 8px; 248 + right: 8px; 249 + z-index: 2; 250 + color: inherit; 251 + border: 1px solid transparent; 252 + background-color: transparent; 253 + font-size: 1vw; 254 + font-family: "Courier", monospace; 255 + } 256 + .container:hover .delete-btn { 257 + opacity: 1; 258 + } 259 + 260 + .delete-btn:hover { 261 + font-weight: bold; 262 + cursor: pointer; 263 + border: 1px solid currentColor; 264 + } 265 + 266 + .param-btn:disabled { 267 + opacity: 0.6; 268 + cursor: not-allowed; 269 + } 270 + 271 + 272 + .page { 273 + padding: 20px 20vw; 274 + } 275 + 276 + pre { 277 + overflow-x: auto; 278 + padding: 1em; 279 + border: 1px solid #ccc; 280 + background: #f9f9f9; 281 + border-radius: 6px; 282 + font-family: monospace; 283 + white-space: pre; 284 + } 285 + 286 + pre code { 287 + display: block; 288 + max-width: 100%; 289 + box-sizing: border-box; 290 + } 291 + 292 + textarea { 293 + width: 100%; 294 + height: 200px; 295 + padding: 12px; 296 + font-size: 16px; 297 + font-family: inherit; /* assumes same font as input */ 298 + border: 1px solid #ccc; 299 + border-radius: 6px; 300 + background-color: white; 301 + box-sizing: border-box; 302 + resize: vertical; 303 + margin: 1em 0; 304 + } 305 + 306 + .anchor-target { 307 + scroll-margin-top: 80px; /* adjust based on your sticky topbar height */ 308 + } 309 + 310 + 311 + 312 + @media (max-width: 600px) { 313 + .container { 314 + width: 46vw; 315 + height: 46vw; 316 + } 317 + 318 + .btn-text { 319 + display: none; 320 + } 321 + 322 + .page{ 323 + padding: 20px 20px; 324 + } 325 + } 326 + 327 +
+55
transfer_page.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 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" /> 13 + </head> 14 + 15 + <body> 16 + <div class="topbar"> 17 + <div style="flex-grow: 1"> 18 + <b><a id="headerTitle" href="/">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 + 55 + </html>