Avatar

ramkarthik

Software engineer. He/Him. ManUtd fan. I build side projects (http://kramkarthik.com/projects). Recent project - https://dotnetjobs.co/.
12 public vals
Joined May 26, 2023

A minimal bookmarking tool

This allows you to bookmark links and view them later. Completely powered by ValTown and SQLite.

To set this up for yourself

  1. Fork the val
  2. From your ValTown settings page, add an environment variable named bookmarks_client_id and give it a value (you will be using this for saving)
  3. Add another environment variable named bookmarks_client_secret and give it a value (you will also be using this for saving)
  4. At first, the "bookmarks" table will not exist, so we need to save an article first, which will create the "bookmarks" table
  5. To do this, add a bookmarklet to your browser with this value (replace BOOKMARKS-CLIENT-ID and BOOKMARKS-CLIENT-SECRET with the values you added to the environment variables, and replace BOOKMARKS-URL with your VAL's URL):
javascript:void(open('BOOKMARKS-URL/save?u='+encodeURIComponent(location.href)+'&t='+encodeURIComponent(document.title)+'&id=BOOKMARKS-CLIENT-ID&secret=BOOKMARKS-CLIENT-SECRET', 'Bookmark a link', 'width=400,height=450'))
  1. Click this bookmarklet to bookmark the URL of the current active tab
  2. Go to your VAL URL homepage to see the bookmark

Demo

Here are my bookmarks: https://ramkarthik-bookmark.web.val.run/

Note

Make sure you don't share bookmarks_client_id and bookmarks_client_secret. It is used for authentication before saving a bookmark.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { Hono } from "npm:hono@3";
const app = new Hono();
app.get("/", async (c) => {
var page = parseInt(c.req.query("page") || "0");
var offset = 0;
if (page && page > 0) {
offset = page * 10;
}
var bookmarks = await sqlite.execute({
sql: "select title, url from bookmarks order by created_at desc limit 10 offset :offset",
args: { offset: offset },
});
var totalBookmarkRows = await sqlite.execute("select count(1) from bookmarks order by created_at desc");
var totalBookmarks = parseInt(totalBookmarkRows.rows[0][0].toString());
var pagesCount = Math.floor(
((totalBookmarks % 10) == 0 && totalBookmarks / 10 > 0)
? ((totalBookmarks / 10) - 1)
: (totalBookmarks / 10),
);
var bookmarksList = "";
for (var i = 0; i < bookmarks.rows.length; i++) {
bookmarksList += "<p>" + (page * 10 + i + 1) + ". <a href=\"" + bookmarks.rows[i][1] + "\">" + bookmarks.rows[i][0]
+ "</a></p>";
}
var pagination = "<div style=\"flex-direction: row; width: 100%;justify-content: space-between;\">";
if (page > 0) {
pagination += "<a href=\"?page=" + (page - 1) + "\"> < prev </a>";
}
if (page < pagesCount) {
pagination += "<a href=\"?page=" + (page + 1) + "\"> next > </a>";
}
pagination += "</div>";
const html = `<html>
<head>
<title>My reading list</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
</head>
<body style="width:70%; margin-left:15%">
<h1>Reading list</h1>`
+ bookmarksList
+ pagination + `</body>
</html>`;
return new Response(
html,
{
headers: {
"Content-Type": "text/html",
},
},
);
});
app.get("/save", async (c) => {
const id = c.req.query("id");
const secret = c.req.query("secret");
const title = c.req.query("t");
const url = c.req.query("u");
if (!id && !secret) {
return c.text("Authentication details (ID/Secret) missing!");
}
if (id != Deno.env.get("bookmarks_client_id") || secret != Deno.env.get("bookmarks_client_secret")) {
return c.text("Unauthorized!");
}
if (!url) {
return c.text("URL missing!");
}
const create_table = await sqlite.execute(
"CREATE TABLE IF NOT EXISTS bookmarks (title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)",
);
const res = await sqlite.execute({
sql: `insert into bookmarks (title, url) values (:title, :url)`,
args: { title: title, url: url },
});
return c.text("Saved!");
});
app.get("/api/bookmarks", async (c) => {
var baseUrl = c.req.url;
var page = parseInt(c.req.query("page") || "0");
var offset = 0;
if (page && page > 0) {
offset = page * 10;
}
var bookmarks = await sqlite.execute({
sql: "select title, url from bookmarks order by created_at desc limit 10 offset :offset",
args: { offset: offset },
});
var totalBookmarkRows = await sqlite.execute("select count(1) from bookmarks order by created_at desc");
var totalBookmarks = parseInt(totalBookmarkRows.rows[0][0].toString());
var pagesCount = Math.floor(
((totalBookmarks % 10) == 0 && totalBookmarks / 10 > 0)
? ((totalBookmarks / 10) - 1)
: (totalBookmarks / 10),
);
let response = {
prev: page > 0 ? baseUrl + "?page=" + (page - 1) : null,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { prsEmail } from "https://esm.town/v/ramkarthik/prsEmail";
import { reposStaleGithubPRs } from "https://esm.town/v/ramkarthik/reposStaleGithubPRs";
import process from "node:process";
// Sends an email with all the open PRs that have not had any activity
// for specified period of days
// Set up this function to run every day (or any interval)
export async function staleGithubPRsEmail() {
// Set up a secret named githubRepos with all the repos you want to analyze (comma separated)
let repos = process.env.githubRepos.split(",").filter((r) =>
r.trim()
);
let staleSinceDaysAgo = 3; // Any PR with no activity for this many days will qualify
let pullRequests = await reposStaleGithubPRs(
repos,
process.env.githubOwner, // Set up a secret named githubOwner with the owner name
process.env.github, // Create GitHub token and set up this secret
staleSinceDaysAgo,
);
let { html, subject } = prsEmail(
pullRequests,
staleSinceDaysAgo,
);
console.email({ html: html, subject: subject });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { bookmarksHtml } from "https://esm.town/v/ramkarthik/bookmarksHtml";
import process from "node:process";
import { raindropBookmarksSinceLastRun } from "https://esm.town/v/ramkarthik/raindropBookmarksSinceLastRun";
import { daysAgoFromToday } from "https://esm.town/v/ramkarthik/daysAgoFromToday";
export async function raindropBookmarksToEmail(interval: Interval) {
let lastRunAt = interval.lastRunAt?.toISOString() ||
daysAgoFromToday(7);
let bookmarks = await raindropBookmarksSinceLastRun(
lastRunAt,
process.env.raindrop,
);
if (bookmarks && bookmarks.length > 0) {
let emailHtml = bookmarksHtml(bookmarks);
console.email({
html: emailHtml,
subject: "[Raindrop] Your bookmarks since " + lastRunAt.split("T")[0],
});
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export const prsEmail = (pullRequests: any, staleSinceDaysAgo: number) => {
let emailHtml = "";
let header =
`<h3>Below PRs have been stale for over ${staleSinceDaysAgo} days:</h3>`;
let footer =
`<p>Email from val.town. You can unsubscribe by clearing your interval here: https://val.town/@me.intervals</p>`;
emailHtml = header;
emailHtml += "<br />";
pullRequests.forEach((p, index) => {
emailHtml += `<b>Repo: ${p.repo}</b><ol>`;
p.prs.forEach((pr, index) => {
emailHtml += `<li><a href='${pr.url}'>${pr.title}</a>
<p>Created: ${pr.created_at}</p>
<p>Updated: ${pr.updated_at}</p>
<p>Author: ${pr.author}</p>
<p>Reviewers: ${pr.reviewers.join(", ")}</p>
<p>Head: ${pr.head}</p>
<p>Base: ${pr.base}</p>
</li>`;
});
emailHtml += "</ol><br /><br />";
});
emailHtml += footer;
let numberOfRepos = pullRequests.length > 3 ? 3 : pullRequests.length;
let topRepos = Array.from(new Set(pullRequests.map((p) => p.repo))).slice(
0,
numberOfRepos,
);
return {
html: emailHtml,
subject: "[GitHub] Stale PRs from " + topRepos.join(", "),
};
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { staleGithubPRs } from "https://esm.town/v/ramkarthik/staleGithubPRs";
export const reposStaleGithubPRs = async (
repos: string[],
owner: string,
token: string,
staleSinceDaysAgo: number,
) => {
let pullRequests = [];
for (const repo of repos) {
let prs = await staleGithubPRs(
owner,
repo,
token,
staleSinceDaysAgo,
);
if (prs && prs.length > 0) {
pullRequests.push({ repo: repo, prs: prs });
}
}
return pullRequests;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { daysAgoFromToday } from "https://esm.town/v/ramkarthik/daysAgoFromToday";
import { fetchJSON } from "https://esm.town/v/stevekrouse/fetchJSON?v=41";
export const staleGithubPRs = async (
owner: string,
repo: string,
token: string,
staleSinceDaysAgo: number,
) => {
let options = {
"headers": {
"Authorization": "Bearer " + token,
"X-GitHub-Api-Version": "2022-11-28",
},
};
let data = await fetchJSON(
`https://api.github.com/repos/${owner}/${repo}/pulls`,
options,
);
let pullRequests = [];
let staleDate = daysAgoFromToday(staleSinceDaysAgo);
if (data && data.length > 0) {
let prs = data?.filter((d) => d.updated_at <= staleDate).map((d) => {
return {
url: d.html_url,
title: d.title,
author: d.user.login,
created_at: d.created_at,
updated_at: d.updated_at,
reviewers: d.requested_reviewers.map((r) => r.login),
head: d.head.ref,
base: d.base.ref,
};
});
pullRequests.push(...prs);
}
return pullRequests;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const bookmarksHtml = (bookmarks: any[], header: string = "") => {
let emailHtml = "";
header = header || `<h3>Here are your bookmarks from Raindrop:</h3>`;
let footer =
`<p>Email from val.town. You can unsubscribe by clearing your interval here: https://val.town/@me.intervals</p>`;
emailHtml = header;
emailHtml += "<br /><ol>";
bookmarks.forEach((i, index) => {
emailHtml +=
`<li><a href='${i.link}'>${i.title}</a><p>${i.excerpt}</p></li>`;
});
emailHtml += "</ol>";
emailHtml += footer;
return emailHtml;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { raindropBookmarks } from "https://esm.town/v/ramkarthik/raindropBookmarks";
export const raindropBookmarksSinceLastRun = async (
lastRunAt: String,
raindropToken: String,
) => {
let bookmarks = [], reachedLastItem = false, page = 0;
while (!reachedLastItem) {
let data = await raindropBookmarks(
page,
raindropToken,
);
if (data.result && data.items.length > 0) {
data.items.every((item, index) => {
if (item.created >= lastRunAt) {
bookmarks.push(item);
return true;
}
else {
reachedLastItem = true;
return false;
}
});
}
else {
reachedLastItem = true;
}
page += 1;
}
return bookmarks;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { fetchJSON } from "https://esm.town/v/stevekrouse/fetchJSON?v=41";
export const raindropBookmarks = async (
page: Number,
raindropToken: String,
) => {
let options = {
"headers": { "Authorization": "Bearer " + raindropToken },
};
let data = await fetchJSON(
"https://api.raindrop.io/rest/v1/raindrops/0?perpage=20&page=" + page,
options,
);
return data;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export async function domainAvailability(req, res) {
const whoiser = await import("npm:whoiser");
var domain = req.body;
let domainWhois = await whoiser.domain(domain);
console.log(domainWhois);
if (
(domainWhois["whois.verisign-grs.com"] &&
domainWhois["whois.verisign-grs.com"]["Domain Status"] &&
domainWhois["whois.verisign-grs.com"]["Domain Status"].length > 0) ||
(domainWhois["whois.nic.google"] &&
domainWhois["whois.nic.google"]["Domain Status"] &&
domainWhois["whois.nic.google"]["Domain Status"].length > 0)
)
res.send({ domain: domain, available: false });
else
res.send({ domain: domain, available: true });
}