+46
-58
README.md
+46
-58
README.md
···
1
1
2
2

3
-
# woomarks
4
-
woomarks is an app that let's you save links in your browser storage, no account needed.
3
+
# woomarks (AT Protocol Edition)
4
+
woomarks is an app that lets you save bookmarks to your AT Protocol Personal Data Server (PDS).
5
5
6
6

7
7
8
-
It's only frontend code, no database, backend server needed.
8
+
This version stores bookmarks directly on the AT Protocol network using a custom lexicon, making your bookmarks portable across the decentralized web.
9
9
10
10
11
11
12
-
It also can import/export to csv files or local storage.
13
-
14
-
15
-
## Demos
16
-
#### [Creator's personal boomarks page](https://roberto.fyi/bookmarks/).
17
-
- Saving is closed to the public.
18
-
- Saved links are visible to the public, as they are loaded from a csv file in the server.
19
-
20
-
#### [woomarks public app](https://woomarks.com).
21
-
- Saving is open to the public.
22
-
- The saved links are private and saved in the browser local storage of the users.
23
-
24
12
## Features
25
-
- Add/Delete links
26
-
- Search
27
-
- Tags
28
-
- Bookmarklet (useful for a 2-click-save)
29
-
- Data reads from:
30
-
- csv file in server (these links are public)
31
-
- local storage in browser (these links are visible just for the user)
32
-
- Local storage saving.
33
-
- Import to local storage from csv file
34
-
- Export to csv from local storage.
35
-
- Export to csv from csv file (useful when links are "deleted" using the app and just hidden using a local storage blacklist).
36
-
- Export to csv from both places.
37
-
- No external libraries.
38
-
- Vanilla css code.
39
-
- Vanilla js code.
13
+
- Add/Delete bookmarks stored on AT Protocol
14
+
- Search and filter by tags
15
+
- Decentralized storage on your Personal Data Server
16
+
- Bookmarklet support for easy saving
17
+
- Responsive design with dynamic colors and fonts
18
+
- No backend server needed - connects directly to AT Protocol
19
+
- Open lexicon format for interoperability
40
20
41
-
## Install
21
+
## Prerequisites
22
+
- An AT Protocol account (e.g., Bluesky account)
23
+
- A Personal Data Server (PDS) that supports custom lexicons
42
24
43
-
#### BASIC INSTALL (3min)
44
-
- Copy the contents of this repository to an online directory. That's all. You can start saving links.
25
+
## AT Protocol Integration
26
+
This app uses the standard `community.lexicon.bookmarks.bookmark` lexicon from [lexicon.community](https://github.com/lexicon-community/lexicon) to store bookmark records on your PDS. Each bookmark contains:
27
+
- URI (required) - The bookmarked URL
28
+
- Title (optional) - The title of the bookmarked page
29
+
- Tags (optional array) - Organizational tags
30
+
- Creation timestamp
45
31
46
-
#### SHOWCASE YOUR LINKS
47
-
- If you want to showcase your saved links, update the **mybookmarks.csv** file
32
+
Using the community standard lexicon means your bookmarks are:
33
+
- Interoperable with other AT Protocol bookmark apps
34
+
- Portable across different implementations
35
+
- Following established community standards
36
+
- Owned and controlled by you on your PDS
48
37
49
-
#### IMPORT FROM POCKET
50
-
- Go to this Pocket page to export your links. https://getpocket.com/export.php?
38
+
## Installation
51
39
52
-
**Option 1** (If you want your links public)
53
-
- Replace the mybookmarks.csv witha the content of your Pocket csv file.
40
+
1. **Deploy the App**
41
+
- Copy the contents of this repository to a web server
42
+
- Or use a static hosting service like GitHub Pages, Netlify, or Vercel
54
43
55
-
**Option 2** (If you want your links saved on your browser's local storage)
56
-
- Add > Bulk Transfer > Paste the contents.
44
+
2. **Login with AT Protocol**
45
+
- Open the app in your browser
46
+
- Enter your AT Protocol handle (e.g., `username.bsky.social`)
47
+
- Enter your app password (generate one in your AT Protocol client)
48
+
- Click Login
57
49
58
-
#### CREATE BOOKMARKLET
59
-
To be able to easily save bookmarks with the form prefilled, create a bookmarklet:
60
-
- In your browser, create a new bookmark with "add woomark" () as Name
61
-
- Paste the next code as URL.
62
-
```
50
+
3. **Start Bookmarking**
51
+
- Use the Add button to save new bookmarks
52
+
- Tag and organize your bookmarks
53
+
- Search and filter your collection
54
+
55
+
## Bookmarklet Setup
56
+
Create a bookmarklet for easy saving:
57
+
- Create a new bookmark in your browser
58
+
- Set the name to "Save to woomarks"
59
+
- Use this as the URL:
60
+
```javascript
63
61
javascript:(function(){
64
62
const url = encodeURIComponent(window.location.href);
65
63
const title = encodeURIComponent(document.title);
66
-
window.open(`https://YOURDOMAINGOESHERE.com/?title=${title}&url=${url}`, '_blank');
64
+
window.open(`https://YOURDOMAINHERE.com/?title=${title}&url=${url}`, '_blank');
67
65
})();
68
66
```
69
67
70
-
#### HIDE SAVE BUTTON
71
-
- If you are using this for your personal use (you don't want anyone else saving on your page), you can uncomment this line in **script.js** file
72
-
and add the code you want here AND as a variable in your browser's local storage.
73
-
74
-
`
75
-
// const appcode = "notsosecretcode";
76
-
`
77
-
78
-

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