experiments in a post-browser web
10
fork

Configure Feed

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

feat(mobile): offline reading, webview nav tracking, configurable tag settings

- Add readability-rust reader mode for offline content storage
- Track webview navigations as from:history items via Swift FFI
- Add configurable todo/done, reading list, and offline tag pairs
- Add eye icon for viewing offline reader-mode content in webview
- Tag desktop trackNavigation URLs with from:history + migration
- Add inline HTML webview FFI for loading stored reader content

+1262 -114
+53
backend/electron/datastore.ts
··· 373 373 migrateUnformattedPhoneEntities(); 374 374 migrateLowQualityEntities(); 375 375 migrateInvalidPersonEntities(); 376 + migrateTagNavigationHistory(); 376 377 dropLegacyAddressTables(); 377 378 378 379 // Validate schema against canonical definition ··· 2008 2009 * Drop legacy address/visit/address_tags tables. 2009 2010 * Data was already migrated to items/item_visits/item_tags by earlier migrations. 2010 2011 */ 2012 + /** 2013 + * Tag existing URL items that have visits but no tags with 'from:history'. 2014 + * These are navigation-tracked URLs that were created before we started 2015 + * auto-tagging them. Items with user-added tags are left alone. 2016 + */ 2017 + function migrateTagNavigationHistory(): void { 2018 + if (!db) return; 2019 + 2020 + const MIGRATION_ID = 'tag_navigation_history_v1'; 2021 + 2022 + const migrationRecord = db.prepare('SELECT * FROM migrations WHERE id = ?').get(MIGRATION_ID) as { status: string } | undefined; 2023 + if (migrationRecord && migrationRecord.status === 'complete') { 2024 + return; 2025 + } 2026 + 2027 + // Find URL items that have visits (navigation-tracked) but no tags at all 2028 + const untaggedNavItems = db.prepare(` 2029 + SELECT DISTINCT i.id FROM items i 2030 + INNER JOIN item_visits iv ON i.id = iv.itemId 2031 + LEFT JOIN item_tags it ON i.id = it.itemId 2032 + WHERE i.type = 'url' AND i.deletedAt = 0 AND it.itemId IS NULL 2033 + `).all() as { id: string }[]; 2034 + 2035 + if (untaggedNavItems.length > 0) { 2036 + const timestamp = now(); 2037 + // Get or create the from:history tag 2038 + const historyTagRow = db.prepare('SELECT id FROM tags WHERE name = ?').get('from:history') as { id: string } | undefined; 2039 + let historyTagId: string; 2040 + if (historyTagRow) { 2041 + historyTagId = historyTagRow.id; 2042 + } else { 2043 + historyTagId = generateId('tag'); 2044 + db.prepare( 2045 + 'INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 0, ?, 0, ?, ?)' 2046 + ).run(historyTagId, 'from:history', timestamp, timestamp, timestamp); 2047 + } 2048 + 2049 + const insertStmt = db.prepare('INSERT OR IGNORE INTO item_tags (itemId, tagId, createdAt) VALUES (?, ?, ?)'); 2050 + for (const { id } of untaggedNavItems) { 2051 + insertStmt.run(id, historyTagId, timestamp); 2052 + } 2053 + 2054 + DEBUG && console.log('main', `Tagged ${untaggedNavItems.length} navigation URLs with from:history`); 2055 + } 2056 + 2057 + db.prepare('INSERT OR REPLACE INTO migrations (id, status, completedAt) VALUES (?, ?, ?)').run(MIGRATION_ID, 'complete', Date.now()); 2058 + } 2059 + 2011 2060 function dropLegacyAddressTables(): void { 2012 2061 if (!db) return; 2013 2062 ··· 3303 3352 parsed.domain, 3304 3353 options.favicon || '' 3305 3354 ); 3355 + 3356 + // Tag navigation-created items as history so they're hidden from default views 3357 + const { tag: historyTag } = getOrCreateTag('from:history'); 3358 + tagItem(itemId, historyTag.id); 3306 3359 } 3307 3360 3308 3361 // Record the visit
+324 -75
backend/tauri-mobile/src-tauri/Cargo.lock
··· 15 15 checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 16 16 dependencies = [ 17 17 "cfg-if", 18 + "getrandom 0.3.4", 18 19 "once_cell", 19 20 "version_check", 20 21 "zerocopy", ··· 54 55 ] 55 56 56 57 [[package]] 58 + name = "anstream" 59 + version = "0.6.21" 60 + source = "registry+https://github.com/rust-lang/crates.io-index" 61 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 62 + dependencies = [ 63 + "anstyle", 64 + "anstyle-parse", 65 + "anstyle-query", 66 + "anstyle-wincon", 67 + "colorchoice", 68 + "is_terminal_polyfill", 69 + "utf8parse", 70 + ] 71 + 72 + [[package]] 73 + name = "anstyle" 74 + version = "1.0.13" 75 + source = "registry+https://github.com/rust-lang/crates.io-index" 76 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 77 + 78 + [[package]] 79 + name = "anstyle-parse" 80 + version = "0.2.7" 81 + source = "registry+https://github.com/rust-lang/crates.io-index" 82 + checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 83 + dependencies = [ 84 + "utf8parse", 85 + ] 86 + 87 + [[package]] 88 + name = "anstyle-query" 89 + version = "1.1.5" 90 + source = "registry+https://github.com/rust-lang/crates.io-index" 91 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 92 + dependencies = [ 93 + "windows-sys 0.61.2", 94 + ] 95 + 96 + [[package]] 97 + name = "anstyle-wincon" 98 + version = "3.0.11" 99 + source = "registry+https://github.com/rust-lang/crates.io-index" 100 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 101 + dependencies = [ 102 + "anstyle", 103 + "once_cell_polyfill", 104 + "windows-sys 0.61.2", 105 + ] 106 + 107 + [[package]] 57 108 name = "anyhow" 58 - version = "1.0.101" 109 + version = "1.0.102" 59 110 source = "registry+https://github.com/rust-lang/crates.io-index" 60 - checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" 111 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 61 112 62 113 [[package]] 63 114 name = "async-broadcast" ··· 152 203 dependencies = [ 153 204 "proc-macro2", 154 205 "quote", 155 - "syn 2.0.116", 206 + "syn 2.0.117", 156 207 ] 157 208 158 209 [[package]] ··· 187 238 dependencies = [ 188 239 "proc-macro2", 189 240 "quote", 190 - "syn 2.0.116", 241 + "syn 2.0.117", 191 242 ] 192 243 193 244 [[package]] ··· 306 357 307 358 [[package]] 308 359 name = "bumpalo" 309 - version = "3.20.0" 360 + version = "3.20.2" 310 361 source = "registry+https://github.com/rust-lang/crates.io-index" 311 - checksum = "c81d250916401487680ed13b8b675660281dcfc3ab0121fe44c94bcab9eae2fb" 362 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 312 363 313 364 [[package]] 314 365 name = "bytemuck" ··· 462 513 ] 463 514 464 515 [[package]] 516 + name = "clap" 517 + version = "4.5.60" 518 + source = "registry+https://github.com/rust-lang/crates.io-index" 519 + checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" 520 + dependencies = [ 521 + "clap_builder", 522 + "clap_derive", 523 + ] 524 + 525 + [[package]] 526 + name = "clap_builder" 527 + version = "4.5.60" 528 + source = "registry+https://github.com/rust-lang/crates.io-index" 529 + checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" 530 + dependencies = [ 531 + "anstream", 532 + "anstyle", 533 + "clap_lex", 534 + "strsim", 535 + ] 536 + 537 + [[package]] 538 + name = "clap_derive" 539 + version = "4.5.55" 540 + source = "registry+https://github.com/rust-lang/crates.io-index" 541 + checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" 542 + dependencies = [ 543 + "heck 0.5.0", 544 + "proc-macro2", 545 + "quote", 546 + "syn 2.0.117", 547 + ] 548 + 549 + [[package]] 550 + name = "clap_lex" 551 + version = "1.0.0" 552 + source = "registry+https://github.com/rust-lang/crates.io-index" 553 + checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" 554 + 555 + [[package]] 556 + name = "colorchoice" 557 + version = "1.0.4" 558 + source = "registry+https://github.com/rust-lang/crates.io-index" 559 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 560 + 561 + [[package]] 465 562 name = "combine" 466 563 version = "4.6.7" 467 564 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 597 694 ] 598 695 599 696 [[package]] 697 + name = "cssparser" 698 + version = "0.31.2" 699 + source = "registry+https://github.com/rust-lang/crates.io-index" 700 + checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" 701 + dependencies = [ 702 + "cssparser-macros", 703 + "dtoa-short", 704 + "itoa", 705 + "phf 0.11.3", 706 + "smallvec", 707 + ] 708 + 709 + [[package]] 600 710 name = "cssparser-macros" 601 711 version = "0.6.1" 602 712 source = "registry+https://github.com/rust-lang/crates.io-index" 603 713 checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" 604 714 dependencies = [ 605 715 "quote", 606 - "syn 2.0.116", 716 + "syn 2.0.117", 607 717 ] 608 718 609 719 [[package]] ··· 613 723 checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" 614 724 dependencies = [ 615 725 "quote", 616 - "syn 2.0.116", 726 + "syn 2.0.117", 617 727 ] 618 728 619 729 [[package]] ··· 637 747 "proc-macro2", 638 748 "quote", 639 749 "strsim", 640 - "syn 2.0.116", 750 + "syn 2.0.117", 641 751 ] 642 752 643 753 [[package]] ··· 648 758 dependencies = [ 649 759 "darling_core", 650 760 "quote", 651 - "syn 2.0.116", 761 + "syn 2.0.117", 652 762 ] 653 763 654 764 [[package]] 655 765 name = "deranged" 656 - version = "0.5.6" 766 + version = "0.5.8" 657 767 source = "registry+https://github.com/rust-lang/crates.io-index" 658 - checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" 768 + checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" 659 769 dependencies = [ 660 770 "powerfmt", 661 771 "serde_core", ··· 671 781 "proc-macro2", 672 782 "quote", 673 783 "rustc_version", 674 - "syn 2.0.116", 784 + "syn 2.0.117", 675 785 ] 676 786 677 787 [[package]] ··· 729 839 dependencies = [ 730 840 "proc-macro2", 731 841 "quote", 732 - "syn 2.0.116", 842 + "syn 2.0.117", 733 843 ] 734 844 735 845 [[package]] ··· 752 862 dependencies = [ 753 863 "proc-macro2", 754 864 "quote", 755 - "syn 2.0.116", 865 + "syn 2.0.117", 756 866 ] 757 867 758 868 [[package]] ··· 790 900 version = "1.0.20" 791 901 source = "registry+https://github.com/rust-lang/crates.io-index" 792 902 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 903 + 904 + [[package]] 905 + name = "ego-tree" 906 + version = "0.6.3" 907 + source = "registry+https://github.com/rust-lang/crates.io-index" 908 + checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" 793 909 794 910 [[package]] 795 911 name = "embed-resource" ··· 835 951 dependencies = [ 836 952 "proc-macro2", 837 953 "quote", 838 - "syn 2.0.116", 954 + "syn 2.0.117", 839 955 ] 840 956 841 957 [[package]] ··· 969 1085 dependencies = [ 970 1086 "proc-macro2", 971 1087 "quote", 972 - "syn 2.0.116", 1088 + "syn 2.0.117", 973 1089 ] 974 1090 975 1091 [[package]] ··· 1050 1166 dependencies = [ 1051 1167 "proc-macro2", 1052 1168 "quote", 1053 - "syn 2.0.116", 1169 + "syn 2.0.117", 1054 1170 ] 1055 1171 1056 1172 [[package]] ··· 1200 1316 ] 1201 1317 1202 1318 [[package]] 1319 + name = "getopts" 1320 + version = "0.2.24" 1321 + source = "registry+https://github.com/rust-lang/crates.io-index" 1322 + checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" 1323 + dependencies = [ 1324 + "unicode-width", 1325 + ] 1326 + 1327 + [[package]] 1203 1328 name = "getrandom" 1204 1329 version = "0.1.16" 1205 1330 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1316 1441 "proc-macro-error", 1317 1442 "proc-macro2", 1318 1443 "quote", 1319 - "syn 2.0.116", 1444 + "syn 2.0.117", 1320 1445 ] 1321 1446 1322 1447 [[package]] ··· 1395 1520 "proc-macro-error", 1396 1521 "proc-macro2", 1397 1522 "quote", 1398 - "syn 2.0.116", 1523 + "syn 2.0.117", 1399 1524 ] 1400 1525 1401 1526 [[package]] ··· 1463 1588 1464 1589 [[package]] 1465 1590 name = "html5ever" 1591 + version = "0.26.0" 1592 + source = "registry+https://github.com/rust-lang/crates.io-index" 1593 + checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" 1594 + dependencies = [ 1595 + "log", 1596 + "mac", 1597 + "markup5ever 0.11.0", 1598 + "proc-macro2", 1599 + "quote", 1600 + "syn 1.0.109", 1601 + ] 1602 + 1603 + [[package]] 1604 + name = "html5ever" 1466 1605 version = "0.29.1" 1467 1606 source = "registry+https://github.com/rust-lang/crates.io-index" 1468 1607 checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" 1469 1608 dependencies = [ 1470 1609 "log", 1471 1610 "mac", 1472 - "markup5ever", 1611 + "markup5ever 0.14.1", 1473 1612 "match_token", 1474 1613 ] 1475 1614 ··· 1789 1928 ] 1790 1929 1791 1930 [[package]] 1931 + name = "is_terminal_polyfill" 1932 + version = "1.70.2" 1933 + source = "registry+https://github.com/rust-lang/crates.io-index" 1934 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 1935 + 1936 + [[package]] 1792 1937 name = "itoa" 1793 1938 version = "1.0.17" 1794 1939 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1841 1986 1842 1987 [[package]] 1843 1988 name = "js-sys" 1844 - version = "0.3.85" 1989 + version = "0.3.88" 1845 1990 source = "registry+https://github.com/rust-lang/crates.io-index" 1846 - checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" 1991 + checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" 1847 1992 dependencies = [ 1848 1993 "once_cell", 1849 1994 "wasm-bindgen", ··· 1888 2033 source = "registry+https://github.com/rust-lang/crates.io-index" 1889 2034 checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" 1890 2035 dependencies = [ 1891 - "cssparser", 1892 - "html5ever", 2036 + "cssparser 0.29.6", 2037 + "html5ever 0.29.1", 1893 2038 "indexmap 2.13.0", 1894 - "selectors", 2039 + "selectors 0.24.0", 1895 2040 ] 1896 2041 1897 2042 [[package]] ··· 2008 2153 2009 2154 [[package]] 2010 2155 name = "markup5ever" 2156 + version = "0.11.0" 2157 + source = "registry+https://github.com/rust-lang/crates.io-index" 2158 + checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" 2159 + dependencies = [ 2160 + "log", 2161 + "phf 0.10.1", 2162 + "phf_codegen 0.10.0", 2163 + "string_cache", 2164 + "string_cache_codegen", 2165 + "tendril", 2166 + ] 2167 + 2168 + [[package]] 2169 + name = "markup5ever" 2011 2170 version = "0.14.1" 2012 2171 source = "registry+https://github.com/rust-lang/crates.io-index" 2013 2172 checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" ··· 2028 2187 dependencies = [ 2029 2188 "proc-macro2", 2030 2189 "quote", 2031 - "syn 2.0.116", 2190 + "syn 2.0.117", 2032 2191 ] 2033 2192 2034 2193 [[package]] ··· 2176 2335 "proc-macro-crate 3.4.0", 2177 2336 "proc-macro2", 2178 2337 "quote", 2179 - "syn 2.0.116", 2338 + "syn 2.0.117", 2180 2339 ] 2181 2340 2182 2341 [[package]] ··· 2398 2557 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 2399 2558 2400 2559 [[package]] 2560 + name = "once_cell_polyfill" 2561 + version = "1.70.2" 2562 + source = "registry+https://github.com/rust-lang/crates.io-index" 2563 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 2564 + 2565 + [[package]] 2401 2566 name = "open" 2402 2567 version = "5.3.3" 2403 2568 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2493 2658 "cc", 2494 2659 "chrono", 2495 2660 "libc", 2661 + "readability-rust", 2496 2662 "regex", 2497 2663 "reqwest 0.12.28", 2498 2664 "rusqlite", ··· 2553 2719 2554 2720 [[package]] 2555 2721 name = "phf_codegen" 2722 + version = "0.10.0" 2723 + source = "registry+https://github.com/rust-lang/crates.io-index" 2724 + checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" 2725 + dependencies = [ 2726 + "phf_generator 0.10.0", 2727 + "phf_shared 0.10.0", 2728 + ] 2729 + 2730 + [[package]] 2731 + name = "phf_codegen" 2556 2732 version = "0.11.3" 2557 2733 source = "registry+https://github.com/rust-lang/crates.io-index" 2558 2734 checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" ··· 2615 2791 "phf_shared 0.11.3", 2616 2792 "proc-macro2", 2617 2793 "quote", 2618 - "syn 2.0.116", 2794 + "syn 2.0.117", 2619 2795 ] 2620 2796 2621 2797 [[package]] ··· 2751 2927 checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2752 2928 dependencies = [ 2753 2929 "proc-macro2", 2754 - "syn 2.0.116", 2930 + "syn 2.0.117", 2755 2931 ] 2756 2932 2757 2933 [[package]] ··· 3018 3194 checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" 3019 3195 3020 3196 [[package]] 3197 + name = "readability-rust" 3198 + version = "0.1.0" 3199 + source = "registry+https://github.com/rust-lang/crates.io-index" 3200 + checksum = "a1c18c133365854cd0b5f899503fc22e3533270481cc09285ec0ceff2c233fee" 3201 + dependencies = [ 3202 + "chrono", 3203 + "clap", 3204 + "html5ever 0.26.0", 3205 + "regex", 3206 + "scraper", 3207 + "serde", 3208 + "serde_json", 3209 + "thiserror 1.0.69", 3210 + "url", 3211 + ] 3212 + 3213 + [[package]] 3021 3214 name = "redox_syscall" 3022 3215 version = "0.5.18" 3023 3216 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3054 3247 dependencies = [ 3055 3248 "proc-macro2", 3056 3249 "quote", 3057 - "syn 2.0.116", 3250 + "syn 2.0.117", 3058 3251 ] 3059 3252 3060 3253 [[package]] ··· 3318 3511 "proc-macro2", 3319 3512 "quote", 3320 3513 "serde_derive_internals", 3321 - "syn 2.0.116", 3514 + "syn 2.0.117", 3322 3515 ] 3323 3516 3324 3517 [[package]] ··· 3328 3521 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 3329 3522 3330 3523 [[package]] 3524 + name = "scraper" 3525 + version = "0.18.1" 3526 + source = "registry+https://github.com/rust-lang/crates.io-index" 3527 + checksum = "585480e3719b311b78a573db1c9d9c4c1f8010c2dee4cc59c2efe58ea4dbc3e1" 3528 + dependencies = [ 3529 + "ahash", 3530 + "cssparser 0.31.2", 3531 + "ego-tree", 3532 + "getopts", 3533 + "html5ever 0.26.0", 3534 + "once_cell", 3535 + "selectors 0.25.0", 3536 + "tendril", 3537 + ] 3538 + 3539 + [[package]] 3331 3540 name = "selectors" 3332 3541 version = "0.24.0" 3333 3542 source = "registry+https://github.com/rust-lang/crates.io-index" 3334 3543 checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" 3335 3544 dependencies = [ 3336 3545 "bitflags 1.3.2", 3337 - "cssparser", 3546 + "cssparser 0.29.6", 3338 3547 "derive_more", 3339 3548 "fxhash", 3340 3549 "log", 3341 3550 "phf 0.8.0", 3342 3551 "phf_codegen 0.8.0", 3343 3552 "precomputed-hash", 3344 - "servo_arc", 3553 + "servo_arc 0.2.0", 3554 + "smallvec", 3555 + ] 3556 + 3557 + [[package]] 3558 + name = "selectors" 3559 + version = "0.25.0" 3560 + source = "registry+https://github.com/rust-lang/crates.io-index" 3561 + checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" 3562 + dependencies = [ 3563 + "bitflags 2.11.0", 3564 + "cssparser 0.31.2", 3565 + "derive_more", 3566 + "fxhash", 3567 + "log", 3568 + "new_debug_unreachable", 3569 + "phf 0.10.1", 3570 + "phf_codegen 0.10.0", 3571 + "precomputed-hash", 3572 + "servo_arc 0.3.0", 3345 3573 "smallvec", 3346 3574 ] 3347 3575 ··· 3394 3622 dependencies = [ 3395 3623 "proc-macro2", 3396 3624 "quote", 3397 - "syn 2.0.116", 3625 + "syn 2.0.117", 3398 3626 ] 3399 3627 3400 3628 [[package]] ··· 3405 3633 dependencies = [ 3406 3634 "proc-macro2", 3407 3635 "quote", 3408 - "syn 2.0.116", 3636 + "syn 2.0.117", 3409 3637 ] 3410 3638 3411 3639 [[package]] ··· 3429 3657 dependencies = [ 3430 3658 "proc-macro2", 3431 3659 "quote", 3432 - "syn 2.0.116", 3660 + "syn 2.0.117", 3433 3661 ] 3434 3662 3435 3663 [[package]] ··· 3490 3718 "darling", 3491 3719 "proc-macro2", 3492 3720 "quote", 3493 - "syn 2.0.116", 3721 + "syn 2.0.117", 3494 3722 ] 3495 3723 3496 3724 [[package]] ··· 3512 3740 dependencies = [ 3513 3741 "proc-macro2", 3514 3742 "quote", 3515 - "syn 2.0.116", 3743 + "syn 2.0.117", 3516 3744 ] 3517 3745 3518 3746 [[package]] ··· 3526 3754 ] 3527 3755 3528 3756 [[package]] 3757 + name = "servo_arc" 3758 + version = "0.3.0" 3759 + source = "registry+https://github.com/rust-lang/crates.io-index" 3760 + checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" 3761 + dependencies = [ 3762 + "stable_deref_trait", 3763 + ] 3764 + 3765 + [[package]] 3529 3766 name = "sha2" 3530 3767 version = "0.10.9" 3531 3768 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3707 3944 3708 3945 [[package]] 3709 3946 name = "syn" 3710 - version = "2.0.116" 3947 + version = "2.0.117" 3711 3948 source = "registry+https://github.com/rust-lang/crates.io-index" 3712 - checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" 3949 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 3713 3950 dependencies = [ 3714 3951 "proc-macro2", 3715 3952 "quote", ··· 3733 3970 dependencies = [ 3734 3971 "proc-macro2", 3735 3972 "quote", 3736 - "syn 2.0.116", 3973 + "syn 2.0.117", 3737 3974 ] 3738 3975 3739 3976 [[package]] ··· 3797 4034 dependencies = [ 3798 4035 "proc-macro2", 3799 4036 "quote", 3800 - "syn 2.0.116", 4037 + "syn 2.0.117", 3801 4038 ] 3802 4039 3803 4040 [[package]] ··· 3897 4134 "serde", 3898 4135 "serde_json", 3899 4136 "sha2", 3900 - "syn 2.0.116", 4137 + "syn 2.0.117", 3901 4138 "tauri-utils", 3902 4139 "thiserror 2.0.18", 3903 4140 "time", ··· 3915 4152 "heck 0.5.0", 3916 4153 "proc-macro2", 3917 4154 "quote", 3918 - "syn 2.0.116", 4155 + "syn 2.0.117", 3919 4156 "tauri-codegen", 3920 4157 "tauri-utils", 3921 4158 ] ··· 4023 4260 "ctor", 4024 4261 "dunce", 4025 4262 "glob", 4026 - "html5ever", 4263 + "html5ever 0.29.1", 4027 4264 "http", 4028 4265 "infer", 4029 4266 "json-patch", ··· 4110 4347 dependencies = [ 4111 4348 "proc-macro2", 4112 4349 "quote", 4113 - "syn 2.0.116", 4350 + "syn 2.0.117", 4114 4351 ] 4115 4352 4116 4353 [[package]] ··· 4121 4358 dependencies = [ 4122 4359 "proc-macro2", 4123 4360 "quote", 4124 - "syn 2.0.116", 4361 + "syn 2.0.117", 4125 4362 ] 4126 4363 4127 4364 [[package]] ··· 4377 4614 dependencies = [ 4378 4615 "proc-macro2", 4379 4616 "quote", 4380 - "syn 2.0.116", 4617 + "syn 2.0.117", 4381 4618 ] 4382 4619 4383 4620 [[package]] ··· 4494 4731 checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 4495 4732 4496 4733 [[package]] 4734 + name = "unicode-width" 4735 + version = "0.2.2" 4736 + source = "registry+https://github.com/rust-lang/crates.io-index" 4737 + checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 4738 + 4739 + [[package]] 4497 4740 name = "unicode-xid" 4498 4741 version = "0.2.6" 4499 4742 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4541 4784 version = "1.0.4" 4542 4785 source = "registry+https://github.com/rust-lang/crates.io-index" 4543 4786 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 4787 + 4788 + [[package]] 4789 + name = "utf8parse" 4790 + version = "0.2.2" 4791 + source = "registry+https://github.com/rust-lang/crates.io-index" 4792 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 4544 4793 4545 4794 [[package]] 4546 4795 name = "uuid" ··· 4643 4892 4644 4893 [[package]] 4645 4894 name = "wasm-bindgen" 4646 - version = "0.2.108" 4895 + version = "0.2.111" 4647 4896 source = "registry+https://github.com/rust-lang/crates.io-index" 4648 - checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" 4897 + checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" 4649 4898 dependencies = [ 4650 4899 "cfg-if", 4651 4900 "once_cell", ··· 4656 4905 4657 4906 [[package]] 4658 4907 name = "wasm-bindgen-futures" 4659 - version = "0.4.58" 4908 + version = "0.4.61" 4660 4909 source = "registry+https://github.com/rust-lang/crates.io-index" 4661 - checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" 4910 + checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b" 4662 4911 dependencies = [ 4663 4912 "cfg-if", 4664 4913 "futures-util", ··· 4670 4919 4671 4920 [[package]] 4672 4921 name = "wasm-bindgen-macro" 4673 - version = "0.2.108" 4922 + version = "0.2.111" 4674 4923 source = "registry+https://github.com/rust-lang/crates.io-index" 4675 - checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" 4924 + checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" 4676 4925 dependencies = [ 4677 4926 "quote", 4678 4927 "wasm-bindgen-macro-support", ··· 4680 4929 4681 4930 [[package]] 4682 4931 name = "wasm-bindgen-macro-support" 4683 - version = "0.2.108" 4932 + version = "0.2.111" 4684 4933 source = "registry+https://github.com/rust-lang/crates.io-index" 4685 - checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" 4934 + checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" 4686 4935 dependencies = [ 4687 4936 "bumpalo", 4688 4937 "proc-macro2", 4689 4938 "quote", 4690 - "syn 2.0.116", 4939 + "syn 2.0.117", 4691 4940 "wasm-bindgen-shared", 4692 4941 ] 4693 4942 4694 4943 [[package]] 4695 4944 name = "wasm-bindgen-shared" 4696 - version = "0.2.108" 4945 + version = "0.2.111" 4697 4946 source = "registry+https://github.com/rust-lang/crates.io-index" 4698 - checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" 4947 + checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" 4699 4948 dependencies = [ 4700 4949 "unicode-ident", 4701 4950 ] ··· 4749 4998 4750 4999 [[package]] 4751 5000 name = "web-sys" 4752 - version = "0.3.85" 5001 + version = "0.3.88" 4753 5002 source = "registry+https://github.com/rust-lang/crates.io-index" 4754 - checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" 5003 + checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" 4755 5004 dependencies = [ 4756 5005 "js-sys", 4757 5006 "wasm-bindgen", ··· 4842 5091 dependencies = [ 4843 5092 "proc-macro2", 4844 5093 "quote", 4845 - "syn 2.0.116", 5094 + "syn 2.0.117", 4846 5095 ] 4847 5096 4848 5097 [[package]] ··· 4969 5218 dependencies = [ 4970 5219 "proc-macro2", 4971 5220 "quote", 4972 - "syn 2.0.116", 5221 + "syn 2.0.117", 4973 5222 ] 4974 5223 4975 5224 [[package]] ··· 4980 5229 dependencies = [ 4981 5230 "proc-macro2", 4982 5231 "quote", 4983 - "syn 2.0.116", 5232 + "syn 2.0.117", 4984 5233 ] 4985 5234 4986 5235 [[package]] ··· 5348 5597 "heck 0.5.0", 5349 5598 "indexmap 2.13.0", 5350 5599 "prettyplease", 5351 - "syn 2.0.116", 5600 + "syn 2.0.117", 5352 5601 "wasm-metadata", 5353 5602 "wit-bindgen-core", 5354 5603 "wit-component", ··· 5364 5613 "prettyplease", 5365 5614 "proc-macro2", 5366 5615 "quote", 5367 - "syn 2.0.116", 5616 + "syn 2.0.117", 5368 5617 "wit-bindgen-core", 5369 5618 "wit-bindgen-rust", 5370 5619 ] ··· 5427 5676 "dunce", 5428 5677 "gdkx11", 5429 5678 "gtk", 5430 - "html5ever", 5679 + "html5ever 0.29.1", 5431 5680 "http", 5432 5681 "javascriptcore-rs", 5433 5682 "jni", ··· 5497 5746 dependencies = [ 5498 5747 "proc-macro2", 5499 5748 "quote", 5500 - "syn 2.0.116", 5749 + "syn 2.0.117", 5501 5750 "synstructure", 5502 5751 ] 5503 5752 ··· 5545 5794 "proc-macro-crate 3.4.0", 5546 5795 "proc-macro2", 5547 5796 "quote", 5548 - "syn 2.0.116", 5797 + "syn 2.0.117", 5549 5798 "zbus_names", 5550 5799 "zvariant", 5551 5800 "zvariant_utils", ··· 5579 5828 dependencies = [ 5580 5829 "proc-macro2", 5581 5830 "quote", 5582 - "syn 2.0.116", 5831 + "syn 2.0.117", 5583 5832 ] 5584 5833 5585 5834 [[package]] ··· 5599 5848 dependencies = [ 5600 5849 "proc-macro2", 5601 5850 "quote", 5602 - "syn 2.0.116", 5851 + "syn 2.0.117", 5603 5852 "synstructure", 5604 5853 ] 5605 5854 ··· 5639 5888 dependencies = [ 5640 5889 "proc-macro2", 5641 5890 "quote", 5642 - "syn 2.0.116", 5891 + "syn 2.0.117", 5643 5892 ] 5644 5893 5645 5894 [[package]] ··· 5671 5920 "proc-macro-crate 3.4.0", 5672 5921 "proc-macro2", 5673 5922 "quote", 5674 - "syn 2.0.116", 5923 + "syn 2.0.117", 5675 5924 "zvariant_utils", 5676 5925 ] 5677 5926 ··· 5684 5933 "proc-macro2", 5685 5934 "quote", 5686 5935 "serde", 5687 - "syn 2.0.116", 5936 + "syn 2.0.117", 5688 5937 "winnow 0.7.14", 5689 5938 ]
+1
backend/tauri-mobile/src-tauri/Cargo.toml
··· 31 31 url = "2" 32 32 regex = "1" 33 33 base64 = "0.22" 34 + readability-rust = "0.1" 34 35 35 36 [features] 36 37 # custom-protocol: use bundled assets instead of dev server (for release builds)
+95 -5
backend/tauri-mobile/src-tauri/gen/apple/Sources/peek-save/WebviewPlugin.swift
··· 1 1 import UIKit 2 2 import WebKit 3 3 4 + // FFI declarations for Rust navigation tracking 5 + @_silgen_name("track_webview_navigation") 6 + func track_webview_navigation(_ url: UnsafePointer<CChar>, _ sourceItemId: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>? 7 + 8 + @_silgen_name("track_navigation_free") 9 + func track_navigation_free(_ ptr: UnsafeMutablePointer<CChar>?) 10 + 4 11 // WKWebView controller for embedded browser 5 12 class EmbeddedWebViewController: UIViewController, WKNavigationDelegate { 6 13 private var webView: WKWebView! 7 14 private var itemId: String = "" 15 + private var originalUrl: String = "" 8 16 9 17 func configure(url: URL, itemId: String) { 10 18 self.itemId = itemId 19 + self.originalUrl = url.absoluteString 11 20 12 21 let config = WKWebViewConfiguration() 13 22 config.allowsInlineMediaPlayback = true ··· 83 92 84 93 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 85 94 navigationItem.title = webView.title ?? webView.url?.host 86 - if let url = webView.url?.absoluteString { 87 - print("[WebviewPlugin] Navigated to: \(url) for item: \(itemId)") 95 + guard let url = webView.url?.absoluteString else { return } 96 + print("[WebviewPlugin] Navigated to: \(url) for item: \(itemId)") 97 + 98 + // Track navigation to new URLs as history 99 + if url != originalUrl { 100 + url.withCString { urlPtr in 101 + itemId.withCString { itemIdPtr in 102 + let resultPtr = track_webview_navigation(urlPtr, itemIdPtr) 103 + if let resultPtr = resultPtr { 104 + let newItemId = String(cString: resultPtr) 105 + if !newItemId.isEmpty { 106 + print("[WebviewPlugin] Tracked navigation, new item: \(newItemId)") 107 + } 108 + track_navigation_free(resultPtr) 109 + } 110 + } 111 + } 88 112 } 89 113 } 90 114 ··· 99 123 100 124 // Navigation delegate for inline webview (must be held strongly) 101 125 class InlineWebViewDelegate: NSObject, WKNavigationDelegate { 126 + var originalUrl: String = "" 127 + var itemId: String = "" 128 + 102 129 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 103 - print("[WebviewPlugin] Inline webview loaded: \(webView.url?.absoluteString ?? "nil")") 130 + guard let url = webView.url?.absoluteString else { return } 131 + print("[WebviewPlugin] Inline webview loaded: \(url)") 132 + 133 + // Track navigation to new URLs as history 134 + if url != originalUrl && !originalUrl.isEmpty { 135 + url.withCString { urlPtr in 136 + itemId.withCString { itemIdPtr in 137 + let resultPtr = track_webview_navigation(urlPtr, itemIdPtr) 138 + if let resultPtr = resultPtr { 139 + let newItemId = String(cString: resultPtr) 140 + if !newItemId.isEmpty { 141 + print("[WebviewPlugin] Inline tracked navigation, new item: \(newItemId)") 142 + } 143 + track_navigation_free(resultPtr) 144 + } 145 + } 146 + } 147 + } 104 148 } 105 149 106 150 func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { ··· 181 225 182 226 // C-compatible function to open inline webview (subview, not modal) 183 227 @_cdecl("webview_plugin_open_inline") 184 - public func webviewPluginOpenInline(urlPtr: UnsafePointer<CChar>, topOffset: Double) -> Bool { 228 + public func webviewPluginOpenInline(urlPtr: UnsafePointer<CChar>, topOffset: Double, itemIdPtr: UnsafePointer<CChar>) -> Bool { 185 229 let urlString = String(cString: urlPtr) 186 - print("[WebviewPlugin] open_inline called with URL: \(urlString), topOffset: \(topOffset)") 230 + let itemId = String(cString: itemIdPtr) 231 + print("[WebviewPlugin] open_inline called with URL: \(urlString), topOffset: \(topOffset), itemId: \(itemId)") 187 232 188 233 guard let url = URL(string: urlString) else { 189 234 print("[WebviewPlugin] Invalid URL: \(urlString)") ··· 269 314 270 315 // Set navigation delegate (held strongly in global var) 271 316 let delegate = InlineWebViewDelegate() 317 + delegate.originalUrl = urlString 318 + delegate.itemId = itemId 272 319 webView.navigationDelegate = delegate 273 320 inlineWebViewDelegate = delegate 274 321 ··· 287 334 currentInlineWebView = webView 288 335 289 336 print("[WebviewPlugin] Inline webview added as subview at topOffset: \(topOffset)") 337 + } 338 + 339 + return true 340 + } 341 + 342 + // C-compatible function to open inline webview with HTML string content (for offline reader mode) 343 + @_cdecl("webview_plugin_open_inline_html") 344 + public func webviewPluginOpenInlineHtml(htmlPtr: UnsafePointer<CChar>, topOffset: Double) -> Bool { 345 + let htmlString = String(cString: htmlPtr) 346 + print("[WebviewPlugin] open_inline_html called, topOffset: \(topOffset), html length: \(htmlString.count)") 347 + 348 + DispatchQueue.main.async { 349 + // Remove any existing inline webview 350 + currentInlineWebView?.removeFromSuperview() 351 + currentInlineWebView = nil 352 + 353 + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 354 + let window = windowScene.windows.first, 355 + let rootView = window.rootViewController?.view else { 356 + print("[WebviewPlugin] ERROR: Could not find root view") 357 + return 358 + } 359 + 360 + let config = WKWebViewConfiguration() 361 + config.allowsInlineMediaPlayback = true 362 + 363 + let webView = WKWebView(frame: .zero, configuration: config) 364 + webView.translatesAutoresizingMaskIntoConstraints = false 365 + 366 + // Add on top of all other subviews 367 + rootView.addSubview(webView) 368 + 369 + NSLayoutConstraint.activate([ 370 + webView.topAnchor.constraint(equalTo: rootView.topAnchor, constant: CGFloat(topOffset)), 371 + webView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), 372 + webView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), 373 + webView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor) 374 + ]) 375 + 376 + webView.loadHTMLString(htmlString, baseURL: nil) 377 + currentInlineWebView = webView 378 + 379 + print("[WebviewPlugin] Inline HTML webview added at topOffset: \(topOffset)") 290 380 } 291 381 292 382 return true
+447 -12
backend/tauri-mobile/src-tauri/src/lib.rs
··· 21 21 extern "C" { 22 22 fn webview_plugin_open(url: *const std::ffi::c_char, item_id: *const std::ffi::c_char) -> bool; 23 23 fn webview_plugin_close(); 24 - fn webview_plugin_open_inline(url: *const std::ffi::c_char, top_offset: f64) -> bool; 24 + fn webview_plugin_open_inline(url: *const std::ffi::c_char, top_offset: f64, item_id: *const std::ffi::c_char) -> bool; 25 + fn webview_plugin_open_inline_html(html: *const std::ffi::c_char, top_offset: f64) -> bool; 25 26 fn webview_plugin_close_inline(); 27 + } 28 + 29 + /// FFI function called by Swift when the user navigates to a new URL in the webview. 30 + /// Creates or finds a URL item, tags it with from:history, and records a visit. 31 + /// Returns the item ID as a C string (caller must free with track_navigation_free). 32 + #[no_mangle] 33 + pub extern "C" fn track_webview_navigation(url_ptr: *const c_char, source_item_id_ptr: *const c_char) -> *mut c_char { 34 + let url = unsafe { CStr::from_ptr(url_ptr) }.to_string_lossy().to_string(); 35 + let source_item_id = unsafe { CStr::from_ptr(source_item_id_ptr) }.to_string_lossy().to_string(); 36 + 37 + println!("[Rust] track_webview_navigation called: url={}, source_item_id={}", url, source_item_id); 38 + 39 + let result = match track_navigation_inner(&url, &source_item_id) { 40 + Ok(item_id) => item_id, 41 + Err(e) => { 42 + println!("[Rust] track_webview_navigation error: {}", e); 43 + String::new() 44 + } 45 + }; 46 + 47 + std::ffi::CString::new(result).unwrap_or_default().into_raw() 48 + } 49 + 50 + /// Free the C string returned by track_webview_navigation 51 + #[no_mangle] 52 + pub extern "C" fn track_navigation_free(ptr: *mut c_char) { 53 + if !ptr.is_null() { 54 + unsafe { let _ = std::ffi::CString::from_raw(ptr); } 55 + } 56 + } 57 + 58 + /// Inner implementation for track_navigation — finds or creates a URL item tagged from:history 59 + fn track_navigation_inner(url: &str, _source_item_id: &str) -> Result<String, String> { 60 + let conn = get_connection()?; 61 + let now = Utc::now().to_rfc3339(); 62 + let url = normalize_url(url); 63 + 64 + // Check if this URL already exists 65 + let url_without_slash = url.strip_suffix('/').unwrap_or(&url); 66 + let existing_id: Option<String> = conn 67 + .query_row( 68 + "SELECT id FROM items WHERE type = 'url' AND (url = ? OR url = ?) AND deleted_at IS NULL", 69 + params![&url, &url_without_slash], 70 + |row| row.get(0), 71 + ) 72 + .ok(); 73 + 74 + let is_new = existing_id.is_none(); 75 + let item_id = if let Some(id) = existing_id { 76 + // Update timestamp 77 + conn.execute( 78 + "UPDATE items SET updated_at = ? WHERE id = ?", 79 + params![&now, &id], 80 + ).map_err(|e| format!("Failed to update item: {}", e))?; 81 + id 82 + } else { 83 + // Create new item 84 + let id = uuid::Uuid::new_v4().to_string(); 85 + let metadata = inject_sync_metadata_for_save(None); 86 + let metadata_json = serde_json::to_string(&metadata).unwrap_or_default(); 87 + conn.execute( 88 + "INSERT INTO items (id, type, url, metadata, created_at, updated_at) VALUES (?, 'url', ?, ?, ?, ?)", 89 + params![&id, &url, &metadata_json, &now, &now], 90 + ).map_err(|e| format!("Failed to insert item: {}", e))?; 91 + id 92 + }; 93 + 94 + // Ensure from:history tag exists and is linked 95 + let tag_id: String = conn 96 + .query_row("SELECT id FROM tags WHERE name = 'from:history'", [], |row| row.get(0)) 97 + .unwrap_or_else(|_| { 98 + let new_id = generate_tag_id(); 99 + let frecency = calculate_frecency(1, &now); 100 + conn.execute( 101 + "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, 'from:history', 1, ?, ?, ?, ?)", 102 + params![&new_id, &now, frecency, &now, &now], 103 + ).ok(); 104 + new_id 105 + }); 106 + 107 + conn.execute( 108 + "INSERT OR IGNORE INTO item_tags (item_id, tag_id, created_at) VALUES (?, ?, ?)", 109 + params![&item_id, &tag_id, &now], 110 + ).map_err(|e| format!("Failed to tag item: {}", e))?; 111 + 112 + // Record the visit 113 + let visit_id = uuid::Uuid::new_v4().to_string(); 114 + let timestamp = Utc::now().timestamp_millis(); 115 + conn.execute( 116 + "INSERT INTO item_visits (id, item_id, timestamp, source, window_type) VALUES (?, ?, ?, 'webview', 'embedded')", 117 + params![&visit_id, &item_id, timestamp], 118 + ).map_err(|e| format!("Failed to record visit: {}", e))?; 119 + 120 + println!("[Rust] Navigation tracked: item_id={}, new={}", item_id, is_new); 121 + Ok(item_id) 26 122 } 27 123 28 124 // Sync version constants — must match backend/version.ts and backend/server/version.js ··· 231 327 sync: SyncSettings, 232 328 #[serde(default = "default_archive_tag")] 233 329 archive_tag: String, 330 + #[serde(default = "default_todo_tag")] 331 + todo_tag: String, 332 + #[serde(default = "default_done_tag")] 333 + done_tag: String, 334 + #[serde(default = "default_readme_tag")] 335 + readme_tag: String, 336 + #[serde(default = "default_read_tag")] 337 + read_tag: String, 338 + #[serde(default = "default_offline_tag")] 339 + offline_tag: String, 234 340 } 235 341 236 342 fn default_archive_tag() -> String { 237 343 "archive".to_string() 238 344 } 239 345 346 + fn default_todo_tag() -> String { 347 + "todo".to_string() 348 + } 349 + 350 + fn default_done_tag() -> String { 351 + "done".to_string() 352 + } 353 + 354 + fn default_readme_tag() -> String { 355 + "readme".to_string() 356 + } 357 + 358 + fn default_read_tag() -> String { 359 + "read".to_string() 360 + } 361 + 362 + fn default_offline_tag() -> String { 363 + "offline".to_string() 364 + } 365 + 240 366 #[derive(Debug, Clone, Serialize, Deserialize)] 241 367 struct ProfileEntry { 242 368 id: String, ··· 574 700 profiles: new_profiles, 575 701 sync, 576 702 archive_tag: default_archive_tag(), 703 + todo_tag: default_todo_tag(), 704 + done_tag: default_done_tag(), 705 + readme_tag: default_readme_tag(), 706 + read_tag: default_read_tag(), 707 + offline_tag: default_offline_tag(), 577 708 } 578 709 } 579 710 ··· 922 1053 ], 923 1054 sync: SyncSettings::default(), 924 1055 archive_tag: default_archive_tag(), 1056 + todo_tag: default_todo_tag(), 1057 + done_tag: default_done_tag(), 1058 + readme_tag: default_readme_tag(), 1059 + read_tag: default_read_tag(), 1060 + offline_tag: default_offline_tag(), 925 1061 } 926 1062 } 927 1063 ··· 1250 1386 key TEXT PRIMARY KEY, 1251 1387 value TEXT NOT NULL 1252 1388 ); 1389 + 1390 + CREATE TABLE IF NOT EXISTS offline_content ( 1391 + item_id TEXT PRIMARY KEY, 1392 + fetched_at TEXT NOT NULL, 1393 + size_bytes INTEGER NOT NULL 1394 + ); 1253 1395 ", 1254 1396 ) { 1255 1397 return Err(format!("Failed to create tables: {}", e)); ··· 1412 1554 ", 1413 1555 ) { 1414 1556 println!("[Rust] Warning: Failed to create item_visits table: {}", e); 1557 + } 1558 + } 1559 + 1560 + // Ensure offline_content index table exists (for offline reading feature) 1561 + let has_offline_content_table: bool = conn 1562 + .query_row( 1563 + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='offline_content'", 1564 + [], 1565 + |row| row.get::<_, i64>(0), 1566 + ) 1567 + .unwrap_or(0) > 0; 1568 + 1569 + if !has_offline_content_table { 1570 + println!("[Rust] Creating offline_content table for offline reading..."); 1571 + if let Err(e) = conn.execute_batch( 1572 + " 1573 + CREATE TABLE IF NOT EXISTS offline_content ( 1574 + item_id TEXT PRIMARY KEY, 1575 + fetched_at TEXT NOT NULL, 1576 + size_bytes INTEGER NOT NULL 1577 + ); 1578 + ", 1579 + ) { 1580 + println!("[Rust] Warning: Failed to create offline_content table: {}", e); 1415 1581 } 1416 1582 } 1417 1583 ··· 2695 2861 2696 2862 /// Open a URL in an inline native WKWebView (added as subview, not modal) 2697 2863 #[tauri::command] 2698 - async fn open_inline_webview(url: String, top_offset: f64) -> Result<(), String> { 2699 - println!("[Rust] open_inline_webview called for url: {}, top_offset: {}", url, top_offset); 2864 + async fn open_inline_webview(url: String, top_offset: f64, item_id: Option<String>) -> Result<(), String> { 2865 + println!("[Rust] open_inline_webview called for url: {}, top_offset: {}, item_id: {:?}", url, top_offset, item_id); 2700 2866 2701 2867 #[cfg(target_os = "ios")] 2702 2868 { 2703 2869 use std::ffi::CString; 2704 2870 let url_cstr = CString::new(url.as_str()).map_err(|e| format!("Invalid URL string: {}", e))?; 2871 + let item_id_str = item_id.unwrap_or_default(); 2872 + let item_id_cstr = CString::new(item_id_str.as_str()).map_err(|e| format!("Invalid item_id string: {}", e))?; 2705 2873 2706 - let result = unsafe { webview_plugin_open_inline(url_cstr.as_ptr(), top_offset) }; 2874 + let result = unsafe { webview_plugin_open_inline(url_cstr.as_ptr(), top_offset, item_id_cstr.as_ptr()) }; 2707 2875 if result { 2708 2876 Ok(()) 2709 2877 } else { ··· 2713 2881 2714 2882 #[cfg(not(target_os = "ios"))] 2715 2883 { 2716 - let _ = (url, top_offset); 2884 + let _ = (url, top_offset, item_id); 2717 2885 Ok(()) 2718 2886 } 2719 2887 } ··· 2726 2894 unsafe { webview_plugin_close_inline() }; 2727 2895 } 2728 2896 Ok(()) 2897 + } 2898 + 2899 + /// Open offline reader-mode HTML in inline native WKWebView 2900 + #[tauri::command] 2901 + async fn open_inline_webview_html(item_id: String, top_offset: f64) -> Result<(), String> { 2902 + println!("[Rust] open_inline_webview_html called for item_id: {}, top_offset: {}", item_id, top_offset); 2903 + 2904 + // Read the offline content 2905 + let container = get_container_path() 2906 + .ok_or_else(|| "No container path".to_string())?; 2907 + let content_path = container.join("offline").join(&item_id).join("content.html"); 2908 + if !content_path.exists() { 2909 + return Err("No offline content available".to_string()); 2910 + } 2911 + let html = fs::read_to_string(&content_path) 2912 + .map_err(|e| format!("Failed to read offline content: {}", e))?; 2913 + 2914 + // Wrap in a styled reader-mode shell 2915 + let styled_html = format!(r#"<!DOCTYPE html> 2916 + <html><head> 2917 + <meta name="viewport" content="width=device-width, initial-scale=1"> 2918 + <style> 2919 + body {{ font-family: -apple-system, system-ui, sans-serif; padding: 16px; line-height: 1.6; color: #333; max-width: 100%; overflow-x: hidden; }} 2920 + img {{ max-width: 100%; height: auto; }} 2921 + pre {{ overflow-x: auto; }} 2922 + @media (prefers-color-scheme: dark) {{ 2923 + body {{ background: #1a1a1a; color: #ddd; }} 2924 + a {{ color: #6cb4ee; }} 2925 + }} 2926 + </style> 2927 + </head><body>{}</body></html>"#, html); 2928 + 2929 + #[cfg(target_os = "ios")] 2930 + { 2931 + use std::ffi::CString; 2932 + let html_cstr = CString::new(styled_html).map_err(|e| format!("Invalid HTML content: {}", e))?; 2933 + let result = unsafe { webview_plugin_open_inline_html(html_cstr.as_ptr(), top_offset) }; 2934 + if result { 2935 + Ok(()) 2936 + } else { 2937 + Err("Failed to open inline HTML webview".to_string()) 2938 + } 2939 + } 2940 + 2941 + #[cfg(not(target_os = "ios"))] 2942 + { 2943 + let _ = (item_id, top_offset, styled_html); 2944 + Ok(()) 2945 + } 2729 2946 } 2730 2947 2731 2948 /// Update a page (URL) item - backward compatible ··· 2845 3062 2846 3063 // Push to webhook (fire and forget) 2847 3064 let saved_url = SavedUrl { 2848 - id, 2849 - url, 2850 - tags, 3065 + id: id.clone(), 3066 + url: url.clone(), 3067 + tags: tags.clone(), 2851 3068 saved_at: now, 2852 3069 metadata: None, 2853 3070 }; 2854 3071 tauri::async_runtime::spawn(async move { 2855 3072 push_url_to_webhook(saved_url).await; 2856 3073 }); 3074 + 3075 + // Handle offline tag changes 3076 + let config = load_profile_config(); 3077 + let offline_tag_name = config.offline_tag; 3078 + 3079 + let offline_added = tags_to_add.iter().any(|t| **t == offline_tag_name); 3080 + let offline_removed = tags_to_remove.iter().any(|t| **t == offline_tag_name); 3081 + 3082 + if offline_added { 3083 + let item_id = id.clone(); 3084 + let fetch_url = url.clone(); 3085 + tauri::async_runtime::spawn(async move { 3086 + if let Err(e) = fetch_and_store_offline_content(&item_id, &fetch_url).await { 3087 + println!("[Rust] Failed to fetch offline content: {}", e); 3088 + } 3089 + }); 3090 + } else if offline_removed { 3091 + let _ = remove_offline_content(&id); 3092 + } 2857 3093 2858 3094 Ok(()) 2859 3095 } ··· 2976 3212 2977 3213 // Push to webhook (fire and forget) - only for page types 2978 3214 if item_type == "page" { 2979 - if let Some(url) = url_opt { 3215 + if let Some(ref url) = url_opt { 2980 3216 let saved_url = SavedUrl { 2981 - id, 2982 - url, 2983 - tags, 3217 + id: id.clone(), 3218 + url: url.clone(), 3219 + tags: tags.clone(), 2984 3220 saved_at: now, 2985 3221 metadata: None, 2986 3222 }; ··· 2988 3224 push_url_to_webhook(saved_url).await; 2989 3225 }); 2990 3226 } 3227 + } 3228 + 3229 + // Handle offline tag changes 3230 + let config = load_profile_config(); 3231 + let offline_tag = config.offline_tag; 3232 + 3233 + let offline_added = tags_to_add.iter().any(|t| **t == offline_tag); 3234 + let offline_removed = tags_to_remove.iter().any(|t| **t == offline_tag); 3235 + 3236 + if offline_added { 3237 + if let Some(url) = url_opt { 3238 + let item_id = id.clone(); 3239 + tauri::async_runtime::spawn(async move { 3240 + if let Err(e) = fetch_and_store_offline_content(&item_id, &url).await { 3241 + println!("[Rust] Failed to fetch offline content: {}", e); 3242 + } 3243 + }); 3244 + } 3245 + } else if offline_removed { 3246 + let _ = remove_offline_content(&id); 2991 3247 } 2992 3248 2993 3249 Ok(()) ··· 3911 4167 Ok(()) 3912 4168 } 3913 4169 4170 + #[tauri::command] 4171 + fn get_todo_tags() -> Result<(String, String), String> { 4172 + let config = load_profile_config(); 4173 + Ok((config.todo_tag, config.done_tag)) 4174 + } 4175 + 4176 + #[tauri::command] 4177 + fn set_todo_tags(todo_tag: String, done_tag: String) -> Result<(), String> { 4178 + let mut config = load_profile_config(); 4179 + config.todo_tag = todo_tag; 4180 + config.done_tag = done_tag; 4181 + if !save_profile_config(&config) { 4182 + return Err("Failed to save todo tags".to_string()); 4183 + } 4184 + Ok(()) 4185 + } 4186 + 4187 + #[tauri::command] 4188 + fn get_reading_tags() -> Result<(String, String), String> { 4189 + let config = load_profile_config(); 4190 + Ok((config.readme_tag, config.read_tag)) 4191 + } 4192 + 4193 + #[tauri::command] 4194 + fn set_reading_tags(readme_tag: String, read_tag: String) -> Result<(), String> { 4195 + let mut config = load_profile_config(); 4196 + config.readme_tag = readme_tag; 4197 + config.read_tag = read_tag; 4198 + if !save_profile_config(&config) { 4199 + return Err("Failed to save reading tags".to_string()); 4200 + } 4201 + Ok(()) 4202 + } 4203 + 4204 + #[tauri::command] 4205 + fn get_offline_tag() -> Result<String, String> { 4206 + let config = load_profile_config(); 4207 + Ok(config.offline_tag) 4208 + } 4209 + 4210 + #[tauri::command] 4211 + fn set_offline_tag(tag: String) -> Result<(), String> { 4212 + let mut config = load_profile_config(); 4213 + config.offline_tag = tag; 4214 + if !save_profile_config(&config) { 4215 + return Err("Failed to save offline tag".to_string()); 4216 + } 4217 + Ok(()) 4218 + } 4219 + 4220 + /// Get total size of offline content storage in bytes 4221 + #[tauri::command] 4222 + fn get_offline_storage_size() -> Result<u64, String> { 4223 + let conn = get_connection()?; 4224 + let total: u64 = conn 4225 + .query_row( 4226 + "SELECT COALESCE(SUM(size_bytes), 0) FROM offline_content", 4227 + [], 4228 + |row| row.get(0), 4229 + ) 4230 + .unwrap_or(0); 4231 + Ok(total) 4232 + } 4233 + 4234 + /// Fetch URL content, extract reader-mode HTML, and store to filesystem 4235 + #[tauri::command] 4236 + async fn fetch_offline_content(item_id: String, url: String) -> Result<(), String> { 4237 + fetch_and_store_offline_content(&item_id, &url).await 4238 + } 4239 + 4240 + /// Delete stored offline content for an item 4241 + #[tauri::command] 4242 + fn delete_offline_content(item_id: String) -> Result<(), String> { 4243 + remove_offline_content(&item_id) 4244 + } 4245 + 4246 + /// Retrieve stored offline content HTML for display 4247 + #[tauri::command] 4248 + fn get_offline_content(item_id: String) -> Result<Option<String>, String> { 4249 + let container = get_container_path() 4250 + .ok_or_else(|| "No container path".to_string())?; 4251 + let content_path = container.join("offline").join(&item_id).join("content.html"); 4252 + if content_path.exists() { 4253 + let content = fs::read_to_string(&content_path) 4254 + .map_err(|e| format!("Failed to read offline content: {}", e))?; 4255 + Ok(Some(content)) 4256 + } else { 4257 + Ok(None) 4258 + } 4259 + } 4260 + 4261 + /// Fetch a URL, extract reader-mode content, and store to the offline directory 4262 + async fn fetch_and_store_offline_content(item_id: &str, url: &str) -> Result<(), String> { 4263 + println!("[Rust] Fetching offline content for item {} from {}", item_id, url); 4264 + 4265 + // Fetch the HTML 4266 + let client = reqwest::Client::builder() 4267 + .timeout(std::time::Duration::from_secs(30)) 4268 + .build() 4269 + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; 4270 + 4271 + let response = client.get(url) 4272 + .header("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15") 4273 + .send() 4274 + .await 4275 + .map_err(|e| format!("Failed to fetch URL: {}", e))?; 4276 + 4277 + let html = response.text().await 4278 + .map_err(|e| format!("Failed to read response: {}", e))?; 4279 + 4280 + // Extract reader-mode content using readability-rust (Mozilla Readability.js port) 4281 + let mut parser = readability_rust::Readability::new(&html, None) 4282 + .map_err(|e| format!("Failed to parse HTML: {}", e))?; 4283 + let article = parser.parse() 4284 + .ok_or_else(|| "Readability could not extract article content".to_string())?; 4285 + 4286 + let content = article.content 4287 + .ok_or_else(|| "Readability extracted no content from page".to_string())?; 4288 + 4289 + // Store to filesystem 4290 + let container = get_container_path() 4291 + .ok_or_else(|| "No container path".to_string())?; 4292 + let offline_dir = container.join("offline").join(item_id); 4293 + fs::create_dir_all(&offline_dir) 4294 + .map_err(|e| format!("Failed to create offline directory: {}", e))?; 4295 + 4296 + let content_path = offline_dir.join("content.html"); 4297 + let size_bytes = content.len() as i64; 4298 + fs::write(&content_path, &content) 4299 + .map_err(|e| format!("Failed to write offline content: {}", e))?; 4300 + 4301 + // Update index in SQLite 4302 + let conn = get_connection()?; 4303 + let now = Utc::now().to_rfc3339(); 4304 + conn.execute( 4305 + "INSERT OR REPLACE INTO offline_content (item_id, fetched_at, size_bytes) VALUES (?, ?, ?)", 4306 + params![item_id, &now, size_bytes], 4307 + ) 4308 + .map_err(|e| format!("Failed to update offline index: {}", e))?; 4309 + 4310 + println!("[Rust] Stored offline content for item {}: {} bytes", item_id, size_bytes); 4311 + Ok(()) 4312 + } 4313 + 4314 + /// Remove offline content files and index entry for an item 4315 + fn remove_offline_content(item_id: &str) -> Result<(), String> { 4316 + println!("[Rust] Removing offline content for item {}", item_id); 4317 + 4318 + // Remove from filesystem 4319 + if let Some(container) = get_container_path() { 4320 + let offline_dir = container.join("offline").join(item_id); 4321 + if offline_dir.exists() { 4322 + fs::remove_dir_all(&offline_dir) 4323 + .map_err(|e| format!("Failed to remove offline directory: {}", e))?; 4324 + } 4325 + } 4326 + 4327 + // Remove from index 4328 + if let Ok(conn) = get_connection() { 4329 + let _ = conn.execute( 4330 + "DELETE FROM offline_content WHERE item_id = ?", 4331 + params![item_id], 4332 + ); 4333 + } 4334 + 4335 + Ok(()) 4336 + } 4337 + 3914 4338 /// Check if auto-sync is enabled 3915 4339 fn is_auto_sync_enabled() -> bool { 3916 4340 load_profile_config().sync.auto_sync ··· 4991 5415 set_auto_sync, 4992 5416 get_archive_tag, 4993 5417 set_archive_tag, 5418 + get_todo_tags, 5419 + set_todo_tags, 5420 + get_reading_tags, 5421 + set_reading_tags, 5422 + get_offline_tag, 5423 + set_offline_tag, 5424 + get_offline_storage_size, 5425 + fetch_offline_content, 5426 + delete_offline_content, 5427 + get_offline_content, 4994 5428 sync_to_webhook, 4995 5429 get_last_sync, 4996 5430 auto_sync_if_needed, ··· 5017 5451 swap_profile_databases, 5018 5452 // Inline webview 5019 5453 open_inline_webview, 5454 + open_inline_webview_html, 5020 5455 close_inline_webview 5021 5456 ]) 5022 5457 .run(tauri::generate_context!())
+1 -1
backend/tauri-mobile/src-tauri/tauri.conf.json
··· 6 6 "build": { 7 7 "beforeBuildCommand": "npm run build", 8 8 "frontendDist": "../dist", 9 - "devUrl": "http://192.168.50.143:61546" 9 + "devUrl": "http://192.168.50.143:59780" 10 10 }, 11 11 "app": { 12 12 "windows": [
+30
backend/tauri-mobile/src/App.css
··· 517 517 display: block; 518 518 } 519 519 520 + /* Reading list checkbox variant - uses book icon, distinct color */ 521 + .todo-checkbox.reading-checkbox { 522 + color: #5856d6; /* Purple for reading list */ 523 + } 524 + 525 + .todo-checkbox.reading-checkbox.checked { 526 + color: #34c759; /* Green for read/completed */ 527 + } 528 + 520 529 /* Legacy styles for edit mode - keep for now */ 521 530 .saved-url-item { 522 531 padding: 1.15rem; ··· 949 958 color: #30d158; /* Green for completed in dark mode */ 950 959 } 951 960 961 + body.dark .todo-checkbox.reading-checkbox { 962 + color: #7d7aff; /* Purple for reading list in dark mode */ 963 + } 964 + 965 + body.dark .todo-checkbox.reading-checkbox.checked { 966 + color: #30d158; /* Green for read/completed in dark mode */ 967 + } 968 + 952 969 body.dark .card-delete-btn { 953 970 color: #666; 954 971 } ··· 1164 1181 color: #666; 1165 1182 margin: 0 0 1rem 0; 1166 1183 line-height: 1.4; 1184 + } 1185 + 1186 + .settings-label { 1187 + display: block; 1188 + font-size: 0.8rem; 1189 + color: #888; 1190 + margin-bottom: 0.25rem; 1191 + text-transform: uppercase; 1192 + letter-spacing: 0.5px; 1193 + } 1194 + 1195 + body.dark .settings-label { 1196 + color: #666; 1167 1197 } 1168 1198 1169 1199 /* Profile banner shown in main view when not on default profile */
+311 -21
backend/tauri-mobile/src/App.tsx
··· 808 808 const [showRestartPrompt, setShowRestartPrompt] = useState(false); 809 809 const [archiveTag, setArchiveTag] = useState("archive"); 810 810 const [archiveTagInput, setArchiveTagInput] = useState("archive"); 811 + const [todoTag, setTodoTag] = useState("todo"); 812 + const [todoTagInput, setTodoTagInput] = useState("todo"); 813 + const [doneTag, setDoneTag] = useState("done"); 814 + const [doneTagInput, setDoneTagInput] = useState("done"); 815 + const [readmeTag, setReadmeTag] = useState("readme"); 816 + const [readmeTagInput, setReadmeTagInput] = useState("readme"); 817 + const [readTag, setReadTag] = useState("read"); 818 + const [readTagInput, setReadTagInput] = useState("read"); 819 + const [offlineTag, setOfflineTag] = useState("offline"); 820 + const [offlineTagInput, setOfflineTagInput] = useState("offline"); 821 + const [offlineStorageSize, setOfflineStorageSize] = useState(0); 811 822 812 823 // Keyboard height for iOS keyboard avoidance 813 824 const keyboardHeight = useKeyboardHeight(); ··· 953 964 loadSyncStatus(); 954 965 loadProfileInfo(); 955 966 loadArchiveTag(); 967 + loadTodoTags(); 968 + loadReadingTags(); 969 + loadOfflineTag(); 970 + loadOfflineStorageSize(); 956 971 tryAutoSync(); 957 972 958 973 // Reload when app comes back to foreground (to pick up items from share extension) ··· 1066 1081 setArchiveTag(archiveTagInput); 1067 1082 } catch (error) { 1068 1083 console.error("Failed to save archive tag:", error); 1084 + } 1085 + }; 1086 + 1087 + const loadTodoTags = async () => { 1088 + try { 1089 + const [todo, done] = await invoke<[string, string]>("get_todo_tags"); 1090 + setTodoTag(todo); 1091 + setTodoTagInput(todo); 1092 + setDoneTag(done); 1093 + setDoneTagInput(done); 1094 + } catch (error) { 1095 + console.error("Failed to load todo tags:", error); 1096 + } 1097 + }; 1098 + 1099 + const saveTodoTags = async () => { 1100 + try { 1101 + await invoke("set_todo_tags", { todoTag: todoTagInput, doneTag: doneTagInput }); 1102 + setTodoTag(todoTagInput); 1103 + setDoneTag(doneTagInput); 1104 + } catch (error) { 1105 + console.error("Failed to save todo tags:", error); 1106 + } 1107 + }; 1108 + 1109 + const loadReadingTags = async () => { 1110 + try { 1111 + const [readme, read] = await invoke<[string, string]>("get_reading_tags"); 1112 + setReadmeTag(readme); 1113 + setReadmeTagInput(readme); 1114 + setReadTag(read); 1115 + setReadTagInput(read); 1116 + } catch (error) { 1117 + console.error("Failed to load reading tags:", error); 1118 + } 1119 + }; 1120 + 1121 + const saveReadingTags = async () => { 1122 + try { 1123 + await invoke("set_reading_tags", { readmeTag: readmeTagInput, readTag: readTagInput }); 1124 + setReadmeTag(readmeTagInput); 1125 + setReadTag(readTagInput); 1126 + } catch (error) { 1127 + console.error("Failed to save reading tags:", error); 1128 + } 1129 + }; 1130 + 1131 + const loadOfflineTag = async () => { 1132 + try { 1133 + const tag = await invoke<string>("get_offline_tag"); 1134 + setOfflineTag(tag); 1135 + setOfflineTagInput(tag); 1136 + } catch (error) { 1137 + console.error("Failed to load offline tag:", error); 1138 + } 1139 + }; 1140 + 1141 + const saveOfflineTag = async () => { 1142 + try { 1143 + await invoke("set_offline_tag", { tag: offlineTagInput }); 1144 + setOfflineTag(offlineTagInput); 1145 + } catch (error) { 1146 + console.error("Failed to save offline tag:", error); 1147 + } 1148 + }; 1149 + 1150 + const loadOfflineStorageSize = async () => { 1151 + try { 1152 + const size = await invoke<number>("get_offline_storage_size"); 1153 + setOfflineStorageSize(size); 1154 + } catch (error) { 1155 + console.error("Failed to load offline storage size:", error); 1069 1156 } 1070 1157 }; 1071 1158 ··· 2282 2369 if (inlineDiv) { 2283 2370 topOffset = inlineDiv.getBoundingClientRect().top; 2284 2371 } 2285 - console.log("[App] Creating inline webview at topOffset:", topOffset, "for:", url); 2286 - await invoke("open_inline_webview", { url, topOffset }); 2372 + console.log("[App] Creating inline webview at topOffset:", topOffset, "for:", url, "itemId:", itemId); 2373 + await invoke("open_inline_webview", { url, topOffset, itemId }); 2287 2374 setWebviewLoaded(true); 2288 2375 } catch (error) { 2289 2376 console.error("[App] Failed to create inline webview:", error); ··· 2357 2444 <line x1="3" y1="9" x2="21" y2="9"></line> 2358 2445 </svg> 2359 2446 </button> 2447 + {offlineTag && item.tags.includes(offlineTag) && !isExpanded && ( 2448 + <button 2449 + className="card-action-btn" 2450 + onClick={(e) => openOfflineReader(item.id, e)} 2451 + title="Read offline" 2452 + > 2453 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 2454 + <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> 2455 + <circle cx="12" cy="12" r="3"></circle> 2456 + </svg> 2457 + </button> 2458 + )} 2360 2459 <button 2361 2460 className="card-delete-btn" 2362 2461 onClick={(e) => { ··· 2373 2472 </div> 2374 2473 {isExpanded && renderWebviewInline(expandedWebview.url)} 2375 2474 <div className="card-footer"> 2376 - {(item.tags.includes("todo") || item.tags.includes("done")) && ( 2475 + {(item.tags.includes(todoTag) || item.tags.includes(doneTag)) && ( 2377 2476 <button 2378 - className={`todo-checkbox ${item.tags.includes("done") ? "checked" : ""}`} 2379 - onClick={(e) => toggleTodoDone(e, item.id, "page", item.tags)} 2477 + className={`todo-checkbox ${item.tags.includes(doneTag) ? "checked" : ""}`} 2478 + onClick={(e) => toggleTagPair(e, item.id, "page", item.tags, todoTag, doneTag)} 2380 2479 > 2381 - {item.tags.includes("done") ? ( 2480 + {item.tags.includes(doneTag) ? ( 2382 2481 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 2383 2482 <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 2384 2483 <polyline points="9 11 12 14 22 4"></polyline> ··· 2390 2489 )} 2391 2490 </button> 2392 2491 )} 2492 + {(item.tags.includes(readmeTag) || item.tags.includes(readTag)) && ( 2493 + <button 2494 + className={`todo-checkbox reading-checkbox ${item.tags.includes(readTag) ? "checked" : ""}`} 2495 + onClick={(e) => toggleTagPair(e, item.id, "page", item.tags, readmeTag, readTag)} 2496 + > 2497 + {item.tags.includes(readTag) ? ( 2498 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 2499 + <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path> 2500 + <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path> 2501 + <polyline points="9 11 12 14 22 4"></polyline> 2502 + </svg> 2503 + ) : ( 2504 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 2505 + <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path> 2506 + <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path> 2507 + </svg> 2508 + )} 2509 + </button> 2510 + )} 2393 2511 <div className="card-tags"> 2394 2512 {item.tags.map((tag) => ( 2395 2513 <span key={tag} className="card-tag">{tag}</span> ··· 2475 2593 </div> 2476 2594 {isExpanded && renderWebviewInline(expandedWebview.url)} 2477 2595 <div className="card-footer"> 2478 - {(tags.includes("todo") || tags.includes("done")) && ( 2596 + {(tags.includes(todoTag) || tags.includes(doneTag)) && ( 2479 2597 <button 2480 - className={`todo-checkbox ${tags.includes("done") ? "checked" : ""}`} 2481 - onClick={(e) => toggleTodoDone(e, item.id, "text", tags)} 2598 + className={`todo-checkbox ${tags.includes(doneTag) ? "checked" : ""}`} 2599 + onClick={(e) => toggleTagPair(e, item.id, "text", tags, todoTag, doneTag)} 2482 2600 > 2483 - {tags.includes("done") ? ( 2601 + {tags.includes(doneTag) ? ( 2484 2602 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 2485 2603 <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 2486 2604 <polyline points="9 11 12 14 22 4"></polyline> ··· 2488 2606 ) : ( 2489 2607 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 2490 2608 <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 2609 + </svg> 2610 + )} 2611 + </button> 2612 + )} 2613 + {(tags.includes(readmeTag) || tags.includes(readTag)) && ( 2614 + <button 2615 + className={`todo-checkbox reading-checkbox ${tags.includes(readTag) ? "checked" : ""}`} 2616 + onClick={(e) => toggleTagPair(e, item.id, "text", tags, readmeTag, readTag)} 2617 + > 2618 + {tags.includes(readTag) ? ( 2619 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 2620 + <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path> 2621 + <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path> 2622 + <polyline points="9 11 12 14 22 4"></polyline> 2623 + </svg> 2624 + ) : ( 2625 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 2626 + <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path> 2627 + <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path> 2491 2628 </svg> 2492 2629 )} 2493 2630 </button> ··· 2631 2768 } 2632 2769 }; 2633 2770 2634 - // Toggle between todo and done states 2635 - const toggleTodoDone = async (e: React.MouseEvent, id: string, type: ItemType, currentTags: string[]) => { 2771 + // Generalized tag pair toggle (used for todo/done and readme/read) 2772 + const toggleTagPair = async (e: React.MouseEvent, id: string, type: ItemType, currentTags: string[], tagA: string, tagB: string) => { 2636 2773 e.stopPropagation(); // Don't trigger card click 2637 - const hasTodo = currentTags.includes("todo"); 2638 - const hasDone = currentTags.includes("done"); 2774 + const hasA = currentTags.includes(tagA); 2775 + const hasB = currentTags.includes(tagB); 2639 2776 2640 2777 let newTags: string[]; 2641 - if (hasTodo) { 2642 - // todo -> done 2643 - newTags = currentTags.filter(t => t !== "todo").concat("done"); 2644 - } else if (hasDone) { 2645 - // done -> todo 2646 - newTags = currentTags.filter(t => t !== "done").concat("todo"); 2778 + if (hasA) { 2779 + // tagA -> tagB 2780 + newTags = currentTags.filter(t => t !== tagA).concat(tagB); 2781 + } else if (hasB) { 2782 + // tagB -> tagA 2783 + newTags = currentTags.filter(t => t !== tagB).concat(tagA); 2647 2784 } else { 2648 2785 return; // Neither tag present, shouldn't happen 2649 2786 } ··· 2667 2804 } 2668 2805 await loadAllTags(); 2669 2806 } catch (error) { 2670 - console.error("Failed to toggle todo/done:", error); 2807 + console.error("Failed to toggle tag pair:", error); 2671 2808 } 2809 + }; 2810 + 2811 + // Legacy wrapper for backward compatibility 2812 + const toggleTodoDone = (e: React.MouseEvent, id: string, type: ItemType, currentTags: string[]) => { 2813 + toggleTagPair(e, id, type, currentTags, todoTag, doneTag); 2672 2814 }; 2673 2815 2674 2816 // Open URL in system browser (Safari) ··· 2738 2880 setExpandedWebview(null); 2739 2881 setWebviewLoaded(false); 2740 2882 setWebviewError(false); 2883 + // Refresh data to pick up any navigation-tracked history items 2884 + loadSavedUrls(); 2741 2885 }, WEBVIEW_ANIM_MS); 2886 + }; 2887 + 2888 + const openOfflineReader = async (itemId: string, e: React.MouseEvent) => { 2889 + e.stopPropagation(); 2890 + setWebviewLoaded(false); 2891 + setWebviewError(false); 2892 + setWebviewAnimated(false); 2893 + setExpandedWebview({ url: 'offline://' + itemId, itemId }); 2894 + requestAnimationFrame(() => { 2895 + requestAnimationFrame(() => { 2896 + const card = document.getElementById(`card-${itemId}`); 2897 + const app = appRef.current; 2898 + if (card && app) { 2899 + const cardRect = card.getBoundingClientRect(); 2900 + const appRect = app.getBoundingClientRect(); 2901 + const probe = document.createElement('div'); 2902 + probe.style.cssText = 'position:fixed;top:0;height:env(safe-area-inset-top,0px);visibility:hidden;'; 2903 + document.body.appendChild(probe); 2904 + const safeTop = probe.offsetHeight; 2905 + document.body.removeChild(probe); 2906 + const offset = cardRect.top - appRect.top - safeTop; 2907 + app.style.setProperty('--slide-offset', `${Math.max(0, offset)}px`); 2908 + } 2909 + document.documentElement.style.setProperty('--webview-ease', 'var(--webview-ease-in)'); 2910 + setWebviewAnimated(true); 2911 + setTimeout(async () => { 2912 + try { 2913 + let topOffset = 0; 2914 + const card = document.getElementById(`card-${itemId}`); 2915 + const inlineDiv = card?.querySelector('.webview-inline'); 2916 + if (inlineDiv) { 2917 + topOffset = inlineDiv.getBoundingClientRect().top; 2918 + } 2919 + console.log("[App] Opening offline reader at topOffset:", topOffset, "for item:", itemId); 2920 + await invoke("open_inline_webview_html", { itemId, topOffset }); 2921 + setWebviewLoaded(true); 2922 + } catch (error) { 2923 + console.error("[App] Failed to open offline reader:", error); 2924 + setWebviewError(true); 2925 + } 2926 + }, WEBVIEW_ANIM_MS); 2927 + }); 2928 + }); 2742 2929 }; 2743 2930 2744 2931 const renderDiscardConfirmModal = () => { ··· 3089 3276 })()} 3090 3277 </> 3091 3278 )} 3279 + </div> 3280 + 3281 + {/* Todos Section */} 3282 + <div className="settings-section"> 3283 + <h2>Todos</h2> 3284 + <p className="settings-description"> 3285 + Configure the tag pair for todo/done checkboxes. Items with the "active" tag show an unchecked checkbox; tapping it switches to the "completed" tag. 3286 + </p> 3287 + <div className="webhook-input"> 3288 + <label className="settings-label">Active tag</label> 3289 + <input 3290 + type="text" 3291 + value={todoTagInput} 3292 + onChange={(e) => setTodoTagInput(e.target.value)} 3293 + placeholder="todo" 3294 + autoCapitalize="none" 3295 + autoCorrect="off" 3296 + /> 3297 + </div> 3298 + <div className="webhook-input"> 3299 + <label className="settings-label">Completed tag</label> 3300 + <input 3301 + type="text" 3302 + value={doneTagInput} 3303 + onChange={(e) => setDoneTagInput(e.target.value)} 3304 + placeholder="done" 3305 + autoCapitalize="none" 3306 + autoCorrect="off" 3307 + /> 3308 + </div> 3309 + <button 3310 + onClick={saveTodoTags} 3311 + disabled={todoTagInput === todoTag && doneTagInput === doneTag} 3312 + className="save-settings-btn" 3313 + > 3314 + Save Todo Tags 3315 + </button> 3316 + </div> 3317 + 3318 + {/* Reading List Section */} 3319 + <div className="settings-section"> 3320 + <h2>Reading List</h2> 3321 + <p className="settings-description"> 3322 + Configure the tag pair for reading list checkboxes. Works like todo/done but with a book icon. Items with the "unread" tag show an unchecked book; tapping it switches to the "read" tag. 3323 + </p> 3324 + <div className="webhook-input"> 3325 + <label className="settings-label">Unread tag</label> 3326 + <input 3327 + type="text" 3328 + value={readmeTagInput} 3329 + onChange={(e) => setReadmeTagInput(e.target.value)} 3330 + placeholder="readme" 3331 + autoCapitalize="none" 3332 + autoCorrect="off" 3333 + /> 3334 + </div> 3335 + <div className="webhook-input"> 3336 + <label className="settings-label">Read tag</label> 3337 + <input 3338 + type="text" 3339 + value={readTagInput} 3340 + onChange={(e) => setReadTagInput(e.target.value)} 3341 + placeholder="read" 3342 + autoCapitalize="none" 3343 + autoCorrect="off" 3344 + /> 3345 + </div> 3346 + <button 3347 + onClick={saveReadingTags} 3348 + disabled={readmeTagInput === readmeTag && readTagInput === readTag} 3349 + className="save-settings-btn" 3350 + > 3351 + Save Reading Tags 3352 + </button> 3353 + </div> 3354 + 3355 + {/* Offline Section */} 3356 + <div className="settings-section"> 3357 + <h2>Offline</h2> 3358 + <p className="settings-description"> 3359 + Items tagged with this tag will have their content fetched and stored locally for offline reading. Reader-mode extraction is used to save clean article text. 3360 + </p> 3361 + <div className="webhook-input"> 3362 + <label className="settings-label">Offline tag</label> 3363 + <input 3364 + type="text" 3365 + value={offlineTagInput} 3366 + onChange={(e) => setOfflineTagInput(e.target.value)} 3367 + placeholder="offline" 3368 + autoCapitalize="none" 3369 + autoCorrect="off" 3370 + /> 3371 + </div> 3372 + <button 3373 + onClick={saveOfflineTag} 3374 + disabled={offlineTagInput === offlineTag} 3375 + className="save-settings-btn" 3376 + > 3377 + Save Offline Tag 3378 + </button> 3379 + <p className="settings-description" style={{ marginTop: '0.5rem' }}> 3380 + Disk usage: {offlineStorageSize < 1024 ? `${offlineStorageSize} B` : offlineStorageSize < 1024 * 1024 ? `${(offlineStorageSize / 1024).toFixed(1)} KB` : `${(offlineStorageSize / (1024 * 1024)).toFixed(1)} MB`} 3381 + </p> 3092 3382 </div> 3093 3383 3094 3384 {/* Display Section */}