Initial Commit

This commit is contained in:
Tom Butcher 2025-11-09 18:02:15 +00:00
commit 6d5265dfe0
38 changed files with 7799 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.dev.vars
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
.wrangler/

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "tombutcher-api",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy --env production",
"dev": "wrangler dev --test-scheduled --host 0.0.0.0",
"test": "vitest"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.6.4",
"vitest": "~2.1.9",
"wrangler": "^4.38.0"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.80",
"@notionhq/client": "^5.1.0",
"blurhash": "^2.0.5",
"dayjs": "^1.11.18",
"jpeg-js": "^0.4.4",
"lodash": "^4.17.21",
"microdiff": "^1.5.0",
"remove-svg-properties": "^0.3.4",
"sharp": "^0.34.3",
"svgson": "^5.3.1",
"upng-js": "^2.1.0"
}
}

67
src/index.js Normal file
View File

@ -0,0 +1,67 @@
import { handleContactRequest } from "./routes/contact.js";
import { handleContentRequest } from "./routes/content.js";
import { handleNotionHook } from "./routes/hooks.js";
import { globalHeaders } from "./utils/api.js";
async function handleRequest(request, env) {
if (
request.method === "OPTIONS" &&
request.url.split("?")[0].endsWith("/contact")
) {
console.log("Handling contact OPTIONS request...");
return new Response(null, {
status: 204, // No Content
headers: {
"Access-Control-Allow-Origin": env.CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
if (
request.method === "POST" &&
request.url.split("?")[0].endsWith("/contact")
) {
return await handleContactRequest(request, env);
}
if (
request.method === "GET" &&
request.url.split("?")[0].endsWith("/content")
) {
return await handleContentRequest(request, env);
}
if (
request.method === "POST" &&
request.url.split("?")[0].endsWith("/notionHook")
) {
return await handleNotionHook(request, env);
}
// Return 404 if the route is not found
return new Response("Not Found", { status: 404, headers: globalHeaders });
}
async function handleScheduledEvent(event, env) {
console.log("Scheduled event:", event.cron);
switch (event.cron) {
case "*/5 * * * *":
await updateAllSmoobuData(env);
break;
case "* * * * *":
await refreshBookingCache(env);
break;
default:
break;
}
}
export default {
async fetch(request, env) {
return await handleRequest(request, env);
},
async scheduled(event, env) {
return await handleScheduledEvent(event, env);
},
};

45
src/objects/blogs.js Normal file
View File

@ -0,0 +1,45 @@
import {
buildListCache,
queryNotionDataSource,
getNotionPage,
} from "../utils/notion.js";
import { getBlogs, storeBlogs, transformNotionBlog } from "../utils/blogs.js";
import { unionBy } from "lodash";
export async function importNotionBlogs(env, notionId = null) {
console.log("Importing Blogs from Notion...");
let BlogsData = [];
if (notionId !== null) {
BlogsData = [await getNotionPage(notionId)];
} else {
BlogsData = await queryNotionDataSource(env.BLOGS_DB);
}
const Blogs = (
await Promise.all(
BlogsData.map(async (blog) => transformNotionBlog(env, blog))
)
).filter(Boolean);
// If notionId is not null, use lodash unionBy to replace existing Blogs
if (notionId !== null) {
const cachedBlogs = await getBlogs(env);
const mergedBlogs = unionBy(Blogs, cachedBlogs, "notionId");
await storeBlogs(env, mergedBlogs);
console.log("Imported Blogs from Notion and merged with cache.");
return mergedBlogs;
}
await storeBlogs(env, Blogs);
console.log("Imported Blogs from Notion.");
return Blogs;
}
export async function deleteNotionBlogFromCache(env, notionId) {
const cachedBlogs = await getBlogs(env);
const newCache = cachedBlogs.filter((b) => b.notionId !== notionId);
await storeBlogs(env, newCache);
console.log("Deleted blog from cache.");
return newCache;
}

62
src/objects/companies.js Normal file
View File

@ -0,0 +1,62 @@
import {
queryNotionDataSource,
getNotionPage,
sortDuration,
} from "../utils/notion.js";
import {
getCompanies,
storeCompanies,
transformNotionCompany,
} from "../utils/companies.js";
import { unionBy } from "lodash";
import { getSettings } from "../utils/settings.js";
import { getPositions } from "../utils/positions.js";
export async function importNotionCompanies(env, notionId = null) {
console.log("Importing Companies from Notion...");
let CompaniesData = [];
if (notionId !== null) {
CompaniesData = [await getNotionPage(notionId)];
} else {
CompaniesData = await queryNotionDataSource(env.COMPANIES_DB);
}
const settingsData = await getSettings(env);
const positionsData = await getPositions(env);
const Companies = (
await Promise.all(
CompaniesData.map(async (company) =>
transformNotionCompany(env, company, settingsData, positionsData)
)
)
)
.filter(Boolean)
.sort(sortDuration);
// If notionId is not null, use lodash unionBy to replace existing Companies
if (notionId !== null) {
const cachedCompanies = await getCompanies(env);
const mergedCompanies = unionBy(
Companies,
cachedCompanies,
"notionId"
).sort(sortDuration);
await storeCompanies(env, mergedCompanies);
console.log("Imported Companies from Notion and merged with cache.");
return mergedCompanies;
}
await storeCompanies(env, Companies);
console.log("Imported Companies from Notion.");
return Companies;
}
export async function deleteNotionCompanyFromCache(env, notionId) {
const cachedCompanies = await getCompanies(env);
const newCache = cachedCompanies.filter((c) => c.notionId !== notionId);
await storeCompanies(env, newCache);
console.log("Deleted company from cache.");
return newCache;
}

43
src/objects/cv.js Normal file
View File

@ -0,0 +1,43 @@
import {
buildListCache,
queryNotionDataSource,
getNotionPage,
} from "../utils/notion.js";
import { getCvs, storeCvs, transformNotionCv } from "../utils/cv.js";
import { unionBy } from "lodash";
export async function importNotionCvs(env, notionId = null) {
console.log("Importing CVs from Notion...");
let CvsData = [];
if (notionId !== null) {
CvsData = [await getNotionPage(notionId)];
} else {
CvsData = await queryNotionDataSource(env.CV_DB);
}
const Cvs = (
await Promise.all(CvsData.map(async (cv) => transformNotionCv(env, cv)))
).filter(Boolean);
// If notionId is not null, use lodash unionBy to replace existing CVs
if (notionId !== null) {
const cachedCvs = await getCvs(env);
const mergedCvs = unionBy(Cvs, cachedCvs, "notionId");
await storeCvs(env, mergedCvs);
console.log("Imported CVs from Notion and merged with cache.");
return mergedCvs;
}
await storeCvs(env, Cvs);
console.log("Imported CVs from Notion.");
return Cvs;
}
export async function deleteNotionCvFromCache(env, notionId) {
const cachedCvs = await getCvs(env);
const newCache = cachedCvs.filter((c) => c.notionId !== notionId);
await storeCvs(env, newCache);
console.log("Deleted CV from cache.");
return newCache;
}

27
src/objects/files.js Normal file
View File

@ -0,0 +1,27 @@
import { getPages } from "../utils/pages.js";
import { getBlogs } from "../utils/blogs.js";
import { getProjects } from "../utils/projects.js";
import { getCompanies } from "../utils/companies.js";
import { collectFileUrls, updateFiles } from "../utils/fileCache.js";
import { getCvs } from "../utils/cv.js";
export async function importFiles(env) {
console.log("Importing files from Notion...");
const pages = (await getPages(env)) || [];
const blogs = (await getBlogs(env)) || [];
const projects = (await getProjects(env)) || [];
const companies = (await getCompanies(env)) || [];
const cvs = (await getCvs(env)) || [];
const fileUrls = collectFileUrls([
...pages,
...blogs,
...projects,
...companies,
...cvs,
]);
const updatedFiles = await updateFiles(env, fileUrls);
console.log("Imported files from Notion.");
return updatedFiles;
}

26
src/objects/images.js Normal file
View File

@ -0,0 +1,26 @@
import { getPages } from "../utils/pages.js";
import { getBlogs } from "../utils/blogs.js";
import { getProjects } from "../utils/projects.js";
import { getCompanies } from "../utils/companies.js";
import { collectImageUrls, updateImages } from "../utils/imageCache.js";
export async function importImages(env) {
// Fetch caches
console.log("Importing images from Notion...");
const pages = (await getPages(env)) || [];
const blogs = (await getBlogs(env)) || [];
const projects = (await getProjects(env)) || [];
const companies = (await getCompanies(env)) || [];
// Collect all image URLs
const imageUrls = collectImageUrls([
...pages,
...blogs,
...projects,
...companies,
]);
const updatedImages = await updateImages(env, imageUrls);
console.log("Imported images from Notion.");
return updatedImages;
}

22
src/objects/navigation.js Normal file
View File

@ -0,0 +1,22 @@
import { queryNotionDataSource } from "../utils/notion.js";
import { transformNavigation, storeNavigation } from "../utils/navigation.js";
export async function importNotionNavigation(env) {
console.log("Importing navigation from Notion...");
const notionPages = await queryNotionDataSource(env.PAGES_DB);
const notionBlogs = await queryNotionDataSource(env.BLOGS_DB);
// Transform the incoming pages for the given type
const navigationItems = (
await Promise.all(
notionPages.map(async (item) => transformNavigation(item, "page")),
notionBlogs.map(async (item) => transformNavigation(item, "blog"))
)
).filter(Boolean);
// Store the updated cache
await storeNavigation(env, navigationItems);
console.log("Navigation imported from Notion.");
return navigationItems;
}

53
src/objects/pages.js Normal file
View File

@ -0,0 +1,53 @@
import { queryNotionDataSource, getNotionPage } from "../utils/notion.js";
import { transformPageData, storePages, getPages } from "../utils/pages.js";
import { getSettings } from "../utils/settings.js";
import { unionBy } from "lodash";
// Fetch Notion pages (raw data)
export async function importNotionPages(env, notionId = null) {
console.log("Importing pages from Notion...");
const settingsData = await getSettings(env);
let pagesData = [];
if (notionId !== null) {
pagesData = [await getNotionPage(notionId)];
} else {
pagesData = await queryNotionDataSource(env.PAGES_DB, {
sorts: [{ property: "Order", direction: "ascending" }],
});
}
const pages = (
await Promise.all(
pagesData.map(async (page) => transformPageData(env, page, settingsData))
)
)
.filter(Boolean)
.sort((a, b) => a.order - b.order)
.filter((page) => page.published == true);
if (notionId !== null) {
const cachedPages = await getPages(env);
console.log("Cached pages:", cachedPages);
console.log("Pages:", pages);
// Merge: preserve cached order, but replace with new data if available
const mergedPages = unionBy(pages, cachedPages, "notionId")
.sort((a, b) => a.order - b.order)
.filter((page) => page.published == true);
await storePages(env, mergedPages);
console.log("Pages imported from Notion and merged with cache.");
return mergedPages;
}
await storePages(env, pages);
console.log("Pages imported from Notion.");
return pages;
}
export async function deleteNotionPageFromCache(env, notionId) {
const cachedPages = await getPages(env);
const newCache = cachedPages.filter((b) => b.notionId !== notionId);
await storePages(env, newCache);
console.log("Deleted page from cache.");
return newCache;
}

50
src/objects/positions.js Normal file
View File

@ -0,0 +1,50 @@
import { queryNotionDataSource, getNotionPage } from "../utils/notion.js";
import {
getPositions,
storePositions,
transformNotionPosition,
} from "../utils/positions.js";
import { unionBy } from "lodash";
import { getCompanies } from "../utils/companies.js";
export async function importNotionPositions(env, notionId = null) {
console.log("Importing Positions from Notion...");
let PositionsData = [];
if (notionId !== null) {
PositionsData = [await getNotionPage(notionId)];
} else {
PositionsData = await queryNotionDataSource(env.POSITIONS_DB);
}
const companiesData = await getCompanies(env);
const Positions = (
await Promise.all(
PositionsData.map(async (position) =>
transformNotionPosition(env, position, companiesData)
)
)
).filter(Boolean);
// If notionId is not null, use lodash unionBy to replace existing Positions
if (notionId !== null) {
const cachedPositions = await getPositions(env);
const mergedPositions = unionBy(Positions, cachedPositions, "notionId");
await storePositions(env, mergedPositions);
console.log("Imported Positions from Notion and merged with cache.");
return mergedPositions;
}
await storePositions(env, Positions);
console.log("Imported Positions from Notion.");
return Positions;
}
export async function deleteNotionPositionFromCache(env, notionId) {
const cachedPositions = await getPositions(env);
const newCache = cachedPositions.filter((p) => p.notionId !== notionId);
await storePositions(env, newCache);
console.log("Deleted position from cache.");
return newCache;
}

54
src/objects/projects.js Normal file
View File

@ -0,0 +1,54 @@
import {
buildListCache,
queryNotionDataSource,
getNotionPage,
} from "../utils/notion.js";
import {
getProjects,
storeProjects,
transformNotionProject,
} from "../utils/projects.js";
import { unionBy } from "lodash";
import { getSettings } from "../utils/settings.js";
export async function importNotionProjects(env, notionId = null) {
console.log("Importing Projects from Notion...");
let ProjectsData = [];
if (notionId !== null) {
ProjectsData = [await getNotionPage(notionId)];
} else {
ProjectsData = await queryNotionDataSource(env.PROJECTS_DB);
}
const settingsData = await getSettings(env);
const Projects = (
await Promise.all(
ProjectsData.map(async (project) =>
transformNotionProject(env, project, settingsData)
)
)
).filter(Boolean);
// If notionId is not null, use lodash unionBy to replace existing Projects
if (notionId !== null) {
const cachedProjects = await getProjects(env);
const mergedProjects = unionBy(Projects, cachedProjects, "notionId");
await storeProjects(env, mergedProjects);
console.log("Imported Projects from Notion and merged with cache.");
return mergedProjects;
}
await storeProjects(env, Projects);
console.log("Imported Projects from Notion.");
return Projects;
}
export async function deleteNotionProjectFromCache(env, notionId) {
const cachedProjects = await getProjects(env);
const newCache = cachedProjects.filter((p) => p.notionId !== notionId);
await storeProjects(env, newCache);
console.log("Deleted project from cache.");
return newCache;
}

34
src/objects/settings.js Normal file
View File

@ -0,0 +1,34 @@
import { queryNotionDataSource } from "../utils/notion.js";
import {
transformThemes,
transformRedirectsData,
transformGlobalThemesData,
storeSettings,
} from "../utils/settings.js";
export async function importNotionSettings(env) {
console.log("Importing settings from Notion...");
const [globalThemesData, themesData, redirectsData] = await Promise.all([
queryNotionDataSource(env.GLOBAL_THEMES_DB),
queryNotionDataSource(env.THEMES_DB),
queryNotionDataSource(env.REDIRECTS_DB),
]);
console.log("Fetched settings from Notion.");
const themes = await transformThemes(themesData);
const globalThemes = transformGlobalThemesData(env, globalThemesData, themes);
const redirects = await transformRedirectsData(env, redirectsData);
await storeSettings(env, {
globalThemes,
themes,
redirects,
});
return {
globalThemes,
themes,
redirects,
};
}

24
src/objects/videos.js Normal file
View File

@ -0,0 +1,24 @@
import { getPages } from "../utils/pages.js";
import { getBlogs } from "../utils/blogs.js";
import { getProjects } from "../utils/projects.js";
import { getCompanies } from "../utils/companies.js";
import { collectVideoUrls, updateVideos } from "../utils/videoCache.js";
export async function importVideos(env) {
console.log("Importing videos from Notion...");
const pages = (await getPages(env)) || [];
const blogs = (await getBlogs(env)) || [];
const projects = (await getProjects(env)) || [];
const companies = (await getCompanies(env)) || [];
const videoUrls = collectVideoUrls([
...pages,
...blogs,
...projects,
...companies,
]);
const updatedVideos = await updateVideos(env, videoUrls);
console.log("Imported videos from Notion.");
return updatedVideos;
}

24
src/routes/blurHash.js Normal file
View File

@ -0,0 +1,24 @@
export async function handleProcessBlurhash(env) {
try {
const imagesData = await getImages(env);
if (imagesData) {
console.log("Found cached content!");
await processBlurhashes(imagesData);
} else {
console.log("No images found in cache!");
}
} catch (error) {
console.error("Error in handleProcessBlurhash:", error);
return new Response(
JSON.stringify({
error: "Failed to process blurhash!",
message: error.message,
}),
{
status: 500,
headers: globalHeaders,
}
);
}
}

85
src/routes/contact.js Normal file
View File

@ -0,0 +1,85 @@
import { globalHeaders } from "../utils/api.js";
import { addToNotionDataSource } from "../utils/notion.js";
import { env } from "cloudflare:workers";
const TURNSTILE_ENABLED = true;
export async function handleContactRequest(request, env) {
const { name, email, message, token } = await request.json();
if (!email || !token || !message || !name) {
return new Response(
JSON.stringify({
message: "Email, message, name and token are required",
code: "missing-fields",
}),
{
status: 400,
headers: globalHeaders,
},
);
}
// Extract the IP address from the request headers
const ip = request.headers.get("CF-Connecting-IP");
if (TURNSTILE_ENABLED) {
// Verify the Turnstile token
const verificationResponse = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: globalHeaders,
body: JSON.stringify({
secret: env.TURNSTILE_AUTH,
response: token,
}),
},
);
const verificationData = await verificationResponse.json();
if (!verificationData.success) {
const code = verificationData["error-codes"][0];
var errorMessage = "Captcha error.";
if (code == "timeout-or-duplicate") {
errorMessage = "Captcha expired.";
}
return new Response(
JSON.stringify({ message: errorMessage, code: `captcha-${code}` }),
{
status: 400,
headers: globalHeaders,
},
);
}
}
// Add the email and location to Notion
try {
await addToNotionDataSource(
{
Name: name,
Email: email,
Message: message,
["IP Address"]: ip,
},
env.MESSAGES_DB,
);
return new Response(
JSON.stringify({
success: true,
message: "Email processed and added to Notion",
}),
{
headers: globalHeaders,
},
);
} catch (error) {
return new Response(
JSON.stringify({ message: "Error storing data.", code: "storage-error" }),
{
status: 500,
headers: globalHeaders,
},
);
}
}

92
src/routes/content.js Normal file
View File

@ -0,0 +1,92 @@
import { globalHeaders } from "../utils/api.js";
import { getCombinedCachedContent } from "../utils/contentCache.js";
import { importNotionBlogs } from "../objects/blogs.js";
import { importNotionPages } from "../objects/pages.js";
import { importNotionSettings } from "../objects/settings.js";
import { importNotionNavigation } from "../objects/navigation.js";
import { importNotionPositions } from "../objects/positions.js";
import { importNotionCompanies } from "../objects/companies.js";
import { importImages } from "../objects/images.js";
import { importNotionCvs } from "../objects/cv.js";
import { importFiles } from "../objects/files.js";
import { importVideos } from "../objects/videos.js";
// Fetch or return cached content
export async function getCachedContent(env) {
// Try to get combined cached content
var cachedContent = await getCombinedCachedContent(env);
const noBlogs = cachedContent.blogs?.length === 0;
const noPages = cachedContent.pages?.length === 0;
const noSettings = cachedContent.settings == {};
const noCompanies = cachedContent.companies?.length === 0;
const noCvs = cachedContent.cvs?.length === 0;
const noPositions = cachedContent.positions?.length === 0;
if (noBlogs || noPages || noSettings || noCompanies || noCvs || noPositions) {
await importNotionNavigation(env);
}
if (noBlogs) {
cachedContent.blogs = await importNotionBlogs(env);
cachedContent.images = await importImages(env);
cachedContent.files = await importFiles(env);
cachedContent.videos = await importVideos(env);
}
if (noPages) {
cachedContent.pages = await importNotionPages(env);
cachedContent.images = await importImages(env);
cachedContent.files = await importFiles(env);
cachedContent.videos = await importVideos(env);
}
if (noSettings) {
cachedContent.settings = await importNotionSettings(env);
}
if (noCvs) {
cachedContent.cvs = await importNotionCvs(env);
cachedContent.files = await importFiles(env);
}
if (noCompanies) {
await importNotionPositions(env);
cachedContent.companies = await importNotionCompanies(env);
cachedContent.images = await importImages(env);
cachedContent.files = await importFiles(env);
cachedContent.videos = await importVideos(env);
}
if (cachedContent) {
return new Response(
JSON.stringify({
...cachedContent,
blogs: cachedContent.blogs.filter((blog) => blog.published == true),
}),
{
headers: globalHeaders,
}
);
}
return new Response(JSON.stringify({}), { headers: globalHeaders });
}
export async function handleContentRequest(request, env) {
try {
return await getCachedContent(env);
} catch (error) {
console.error("Error handling content request:", error);
return new Response(
JSON.stringify({
error: "Failed to fetch content",
message: error.message,
}),
{
status: 500,
headers: globalHeaders,
}
);
}
}

277
src/routes/hooks.js Normal file
View File

@ -0,0 +1,277 @@
import { importNotionNavigation } from "../objects/navigation";
import { importNotionSettings } from "../objects/settings";
import { importNotionPages, deleteNotionPageFromCache } from "../objects/pages";
import { importNotionBlogs, deleteNotionBlogFromCache } from "../objects/blogs";
import { globalHeaders } from "../utils/api";
import { importImages } from "../objects/images";
import { importFiles } from "../objects/files";
import { importVideos } from "../objects/videos";
import { handleBlurhashUpdate } from "../utils/imageCache";
import { importNotionProjects } from "../objects/projects";
import { importNotionCompanies } from "../objects/companies";
import { importNotionPositions } from "../objects/positions";
import { importNotionCvs } from "../objects/cv";
async function updateAllNotionData(env) {
await importNotionNavigation(env);
const settings = await importNotionSettings(env);
const pages = await importNotionPages(env);
const blogs = await importNotionBlogs(env);
const bookings = await importNotionBookings(env);
const projects = await importNotionProjects(env);
const cvs = await importNotionCvs(env);
return { settings, pages, blogs, bookings, projects, cvs };
}
export async function handleNotionHook(request, env) {
try {
const body = await request.json();
if (body?.verification_token) {
console.log("Verification token received:", body.verification_token);
return new Response(
JSON.stringify({
status: "gotVerificationToken",
}),
{ headers: globalHeaders }
);
}
console.log("Notion hook received:", body.type);
if (
body.type == "page.properties_updated" ||
body.type == "page.content_updated" ||
(body.type == "page.created" && body.data?.parent?.data_source_id)
) {
const dataSourceId = body.data.parent.data_source_id;
const entityId = body?.entity?.id || null;
console.log("Data source ID:", dataSourceId);
if (dataSourceId === env.PAGES_DB) {
console.log("Importing pages from Notion...");
await importNotionNavigation(env);
const pages = await importNotionPages(env, entityId);
const images = await importImages(env);
await handleBlurhashUpdate(request, env);
return new Response(
JSON.stringify({
status: "OK",
content: { pages, images },
}),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.BLOGS_DB) {
await importNotionNavigation(env);
const blogs = await importNotionBlogs(env, entityId);
const images = await importImages(env);
const files = await importFiles(env);
const videos = await importVideos(env);
await handleBlurhashUpdate(request, env);
return new Response(
JSON.stringify({
status: "OK",
content: { blogs, images, files, videos },
}),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.PROJECTS_DB) {
await importNotionNavigation(env);
const projects = await importNotionProjects(env, entityId);
const images = await importImages(env);
const files = await importFiles(env);
const videos = await importVideos(env);
await handleBlurhashUpdate(request, env);
return new Response(
JSON.stringify({
status: "OK",
content: { projects, images, files, videos },
}),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.COMPANIES_DB) {
const positions = await importNotionPositions(env);
const companies = await importNotionCompanies(env, entityId);
const images = await importImages(env);
const files = await importFiles(env);
const videos = await importVideos(env);
await handleBlurhashUpdate(request, env);
return new Response(
JSON.stringify({
status: "OK",
content: { companies, images, positions, files, videos },
}),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.POSITIONS_DB) {
const positions = await importNotionPositions(env, entityId);
const companies = await importNotionCompanies(env);
const images = await importImages(env);
await handleBlurhashUpdate(request, env);
const files = await importFiles(env);
const videos = await importVideos(env);
return new Response(
JSON.stringify({
status: "OK",
content: { positions, companies, images, files, videos },
}),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.CV_DB) {
const cvs = await importNotionCvs(env, entityId);
const files = await importFiles(env);
return new Response(
JSON.stringify({ status: "OK", content: { cvs, files } }),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.THEMES_DB) {
const settings = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { settings } }),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.REDIRECTS_DB) {
const redirects = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { redirects } }),
{
headers: globalHeaders,
}
);
}
if (dataSourceId === env.BRANDING_DB) {
const branding = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { branding } }),
{
headers: globalHeaders,
}
);
}
console.log("Page Blogs with data source:", dataSourceId);
}
if (
body.type == "page.properties_updated" ||
(body.type == "page.deleted" && body.data?.parent?.data_source_id)
) {
const dataSourceId = body.data.parent.data_source_id;
const entityId = body?.entity?.id || null;
console.log("Data source ID:", dataSourceId);
console.log("Entity ID:", entityId);
switch (dataSourceId) {
case env.PAGES_DB:
const pages = await deleteNotionPageFromCache(env, entityId);
await importNotionNavigation(env);
return new Response(
JSON.stringify({ status: "OK", content: { pages } }),
{
headers: globalHeaders,
}
);
case env.BLOGS_DB:
const blogs = await deleteNotionBlogFromCache(env, entityId);
await importNotionNavigation(env);
return new Response(
JSON.stringify({ status: "OK", content: { blogs } }),
{
headers: globalHeaders,
}
);
case env.COMPANIES_DB:
const companies = await deleteNotionCompanyFromCache(env, entityId);
return new Response(
JSON.stringify({ status: "OK", content: { companies } }),
{
headers: globalHeaders,
}
);
case env.POSITIONS_DB:
const positions = await deleteNotionPositionFromCache(env, entityId);
await importNotionCompanies(env);
return new Response(
JSON.stringify({ status: "OK", content: { positions } }),
{
headers: globalHeaders,
}
);
case env.PROJECTS_DB:
const projects = await deleteNotionProjectFromCache(env, entityId);
return new Response(
JSON.stringify({ status: "OK", content: { projects } }),
{
headers: globalHeaders,
}
);
case env.CV_DB:
const cvs = await deleteNotionCvFromCache(env, entityId);
return new Response(
JSON.stringify({ status: "OK", content: { cvs } }),
{
headers: globalHeaders,
}
);
case env.THEMES_DB:
const settings = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { settings } }),
{
headers: globalHeaders,
}
);
case env.REDIRECTS_DB:
const redirects = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { redirects } }),
{
headers: globalHeaders,
}
);
case env.BRANDING_DB:
const branding = await importNotionSettings(env);
return new Response(
JSON.stringify({ status: "OK", content: { branding } }),
{
headers: globalHeaders,
}
);
}
console.log("Page Blogs with data source:", dataSourceId);
}
console.log("Unknown hook:", body.type);
return new Response(
JSON.stringify({ status: "Unknown hook", type: body.type }),
{
headers: globalHeaders,
}
);
} catch (error) {
throw error;
console.log("No body to parse. Calling both fetch requests...");
const content = await updateAllNotionData(env);
return new Response(JSON.stringify({ status: "OK", content }), {
headers: globalHeaders,
});
}
}

6
src/utils/api.js Normal file
View File

@ -0,0 +1,6 @@
import { env } from "cloudflare:workers";
export const globalHeaders = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": env.CORS_ORIGIN,
};

203
src/utils/blogs.js Normal file
View File

@ -0,0 +1,203 @@
import { extractRichText, getPlainText, transformContent } from "./notion.js";
import { addToNotionDataSource, updateNotionPage } from "./notion.js";
import _ from "lodash";
import diff from "microdiff";
import { getNavigation } from "../utils/navigation.js";
import dayjs from "dayjs";
export async function getBlogs(env, cached = false) {
try {
// If cached=true, try to get from Cloudflare Cache API first
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Blogs`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Blogs retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log("Cache miss or error, falling back to KV:", cacheError);
}
}
// Fall back to KV storage
const kvData = await env.CONTENT_KV.get(env.BLOGS_KEY, {
type: "json",
});
// If we were trying to use cache but it failed, store the KV data in cache
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Blogs`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"Blogs-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Blogs stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn("Error storing in cache after KV fallback:", cacheError);
}
}
return kvData || [];
} catch (error) {
console.log("Error fetching Blogs cache:", error);
return null;
}
}
export async function storeBlogs(env, Blogs) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.BLOGS_KEY, JSON.stringify(Blogs));
console.log("Blogs stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(`https://${env.CACHE_URL || "cache"}/Blogs`);
await cache.delete(cacheKey);
console.log("Blogs cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing Blogs cache:", error);
throw error;
}
}
export function diffBlogs(newList, oldList) {
// Keys to check for changes (set to null to check all keys)
const keysToCheck = [
"name",
"maxOccupancy",
"bedrooms",
"bathrooms",
"address",
"features",
"minPrice",
"maxPrice",
"timezone",
"doubleBeds",
"singleBeds",
"sofaBeds",
"couches",
"childBeds",
"queenSizeBeds",
"kingSizeBeds",
];
// Helper: index by smoobuId using lodash
const oldById = _.keyBy(oldList || [], "smoobuId");
const newById = _.keyBy(newList || [], "smoobuId");
// toAdd: in newList but not in oldList
const toAdd = newList.filter((p) => !oldById[p.smoobuId]);
// toDelete: in oldList but not in newList
const toDelete = oldList.filter((p) => !newById[p.smoobuId]);
// toUpdate: in both, but with different content (using microdiff)
const toUpdate = newList.filter((p) => {
const old = oldById[p.smoobuId];
if (!old) return false;
// If syncName is false, exclude name from comparison
const { smoobuId, notionId, name, ...restNew } = p;
const { smoobuId: _, notionId: __, name: oldName, ...restOld } = old;
// Prepare objects for comparison
let newObj, oldObj;
if (old.syncName === false) {
// Don't compare names when syncName is false
newObj = restNew;
oldObj = restOld;
} else {
// Include name in comparison when syncName is true or undefined
newObj = { name, ...restNew };
oldObj = { name: oldName, ...restOld };
}
// Use microdiff to get differences
const differences = diff(oldObj, newObj);
// If no keysToCheck specified, return true if any differences found
if (!keysToCheck) {
return differences.length > 0;
}
// Filter differences to only include keys we care about
const relevantDifferences = differences.filter((change) => {
// Check if the changed path starts with any of our keysToCheck
const path = change.path.join(".");
return keysToCheck.some((key) => path.startsWith(key));
});
return relevantDifferences.length > 0;
});
return { toAdd, toUpdate, toDelete };
}
// Transform Notion blog data to desired format
export async function transformNotionBlog(env, notionBlog) {
const navigationItems = await getNavigation(env);
const properties = notionBlog.properties;
console.log("Properties:", properties);
const published = properties["Published"].checkbox || false;
const dateRaw = properties["Date"].date;
const date = dateRaw?.start
? dayjs(dateRaw.start).format("DD/MM/YY h:mma")
: null;
// Extract image URLs (handle both external and file images)
const images = (properties["Images"]?.files || [])
.map((f) => f?.external?.url || f?.file?.url)
.filter(Boolean);
// Extract slug from formula
const slug = properties.Slug?.formula?.string || "unknown";
const subTitle = getPlainText(properties["Subtitle"]);
// Extract name from title
const name = properties.Name?.title?.[0]?.plain_text || "Untitled";
// Fetch and transform the actual page content from Notion blocks
const content = await transformContent(env, notionBlog.id, navigationItems);
return {
notionId: notionBlog.id,
name: name,
subTitle: subTitle,
images: images,
slug: slug,
date: date,
content,
published,
};
}

110
src/utils/blurHash.js Normal file
View File

@ -0,0 +1,110 @@
import { encode } from "blurhash";
import JPEG from "jpeg-js";
import { env } from "cloudflare:workers";
// Function to decode JPEG image data using jpeg-js (Cloudflare Workers compatible)
export function decodeJPEG(arrayBuffer) {
try {
const buffer = new Uint8Array(arrayBuffer);
console.log("Decoding JPG...");
const jpeg = JPEG.decode(buffer, { useTArray: true });
console.log("Decoded JPG.");
if (!jpeg) {
throw new Error("Failed to decode JPEG");
}
return {
data: jpeg.data,
width: jpeg.width,
height: jpeg.height,
};
} catch (error) {
throw new Error(`JPEG decode error: ${error.message}`);
}
}
// Function to detect image type from URL or content (only JPEG)
export function detectImageType(url) {
const urlLower = url.toLowerCase();
if (urlLower.includes(".jpg") || urlLower.includes(".jpeg")) {
return "jpeg";
}
// Default to JPEG for backward compatibility
return "jpeg";
}
// Function to fetch an image and generate a blur hash (no caching, JPEG only)
export async function fetchBlurHash(url) {
try {
if (env.BLUR_HASH === "false") {
console.log("Blur hash disabled");
return "LDMZ?kMaD$?w.9R:NIIU=V?bw[RP";
}
console.log("Fetching URL for blur hash:", url);
const options = {
cf: {
image: {
compression: "fast",
width: 100,
height: 100,
fit: "cover",
quality: 80,
format: "jpeg",
},
},
};
const response = await fetch(url, options);
console.log("Fetch complete.");
if (!response.ok) {
console.log("Failed to fetch image:", response.status);
return null;
}
let arrayBuffer = await response.arrayBuffer();
const transformed = await env.IMAGES.input(arrayBuffer)
.transform({ width: 128, height: 128 }) // can combine width/height
.output({ format: "image/jpeg" });
const compressedArrayBuffer =
(await transformed.arrayBuffer?.()) ||
(await transformed.response().arrayBuffer());
const fileSizeMB = (
compressedArrayBuffer.byteLength /
(1024 * 1024)
).toFixed(2);
console.log(
"File size:",
compressedArrayBuffer.byteLength,
"bytes (",
fileSizeMB,
"MB)",
);
// Decode JPEG
const decodedImage = decodeJPEG(compressedArrayBuffer);
const { data, width, height } = decodedImage;
// Generate blur hash
console.log("Encoding blur hash...");
const blurHash = encode(data, width, height, 4, 3);
console.log("Encoded blur hash.");
// Clear memory
console.log("Clearing memory...");
data.fill(0);
arrayBuffer = null;
return blurHash;
} catch (error) {
console.error(`Error generating blur hash for ${url}:`, error);
return null;
}
}

237
src/utils/companies.js Normal file
View File

@ -0,0 +1,237 @@
import { getItemByNotionId, transformContent, sortDuration } from "./notion.js";
import _ from "lodash";
import diff from "microdiff";
import { getNavigation } from "../utils/navigation.js";
import { processNotionIcon } from "./icon.js";
export async function getCompanies(env, cached = false) {
try {
// If cached=true, try to get from Cloudflare Cache API first
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Companies`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Companies retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log("Cache miss or error, falling back to KV:", cacheError);
}
}
// Fall back to KV storage
const kvData = await env.CONTENT_KV.get(env.COMPANIES_KEY, {
type: "json",
});
// If we were trying to use cache but it failed, store the KV data in cache
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Companies`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"Companies-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Companies stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn("Error storing in cache after KV fallback:", cacheError);
}
}
return kvData || [];
} catch (error) {
console.log("Error fetching Companies cache:", error);
return null;
}
}
export async function storeCompanies(env, Companies) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.COMPANIES_KEY, JSON.stringify(Companies));
console.log("Companies stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Companies`
);
await cache.delete(cacheKey);
console.log("Companies cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing Companies cache:", error);
throw error;
}
}
export function diffCompanies(newList, oldList) {
// Keys to check for changes
const keysToCheck = [
"name",
"theme",
"published",
"logo",
"image",
"content",
];
// Helper: index by notionId using lodash
const oldById = _.keyBy(oldList || [], "notionId");
const newById = _.keyBy(newList || [], "notionId");
// toAdd: in newList but not in oldList
const toAdd = newList.filter((p) => !oldById[p.notionId]);
// toDelete: in oldList but not in newList
const toDelete = oldList.filter((p) => !newById[p.notionId]);
// toUpdate: in both, but with different content (using microdiff)
const toUpdate = newList.filter((p) => {
const old = oldById[p.notionId];
if (!old) return false;
// Prepare objects for comparison
const { notionId, ...restNew } = p;
const { notionId: _, ...restOld } = old;
// Use microdiff to get differences
const differences = diff(restOld, restNew);
// Filter differences to only include keys we care about
const relevantDifferences = differences.filter((change) => {
// Check if the changed path starts with any of our keysToCheck
const path = change.path.join(".");
return keysToCheck.some((key) => path.startsWith(key));
});
return relevantDifferences.length > 0;
});
return { toAdd, toUpdate, toDelete };
}
// Transform Notion company data to desired format
export async function transformNotionCompany(
env,
notionCompany,
settingsData,
positionsData
) {
const navigationItems = await getNavigation(env);
const properties = notionCompany.properties;
// Extract theme information
let theme = undefined; // default
if (properties.Theme?.relation?.[0]?.id) {
const themeId = properties.Theme.relation[0].id;
const themeData = getItemByNotionId(settingsData.themes, themeId);
if (themeData?.name) {
theme = themeData.name;
}
}
const published = properties["Published"]?.checkbox || false;
// Extract external link
const externalLink = properties["External Link"]?.url || null;
// Extract image URL (handle both external and file images)
const imageFile = notionCompany?.cover;
const image = imageFile?.external?.url || imageFile?.file?.url || null;
// Extract logo URL (handle both external and file images)
const logoFiles = properties["Logo"]?.files || [];
const logo = logoFiles[0]?.file
? await processNotionIcon(logoFiles[0].file, "tb-company-logo")
: undefined;
// Extract name from title
const name = properties.Name?.title?.[0]?.plain_text || "Untitled";
// Extract slug from formula
const slug = properties.Slug?.formula?.string || "unknown";
// Fetch and transform the actual page content from Notion blocks
const content = [{ type: "image", url: image }];
const companyContent = await transformContent(
env,
notionCompany.id,
navigationItems
);
content.push(...companyContent);
const filteredPositions = positionsData
.filter((position) => position.company === notionCompany.id)
.sort(sortDuration);
// Add positions timeline at the end if positions are provided
if (filteredPositions && filteredPositions.length > 0) {
// Filter positions for this company based on company name
const timelinePositions = filteredPositions.map((position) => ({
type: "positionTimelineItem",
name: position.name,
duration: position.duration,
content: position?.content || [],
}));
if (timelinePositions.length > 0) {
content.push({
type: "positionsTimeline",
children: timelinePositions,
});
}
}
if (published == false) {
return null;
}
const positionsList = filteredPositions.map((position) => ({
name: position.name,
notionId: position.notionId,
}));
const duration = {
start: filteredPositions[filteredPositions.length - 1]?.duration?.start,
end: filteredPositions[0]?.duration?.end,
};
return {
notionId: notionCompany.id,
name: name,
theme: theme,
published,
logo: logo,
image: image,
slug: slug,
positions: positionsList,
externalLink: externalLink,
duration: duration,
content,
};
}

44
src/utils/contentCache.js Normal file
View File

@ -0,0 +1,44 @@
import { getImages } from "./imageCache.js";
import { getPages } from "./pages.js";
import { getBlogs } from "./blogs.js";
import { getSettings } from "./settings.js";
import { getProjects } from "./projects.js";
import { getCompanies } from "./companies.js";
import { getCvs } from "./cv.js";
import { getFiles } from "./fileCache.js";
import { getVideos } from "./videoCache.js";
export async function getCombinedCachedContent(env) {
try {
// Get current images from image cache
const cachedImages = await getImages(env, true);
const cachedFiles = await getFiles(env, true);
const cachedVideos = await getVideos(env, true);
const cachedPages = await getPages(env, true);
const cachedBlogs = await getBlogs(env, true);
const cachedSettings = await getSettings(env, true);
const cachedProjects = await getProjects(env, true);
const cachedCompanies = await getCompanies(env, true);
const cachedCvs = await getCvs(env, true);
// Combine content with current images
const content = {
pages: cachedPages,
blogs: cachedBlogs,
settings: cachedSettings,
images: cachedImages,
files: cachedFiles,
videos: cachedVideos,
projects: cachedProjects,
companies: cachedCompanies,
cvs: cachedCvs,
};
console.log("Serving content...");
return content;
return null;
} catch (error) {
console.log("Error getting combined cached content:", error);
return null;
}
}

154
src/utils/cv.js Normal file
View File

@ -0,0 +1,154 @@
import { getPlainText, toCamelCase } from "./notion.js";
import _ from "lodash";
import diff from "microdiff";
export async function getCvs(env, cached = false) {
try {
// If cached=true, try to get from Cloudflare Cache API first
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(`https://${env.CACHE_URL || "cache"}/CVs`);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("CVs retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log("Cache miss or error, falling back to KV:", cacheError);
}
}
// Fall back to KV storage
const kvData = await env.CONTENT_KV.get(env.CV_KEY, {
type: "json",
});
// If we were trying to use cache but it failed, store the KV data in cache
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(`https://${env.CACHE_URL || "cache"}/CVs`);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"CVs-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("CVs stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn("Error storing in cache after KV fallback:", cacheError);
}
}
return kvData || [];
} catch (error) {
console.log("Error fetching CVs cache:", error);
return null;
}
}
export async function storeCvs(env, Cvs) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.CV_KEY, JSON.stringify(Cvs));
console.log("CVs stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(`https://${env.CACHE_URL || "cache"}/CVs`);
await cache.delete(cacheKey);
console.log("CVs cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing CVs cache:", error);
throw error;
}
}
export function diffCvs(newList, oldList) {
// Keys to check for changes
const keysToCheck = ["version", "type", "published", "files"];
// Helper: index by notionId using lodash
const oldById = _.keyBy(oldList || [], "notionId");
const newById = _.keyBy(newList || [], "notionId");
// toAdd: in newList but not in oldList
const toAdd = newList.filter((p) => !oldById[p.notionId]);
// toDelete: in oldList but not in newList
const toDelete = oldList.filter((p) => !newById[p.notionId]);
// toUpdate: in both, but with different content (using microdiff)
const toUpdate = newList.filter((p) => {
const old = oldById[p.notionId];
if (!old) return false;
// Prepare objects for comparison
const { notionId, ...restNew } = p;
const { notionId: _, ...restOld } = old;
// Use microdiff to get differences
const differences = diff(restOld, restNew);
// Filter differences to only include keys we care about
const relevantDifferences = differences.filter((change) => {
// Check if the changed path starts with any of our keysToCheck
const path = change.path.join(".");
return keysToCheck.some((key) => path.startsWith(key));
});
return relevantDifferences.length > 0;
});
return { toAdd, toUpdate, toDelete };
}
// Transform Notion CV data to desired format
export async function transformNotionCv(env, notionCv) {
const properties = notionCv.properties;
console.log("Notion CV:", notionCv);
const published = properties["Published"]?.checkbox || false;
// Extract version (text)
const version = getPlainText(properties["Version"]);
// Extract type (select)
const type = toCamelCase(properties["Type"]?.select?.name || null);
const date = properties["Date"]?.date?.start || undefined;
// Extract files (files array)
const files = (properties["Files"]?.files || [])
.map((f) => f?.external?.url || f?.file?.url)
.filter(Boolean);
if (published == false) {
return null;
}
return {
notionId: notionCv.id,
version: version,
type: type,
published: published,
files: files,
date: date,
};
}

369
src/utils/fileCache.js Normal file
View File

@ -0,0 +1,369 @@
function generateFileId(url) {
const urlObj = new URL(url);
const origin = urlObj.origin;
const pathname = urlObj.pathname;
const combined = `${origin}${pathname}`;
let hash = 0;
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
const positiveHash = Math.abs(hash);
const base36Hash = positiveHash.toString(36);
return base36Hash.padStart(8, "0").substring(0, 12);
}
function generateR2KeyFromFileId(fileId, url) {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split("/").pop() || "file";
const extension = filename.includes(".") ? filename.split(".").pop() : "";
const baseFilename = filename.replace(/\.[^/.]+$/, "");
return extension
? `files/${fileId}-${baseFilename}.${extension}`
: `files/${fileId}-${baseFilename}`;
}
function extractR2KeyFromUrl(mirrorUrl) {
const urlObj = new URL(mirrorUrl);
return urlObj.pathname.substring(1);
}
export async function getFiles(env, cached = false) {
try {
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/files`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Files retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log(
"File cache miss or error, falling back to KV:",
cacheError
);
}
}
const kvData = await env.CONTENT_KV.get(env.FILES_KEY, {
type: "json",
});
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/files`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60",
ETag: `"files-${Date.now()}"`,
},
});
await cache.put(cacheKey, response);
console.log("Files stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn(
"Error storing files in cache after KV fallback:",
cacheError
);
}
}
return kvData || [];
} catch (error) {
console.log("Error fetching files cache:", error);
return null;
}
}
export async function updateFiles(env, currentFileUrls) {
console.log("Updating file cache...");
const currentFiles = currentFileUrls.map((url) => ({
id: generateFileId(url),
url,
}));
const existingFiles = await getFiles(env);
const existingFileMap = new Map(existingFiles.map((file) => [file.id, file]));
const filesToAdd = currentFiles.filter(
(file) => !existingFileMap.has(file.id)
);
const currentFileMap = new Map(currentFiles.map((file) => [file.id, file]));
const filesToKeep = existingFiles
.filter((file) => currentFileMap.has(file.id))
.map((file) => ({
...file,
url: currentFileMap.get(file.id).url,
}));
const filesToRemove = existingFiles.filter(
(file) => !currentFileMap.has(file.id)
);
console.log(
`Files to add: ${filesToAdd.length}, Files to keep: ${filesToKeep.length}, Files to remove: ${filesToRemove.length}`
);
for (const file of filesToRemove) {
if (file.mirrorUrl) {
try {
const key = extractR2KeyFromUrl(file.mirrorUrl);
await env.TB_STORAGE.delete(key);
console.log(`Deleted ${key} from R2 for file ID ${file.id}`);
} catch (error) {
console.error(
`Failed to delete ${file.mirrorUrl} from R2 for file ID ${file.id}:`,
error
);
}
}
}
const newFiles = [];
for (let i = 0; i < filesToAdd.length; i++) {
const fileData = filesToAdd[i];
const { id, url } = fileData;
console.log(
`Uploading file to R2 (${i + 1}/${filesToAdd.length}): ${url} (ID: ${id})`
);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status}`);
}
const contentType = response.headers.get("content-type") || null;
const contentLengthHeader = response.headers.get("content-length");
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: null;
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split("/").pop() || null;
const key = generateR2KeyFromFileId(id, url);
const putOptions = {
httpMetadata: {},
};
if (contentType) {
putOptions.httpMetadata.contentType = contentType;
}
if (Object.keys(putOptions.httpMetadata).length === 0) {
delete putOptions.httpMetadata;
}
const result = await env.TB_STORAGE.put(
key,
response.body,
Object.keys(putOptions).length ? putOptions : undefined
);
if (result == null) {
throw new Error(`Failed to upload file to R2: ${key}`);
}
const mirrorUrl = `${env.R2_PUBLIC_URL}/${key}`;
const metadata =
contentType || contentLength || filename
? {
contentType,
contentLength,
filename,
}
: null;
newFiles.push({
id,
url,
metadata,
mirrorUrl,
});
console.log(
`Successfully uploaded and mirrored file: ${url} -> ${mirrorUrl} (ID: ${id})`
);
} catch (error) {
console.error(`Failed to upload ${url} to R2 (ID: ${id}):`, error);
newFiles.push({
id,
url,
metadata: null,
mirrorUrl: null,
});
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const updatedFiles = [...filesToKeep, ...newFiles];
await storeFiles(env, updatedFiles);
return updatedFiles;
}
export async function storeFiles(env, files) {
try {
await env.CONTENT_KV.put(env.FILES_KEY, JSON.stringify(files));
console.log("Files stored in KV.");
try {
const cache = caches.default;
const cacheKey = new Request(`https://${env.CACHE_URL || "cache"}/files`);
await cache.delete(cacheKey);
console.log("Files cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare file cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing files cache:", error);
throw error;
}
}
function findFileChildren(obj, fileUrls = []) {
if (!obj || typeof obj !== "object") {
return fileUrls;
}
if (obj.type === "file") {
if (obj.url) {
fileUrls.push(obj.url);
} else if (obj.href) {
fileUrls.push(obj.href);
} else if (obj.file) {
fileUrls.push(obj.file);
}
}
if (obj.children && Array.isArray(obj.children)) {
for (const child of obj.children) {
findFileChildren(child, fileUrls);
}
}
for (const [key, value] of Object.entries(obj)) {
if (
key !== "children" &&
key !== "content" &&
key !== "blocks" &&
typeof value === "object"
) {
findFileChildren(value, fileUrls);
}
}
return fileUrls;
}
export function collectFileUrls(contentObjects) {
const fileUrls = [];
for (const object of contentObjects) {
if (object.file) {
fileUrls.push(object.file);
}
if (object.files) {
object.files.forEach((url) => fileUrls.push(url));
}
if (object.content) {
findFileChildren(object.content, fileUrls);
}
}
return fileUrls;
}
export async function handleFileMetadataUpdate(request, env) {
const cachedFiles = await env.CONTENT_KV.get(env.FILES_KEY, {
type: "json",
});
if (!cachedFiles) return;
let updated = false;
for (const file of cachedFiles) {
if (
!file.metadata ||
!file.metadata.contentType ||
!file.metadata.contentLength
) {
const targetUrl = file.mirrorUrl || file.url;
try {
const response = await fetch(targetUrl, { method: "HEAD" });
if (response.ok) {
const contentType = response.headers.get("content-type") || null;
const contentLengthHeader = response.headers.get("content-length");
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: null;
const urlObj = new URL(file.url);
const pathname = urlObj.pathname;
const filename = pathname.split("/").pop() || null;
if (contentType || contentLength || filename) {
file.metadata = {
contentType,
contentLength,
filename,
};
updated = true;
console.log(
`Updated metadata for file ID ${file.id} (${file.url})`
);
}
}
} catch (error) {
console.warn(
`Failed to update metadata for file ID ${file.id} (${file.url}):`,
error
);
}
}
}
if (updated) {
await env.CONTENT_KV.put(env.FILES_KEY, JSON.stringify(cachedFiles));
}
return cachedFiles;
}
export function getFileById(cachedFiles, fileId) {
return cachedFiles.find((file) => file.id === fileId);
}
export function getFileIdFromUrl(url) {
return generateFileId(url);
}

72
src/utils/icon.js Normal file
View File

@ -0,0 +1,72 @@
import { parse, stringify } from "svgson";
async function processSVG(svgText, cssClass) {
try {
// Parse SVG to JSON
const json = await parse(svgText);
// Remove all fill attributes and fill styles recursively
const removeFill = (node) => {
if (node.attributes?.fill) delete node.attributes.fill;
// Remove fill from inline styles
if (node.attributes?.style) {
const styles = node.attributes.style
.split(";")
.map((s) => s.trim())
.filter((s) => s && !s.toLowerCase().startsWith("fill:"))
.join("; ");
if (styles) {
node.attributes.style = styles;
} else {
delete node.attributes.style;
}
}
if (node.children) node.children.forEach(removeFill);
};
removeFill(json);
// Add class to root <svg>
json.attributes.class = cssClass;
// Convert back to SVG string
return stringify(json);
} catch (err) {
throw new Error(`Error processing SVG: ${err.message}`);
}
}
// processNotionIcon fetches and validates the SVG
export async function processNotionIcon(iconObj, cssClass = "") {
if (iconObj?.external?.url && iconObj?.url) {
return undefined;
}
const url = iconObj?.external?.url || iconObj?.file?.url || iconObj.url;
const urlObject = new URL(url);
// Fetch the file headers first
console.log("Fetching icon:", urlObject.origin + urlObject.pathname);
const svgResponse = await fetch(url, { method: "GET" });
const contentType = svgResponse.headers.get("content-type") || "";
if (!contentType.includes("image/svg+xml")) {
console.log(`Not an SVG file (content-type was ${contentType}).`);
return `<img src="${url}" alt="Page Icon" class="${cssClass}" />`;
}
console.log("Fetched SVG:", urlObject.origin + urlObject.pathname);
if (!svgResponse.ok) {
throw new Error(
`Failed to fetch SVG: ${svgResponse.status} ${svgResponse.statusText}`
);
}
const svgText = await svgResponse.text();
const processedSVG = processSVG(svgText, cssClass);
return processedSVG;
}

349
src/utils/imageCache.js Normal file
View File

@ -0,0 +1,349 @@
import { fetchBlurHash } from "./blurHash";
function generateImageId(url) {
const urlObj = new URL(url);
const origin = urlObj.origin;
const pathname = urlObj.pathname;
// Create a consistent hash from origin + pathname
const combined = `${origin}${pathname}`;
// Simple hash function for better distribution
let hash = 0;
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
// Convert to positive number and create readable ID
const positiveHash = Math.abs(hash);
const base36Hash = positiveHash.toString(36);
// Pad with zeros if needed and limit length
return base36Hash.padStart(8, "0").substring(0, 12);
}
// Generate R2 key from image ID and original URL
function generateR2KeyFromImageId(imageId, url) {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split("/").pop() || "image";
const extension = filename.includes(".") ? filename.split(".").pop() : "jpg";
const baseFilename = filename.replace(/\.[^/.]+$/, "");
return `images/${imageId}-${baseFilename}.${extension}`;
}
function extractR2KeyFromUrl(mirrorUrl) {
// Extract the key from a mirror URL like "https://r2-domain.com/images/abc123-image.jpg"
const urlObj = new URL(mirrorUrl);
return urlObj.pathname.substring(1); // Remove leading slash
}
export async function getImages(env, cached = false) {
try {
// If cached=true, try to get from Cloudflare Cache API first
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/images`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Images retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log("Cache miss or error, falling back to KV:", cacheError);
}
}
// Fall back to KV storage
const kvData = await env.CONTENT_KV.get(env.IMAGES_KEY, {
type: "json",
});
// If we were trying to use cache but it failed, store the KV data in cache
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/images`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"images-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Images stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn("Error storing in cache after KV fallback:", cacheError);
}
}
return kvData || [];
} catch (error) {
console.log("Error fetching images cache:", error);
return null;
}
}
export async function updateImages(env, currentImageUrls) {
console.log("Updating image cache...");
// Convert URLs to image objects with IDs
const currentImages = currentImageUrls.map((url) => ({
id: generateImageId(url),
url,
}));
// Get existing image cache
const existingImages = await getImages(env);
const existingImageMap = new Map(existingImages.map((img) => [img.id, img]));
// Find images to add (current images with IDs not in cache)
const imagesToAdd = currentImages.filter(
(img) => !existingImageMap.has(img.id)
);
// Find images to keep (existing images with IDs in current set)
const currentImageMap = new Map(currentImages.map((img) => [img.id, img]));
const imagesToKeep = existingImages
.filter((img) => currentImageMap.has(img.id))
.map((img) => ({
...img,
// Update URL in case it has changed (different query params, etc.)
url: currentImageMap.get(img.id).url,
}));
// Find images to remove (existing images with IDs not in current set)
const imagesToRemove = existingImages.filter(
(img) => !currentImageMap.has(img.id)
);
console.log(
`Images to add: ${imagesToAdd.length}, Images to keep: ${imagesToKeep.length}, Images to remove: ${imagesToRemove.length}`
);
// Delete removed images from R2
for (const image of imagesToRemove) {
if (image.mirrorUrl) {
try {
const key = extractR2KeyFromUrl(image.mirrorUrl);
await env.TB_STORAGE.delete(key);
console.log(`Deleted ${key} from R2 for image ID ${image.id}`);
} catch (error) {
console.error(
`Failed to delete ${image.mirrorUrl} from R2 for image ID ${image.id}:`,
error
);
}
}
}
// Upload new images to R2 and create mirror URLs
const newImages = [];
for (let i = 0; i < imagesToAdd.length; i++) {
const imageData = imagesToAdd[i];
const { id, url } = imageData;
console.log(
`Uploading image to R2 (${i + 1}/${
imagesToAdd.length
}): ${url} (ID: ${id})`
);
try {
// Fetch the image
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
// Generate R2 key from image ID
const key = generateR2KeyFromImageId(id, url);
const result = await env.TB_STORAGE.put(key, response.body);
if (result == null) {
throw new Error(`Failed to upload image to R2: ${key}`);
}
// Create mirror URL
const mirrorUrl = `${env.R2_PUBLIC_URL}/${key}`;
newImages.push({
id,
url,
blurHash: null,
mirrorUrl,
});
console.log(
`Successfully uploaded and mirrored: ${url} -> ${mirrorUrl} (ID: ${id})`
);
} catch (error) {
console.error(`Failed to upload ${url} to R2 (ID: ${id}):`, error);
// Add image without mirror URL if upload fails
newImages.push({
id,
url,
blurHash: null,
mirrorUrl: null,
});
}
// Small delay to avoid overwhelming the system
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Combine kept images with new images
const updatedImages = [...imagesToKeep, ...newImages];
// Store updated image cache
await storeImages(env, updatedImages);
return updatedImages;
}
export async function storeImages(env, images) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.IMAGES_KEY, JSON.stringify(images));
console.log("Images stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/images`
);
await cache.delete(cacheKey);
console.log("Images cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing images cache:", error);
throw error;
}
}
// Recursively traverse objects to find children with type: "image"
function findImageChildren(obj, imageUrls = []) {
if (!obj || typeof obj !== "object") {
return imageUrls;
}
// Check if this object has type: "image"
if (obj.type === "image") {
// Extract image URL from various possible properties
if (obj.url) {
imageUrls.push(obj.url);
} else if (obj.src) {
imageUrls.push(obj.src);
} else if (obj.image) {
imageUrls.push(obj.image);
}
}
// Recursively check children array
if (obj.children && Array.isArray(obj.children)) {
for (const child of obj.children) {
findImageChildren(child, imageUrls);
}
}
// Recursively check all other object properties
for (const [key, value] of Object.entries(obj)) {
if (
key !== "children" &&
key !== "content" &&
key !== "blocks" &&
typeof value === "object"
) {
findImageChildren(value, imageUrls);
}
}
return imageUrls;
}
export function collectImageUrls(contentObjects) {
const imageUrls = [];
// Collect from pages
for (const object of contentObjects) {
if (object.image) {
imageUrls.push(object.image);
}
if (object.images) {
object.images.forEach((url) => imageUrls.push(url));
}
if (object.content) {
// Use recursive function to find all image children
findImageChildren(object.content, imageUrls);
}
}
return imageUrls;
}
export async function handleBlurhashUpdate(request, env) {
// Read the image cache
const cachedImages = await env.CONTENT_KV.get(env.IMAGES_KEY, {
type: "json",
});
if (!cachedImages) return;
let updated = false;
for (const image of cachedImages) {
if (image.blurHash === null) {
// Try mirrorUrl first
let blurHash = image.mirrorUrl
? await fetchBlurHash(image.mirrorUrl)
: null;
// Fallback to original URL if mirrorUrl fails
if (!blurHash) {
blurHash = await fetchBlurHash(image.url);
}
if (blurHash) {
image.blurHash = blurHash;
updated = true;
console.log(`Updated blurHash for image ID ${image.id} (${image.url})`);
}
}
}
// Store updated cache if anything changed
if (updated) {
await env.CONTENT_KV.put(env.IMAGES_KEY, JSON.stringify(cachedImages));
}
return cachedImages;
}
// Helper function to get image by ID
export function getImageById(cachedImages, imageId) {
return cachedImages.find((img) => img.id === imageId);
}
// Helper function to get image ID from URL
export function getImageIdFromUrl(url) {
return generateImageId(url);
}

90
src/utils/navigation.js Normal file
View File

@ -0,0 +1,90 @@
import { processNotionIcon } from "./icon";
export async function getNavigation(env, cached = false) {
try {
// If cached=true, try to get from Cloudflare Cache API first
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.NAVIGATION_CACHE_URL || "cache"}/navigation`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Navigation retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log("Cache miss or error, falling back to KV:", cacheError);
}
}
// Fall back to KV storage
const kvData = await env.CONTENT_KV.get(env.NAVIGATION_KEY, {
type: "json",
});
return kvData || null;
} catch (error) {
console.log("Error fetching navigation cache:", error);
return null;
}
}
export async function storeNavigation(env, navigation) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.NAVIGATION_KEY, JSON.stringify(navigation));
console.log("Navigation stored in KV.");
// Also update the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.NAVIGATION_CACHE_URL || "cache"}/navigation`
);
// Create a response with appropriate cache headers
const response = new Response(JSON.stringify(navigation), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"navigation-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Navigation stored in Cloudflare cache.");
} catch (cacheError) {
console.warn(
"Error updating Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing navigation cache:", error);
throw error;
}
}
// Transform basic Notion page data to desired format
export async function transformNavigation(notionPage, type) {
var icon = undefined;
if (notionPage.icon) {
icon = await processNotionIcon(notionPage.icon, "tb-icon");
}
const properties = notionPage.properties;
const id = notionPage.id;
const slug = properties.Slug?.formula?.string || "unknown";
// Extract name from title
const name = properties.Name?.title?.[0]?.plain_text || "Untitled";
return {
slug: type == "blog" ? `properties/${slug}` : slug,
name: name,
icon: icon,
notionId: id,
type: type,
};
}

627
src/utils/notion.js Normal file
View File

@ -0,0 +1,627 @@
import { processNotionIcon } from "./icon";
import { env } from "cloudflare:workers";
import { getNavigation } from "./navigation";
const NOTION_API_BASE = "https://api.notion.com/v1";
const NOTION_VERSION = "2025-09-03"; // or your preferred version
const iconColorSubstitutions = [
["#D44C47", "#FF453A"],
["#55534E", "#FFFFFF"],
["#448361", "#32D74B"],
["#337ea9", "#0A84FF"],
["#9065B0", "#BF5AF2"],
["#CB912F", "#FFD60A"],
["#C14C8A", "#FF375F"],
["#d9730d", "#FF9F0A"],
];
async function notionFetch(endpoint, options = {}) {
const res = await fetch(`${NOTION_API_BASE}${endpoint}`, {
headers: {
Authorization: `Bearer ${env.NOTION_AUTH}`,
"Notion-Version": NOTION_VERSION,
"Content-Type": "application/json",
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Notion API error: ${res.status} ${res.statusText} - ${text}`
);
}
return res.json();
}
export async function addToNotionDataSource(
properties,
dataSourceId,
icon = null
) {
console.log("Adding to Notion data source...");
const pageParams = {};
const notionProperties = {};
if (icon != null) {
pageParams["icon"] = {
type: "external",
external: {
url: icon,
},
};
}
for (const [key, value] of Object.entries(properties)) {
if (key === "Email" && value != "") {
notionProperties[key] = { email: value };
} else if (key === "Name" && value != "") {
notionProperties[key] = { title: [{ text: { content: value } }] };
} else if (key === "Features") {
notionProperties[key] = {
multi_select: value.map((feature) => ({
name: feature,
})),
};
} else if (key === "Message" && value != "") {
pageParams["children"] = [
{
object: "block",
type: "paragraph",
paragraph: {
rich_text: [
{
type: "text",
text: {
content: value,
},
},
],
},
},
];
} else if (typeof value === "string" && value != "") {
notionProperties[key] = {
rich_text: [{ text: { content: value } }],
};
} else if (typeof value === "number" && value != undefined) {
notionProperties[key] = { number: value };
} else if (value && typeof value === "object" && !Array.isArray(value)) {
notionProperties[key] = value;
}
}
try {
const response = await notionFetch("/pages", {
method: "POST",
body: JSON.stringify({
parent: { data_source_id: dataSourceId },
...pageParams,
properties: notionProperties,
}),
});
console.log("Added to Notion DB!");
return response;
} catch (error) {
console.error("Failed to add to Notion:", error);
throw new Error("Failed to add to Notion");
}
}
export async function queryNotionDataSource(dataSourceId, queryParams = {}) {
console.log(`Fetching data source: ${dataSourceId} from Notion API...`);
try {
const body = JSON.stringify(queryParams);
const result = await notionFetch(`/data_sources/${dataSourceId}/query`, {
method: "POST",
body,
});
console.log("Fetched data source:", dataSourceId);
return result.results;
} catch (error) {
console.error(`Error fetching Notion data source ${dataSourceId}:`, error);
throw error;
}
}
export async function getNotionDatabase(databaseId) {
console.log(`Fetching database: ${databaseId} from Notion API...`);
try {
const result = await notionFetch(`/databases/${databaseId}`, {
method: "GET",
});
return result;
} catch (error) {
console.error(`Error fetching Notion database ${databaseId}:`, error);
throw error;
}
}
export async function getNotionDataSource(dataSourceId) {
console.log(`Fetching data source: ${dataSourceId} from Notion API...`);
try {
const result = await notionFetch(`/data_sources/${dataSourceId}`, {
method: "GET",
});
return result;
} catch (error) {
console.error(`Error fetching Notion data source ${dataSourceId}:`, error);
throw error;
}
}
export async function getNotionPage(pageId) {
console.log(`Fetching page: ${pageId} from Notion API...`);
try {
const result = await notionFetch(`/pages/${pageId}`, {
method: "GET",
});
return result;
} catch (error) {
console.error(`Error fetching Notion page ${pageId}:`, error);
throw error;
}
}
export async function updateNotionPage(
pageId,
properties,
archive = false,
trash = false
) {
console.log(`Updating page: ${pageId} in Notion API...`);
const notionProperties = {};
for (const [key, value] of Object.entries(properties)) {
if (key === "Email" && value != "") {
notionProperties[key] = { email: value };
} else if (key === "Name") {
notionProperties[key] = { title: [{ text: { content: value } }] };
} else if (key === "Features") {
notionProperties[key] = {
multi_select: value.map((feature) => ({
name: feature,
})),
};
} else if (typeof value === "string" && value != "") {
notionProperties[key] = {
rich_text: [{ text: { content: value } }],
};
} else if (typeof value === "number" && value != undefined) {
notionProperties[key] = { number: value };
} else if (value && typeof value === "object" && !Array.isArray(value)) {
notionProperties[key] = value;
}
}
try {
const result = await notionFetch(`/pages/${pageId}`, {
method: "PATCH",
body: JSON.stringify({
properties: notionProperties,
archived: archive,
in_trash: trash,
}),
});
return result;
} catch (error) {
console.error(`Error updating Notion page ${pageId}:`, error);
throw error;
}
}
export async function getNotionBlocks(pageId, queryParams = {}) {
console.log(`Fetching blocks for page: ${pageId} from Notion API...`);
try {
const result = await notionFetch(
`/blocks/${pageId}/children?${new URLSearchParams(queryParams)}`
);
return result.results;
} catch (error) {
console.error(`Error fetching Notion page ${pageId}:`, error);
throw error;
}
}
export async function getNotionSVGIcon(url) {
try {
console.log(`Fetching SVG icon from: ${url}`);
const response = await fetch(url);
if (!response.ok)
throw new Error(`Failed to fetch SVG: ${response.statusText}`);
let svgText = await response.text();
iconColorSubstitutions.forEach(([searchColor, replaceColor]) => {
svgText = svgText.replaceAll(searchColor, replaceColor);
});
return svgText;
} catch (error) {
console.error(error);
return null;
}
}
// Transform Notion blocks to content format
export async function transformContent(env, pageId, navigationItems) {
try {
console.log(`Transforming content for page: ${pageId}`);
// Fetch blocks from Notion
const blocks = await getNotionBlocks(pageId);
const content = [];
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const blockType = block.type;
// Group consecutive list items
if (
blockType === "bulleted_list_item" ||
blockType === "numbered_list_item"
) {
const isNumbered = blockType === "numbered_list_item";
const children = [];
// Collect consecutive list items of the same type
while (
i < blocks.length &&
blocks[i].type ===
(isNumbered ? "numbered_list_item" : "bulleted_list_item")
) {
children.push({
type: "listItem",
text: await extractRichText(
isNumbered
? blocks[i].numbered_list_item.rich_text
: blocks[i].bulleted_list_item.rich_text,
navigationItems
),
});
i++;
}
// Step back one index because the for loop will increment it
i--;
content.push({
type: "list",
ordered: isNumbered,
children,
});
continue;
}
switch (blockType) {
case "heading_1":
content.push({
type: "title1",
text: await extractRichText(
block.heading_1.rich_text,
navigationItems
),
});
break;
case "heading_2":
content.push({
type: "title2",
text: await extractRichText(
block.heading_2.rich_text,
navigationItems
),
});
break;
case "heading_3":
content.push({
type: "title3",
text: await extractRichText(
block.heading_3.rich_text,
navigationItems
),
});
break;
case "paragraph":
content.push({
type: "paragraph",
text: await extractRichText(
block.paragraph.rich_text,
navigationItems
),
});
break;
case "divider":
content.push({
type: "divider",
});
break;
case "image":
content.push({
type: "image",
url: block.image.external?.url || block.image.file?.url,
caption: block.image.caption
? await extractRichText(block.image.caption, navigationItems)
: null,
});
break;
case "quote":
content.push({
type: "quote",
text: await extractRichText(block.quote.rich_text, navigationItems),
});
break;
case "code":
content.push({
type: "code",
text: await extractRichText(block.code.rich_text, navigationItems),
language: block.code.language,
});
break;
case "bookmark":
content.push({
type: "button",
text: await extractRichText(
block.bookmark.caption,
navigationItems
),
url: block.bookmark.url,
});
break;
case "callout":
console.log("Callout:", block);
var calloutChildren = [];
if (block.callout?.rich_text && block.callout.rich_text.length > 0) {
const text = await extractRichText(
block.callout.rich_text,
navigationItems
);
calloutChildren.push({
type: "paragraph",
text: text,
});
}
if (block.has_children == true) {
const children = await transformContent(
env,
block.id,
navigationItems
);
calloutChildren.push(...children);
}
content.push({
type: "callout",
children: calloutChildren,
icon: block.callout?.icon
? await processNotionIcon(block.callout.icon, "tb-callout-icon")
: undefined,
});
break;
case "video":
content.push({
type: "video",
url: block.video.file?.url || block.video?.external?.url,
});
break;
case "file":
content.push({
type: "file",
url: block.file.file?.url || block.file?.external?.url,
});
break;
case "link_to_page":
const linkedNavigationItem = getItemByNotionId(
navigationItems,
block.link_to_page.page_id
);
content.push({
type: "button",
url: `/${linkedNavigationItem.slug}`,
text: linkedNavigationItem.name,
icon: linkedNavigationItem.icon,
});
break;
case "column_list":
const childColumns = await getNotionBlocks(block.id);
const children = [];
for (const childColumn of childColumns) {
children.push({
type: "columnFlexItem",
width: childColumn.column.width_ratio * 100 + "%",
children: await transformContent(
env,
childColumn.id,
navigationItems
),
});
}
content.push({ type: "columnFlex", children: children });
break;
default:
// For unsupported block types, try to extract text if available
console.log(`Unsupported block type: ${blockType}`);
console.log(JSON.stringify(block));
break;
}
}
return content;
} catch (error) {
console.error(`Error transforming page content for ${pageId}:`, error);
// Return fallback content if transformation fails
return [
{
type: "paragraph",
text: "Content could not be loaded at this time.",
},
];
}
}
function processLink(href, navigationItems) {
var pageIdNoDashes = null;
if (href.startsWith("/")) {
pageIdNoDashes = href.split("/").pop().replaceAll("#", "");
}
if (href.includes("notion.so/")) {
if (href.includes("-")) {
pageIdNoDashes = href.split("-").pop().replaceAll("#", "");
} else {
pageIdNoDashes = href.split("/").pop().replaceAll("#", "");
}
}
if (pageIdNoDashes != null) {
const linkedNavigationItem = getItemByNotionId(
navigationItems,
pageIdNoDashes
);
if (!linkedNavigationItem.slug) {
return {
type: "externalLink",
url: href,
};
}
return {
type: "internalLink",
url: `/${linkedNavigationItem.slug}`,
};
} else {
return {
type: "externalLink",
url: href,
};
}
}
// Helper function to extract rich text content
export async function extractRichText(richTextArray, navigationItems) {
if (!richTextArray || !Array.isArray(richTextArray)) {
return [];
}
const textPieces = await Promise.all(
richTextArray.map(async (textObj) => {
let text = textObj?.plain_text || "";
let annotations = textObj?.annotations || {};
let link = undefined;
let emoji = undefined;
// Handle custom emoji mentions
if (
textObj.type === "mention" &&
textObj.mention?.type === "custom_emoji" &&
textObj.mention.custom_emoji?.url
) {
emoji = await processNotionIcon(
textObj.mention.custom_emoji,
"tb-inline-emoji"
);
}
// Handle links
if (textObj.href) {
link = processLink(textObj.href, navigationItems);
}
return {
text: text,
bold: annotations?.bold || false,
italic: annotations?.italic || false,
underline: annotations?.underline || false,
strikethrough: annotations?.strikethrough || false,
code: annotations?.code || false,
color: annotations?.color || undefined,
link: link,
};
})
);
return textPieces;
}
// Utility: safely extract plain text from a Notion title or rich_text field
export function getPlainText(field) {
if (!field) return "";
if (field.type === "title" || field.type === "rich_text") {
return field[field.type].map((t) => t.plain_text).join("") || "";
}
return "";
}
// Utility: convert a string to camelCase
export function toCamelCase(str = "") {
return str
.replace(/[^a-zA-Z0-9 ]/g, " ") // remove non-alphanumeric except spaces
.trim()
.split(/\s+/) // split on spaces
.map((word, index) => {
if (index === 0) return word.toLowerCase();
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join("");
}
// Moved from Blogs.js: buildNewCache, now buildListCache
export function buildListCache(
cachedList,
{ added = [], updated = [], deleted = [] }
) {
// Remove deleted and items that will be updated
const filtered = cachedList.filter(
(p) =>
!deleted.some((d) => d.notionId === p.notionId) &&
!updated.some((u) => u.notionId === p.notionId)
);
return [...filtered, ...updated, ...added];
}
export function getItemByNotionId(items, notionId) {
const itemWithDashes = items.find((item) => item.notionId === notionId);
if (itemWithDashes) {
return itemWithDashes;
}
const itemWithoutDashes = items.find(
(item) => item.notionId.replaceAll("-", "") === notionId.replaceAll("-", "")
);
if (itemWithoutDashes) {
return itemWithoutDashes;
}
return null;
}
// Sort function for objects with duration properties
// Sorts by end date (newest first), with ongoing positions (no end date) first
export function sortDuration(a, b) {
const aEnd = a.duration?.end;
const bEnd = b.duration?.end;
const aStart = a.duration?.start || "";
const bStart = b.duration?.start || "";
// Positions without end date (ongoing) should be first (newest)
if (!aEnd && !bEnd) {
// Both ongoing - sort by start date, newest first
return bStart.localeCompare(aStart);
}
if (!aEnd) return -1; // a is ongoing, should be first
if (!bEnd) return 1; // b is ongoing, should be first
// Both have start dates - sort by start date, newest first
const startComparison = bStart.localeCompare(aStart);
if (startComparison !== 0) return startComparison;
return bEnd.localeCompare(aEnd);
}

214
src/utils/pages.js Normal file
View File

@ -0,0 +1,214 @@
import {
transformContent,
toCamelCase,
getItemByNotionId,
} from "../utils/notion.js";
import { getNavigation } from "../utils/navigation.js";
// Transform Notion page data to desired format
export async function getPages(env, cached = false) {
try {
// If cached=true, try to get from Cloudflare Cache API first
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/pages`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Pages retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log("Cache miss or error, falling back to KV:", cacheError);
}
}
// Fall back to KV storage
const kvData = await env.CONTENT_KV.get(env.PAGES_KEY, {
type: "json",
});
// If we were trying to use cache but it failed, store the KV data in cache
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/pages`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"pages-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Pages stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn("Error storing in cache after KV fallback:", cacheError);
}
}
return kvData || [];
} catch (error) {
console.log("Error fetching pages cache:", error);
return null;
}
}
export async function storePages(env, pages) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.PAGES_KEY, JSON.stringify(pages));
console.log("Pages stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(`https://${env.CACHE_URL || "cache"}/pages`);
await cache.delete(cacheKey);
console.log("Pages cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing pages cache:", error);
throw error;
}
}
export async function transformPageData(env, notionPage, settingsData) {
const navigationItems = await getNavigation(env);
const icon = getItemByNotionId(navigationItems, notionPage.id).icon;
const properties = notionPage.properties;
// Extract theme information
let theme = undefined; // default
if (properties.Theme?.relation?.[0]?.id) {
const themeId = properties.Theme.relation[0].id;
console.log(settingsData.themes);
const themeData = getItemByNotionId(settingsData.themes, themeId);
if (themeData?.name) {
theme = themeData.name;
}
}
const published = properties["Published"]?.checkbox || false;
// Check if page is published - if not, return null to filter it out
const pageType = toCamelCase(properties["Page Type"].select.name);
const align = toCamelCase(properties["Align"].select?.name || "center");
const justify = toCamelCase(properties["Justify"].select?.name || "middle");
// Extract slug from formula);
const slug = properties.Slug?.formula?.string || "unknown";
// Extract name from title
const name = properties.Name?.title?.[0]?.plain_text || "Untitled";
// Fetch and transform the actual page content from Notion blocks
const content = [];
const showPageIcon = properties["Show Page Icon"]?.checkbox || false;
if (showPageIcon == true) {
content.push({
type: "pageIcon",
icon: icon,
});
}
const pageContent = await transformContent(
env,
notionPage.id,
navigationItems
);
content.push(...pageContent);
const showBlogs = properties["Show Blogs"]?.checkbox || false;
const showProjects = properties["Show Projects"]?.checkbox || false;
const showExperience = properties["Show Experience"]?.checkbox || false;
const showContactForm = properties["Show Contact Form"]?.checkbox || false;
const gradientBackground =
properties["Gradient Background"]?.checkbox || false;
const showScroll = properties["Show Scroll Icon"]?.checkbox || false;
const verticalScroll = properties["Vertical Scroll"]?.checkbox || false;
const horizontalScroll = properties["Horizontal Scroll"]?.checkbox || false;
const scrollSnap = properties["Scroll Snap"]?.checkbox || false;
const paragraphWidth = properties["Paragraph Width"]?.number || 400;
const order = properties["Order"]?.number || undefined;
const spacing = properties["Spacing"]?.number || 5;
const showScrollButtons =
properties["Show Scroll Buttons"]?.checkbox || false;
const scrollButtonDistance =
properties["Scroll Button Distance"]?.number || 10;
if (showBlogs == true) {
content.push({
type: "blogs",
});
}
if (showProjects == true) {
content.push({
type: "projects",
});
}
if (showExperience == true) {
content.push({
type: "companies",
});
}
if (showContactForm == true) {
content.push({
type: "contactForm",
});
}
return {
notionId: notionPage.id,
published: published,
icon: icon,
slug: slug,
name: name,
pageType: pageType,
content: content,
theme: theme,
align: align,
justify: justify,
showScroll: showScroll,
showBlogs: showBlogs,
showProjects: showProjects,
showExperience: showExperience,
showContactForm: showContactForm,
gradientBackground: gradientBackground,
paragraphWidth: paragraphWidth,
spacing: spacing,
order: order,
verticalScroll: verticalScroll,
horizontalScroll: horizontalScroll,
scrollSnap: scrollSnap,
showScrollButtons: showScrollButtons,
scrollButtonDistance: scrollButtonDistance,
};
}

164
src/utils/positions.js Normal file
View File

@ -0,0 +1,164 @@
import { getItemByNotionId, getPlainText, transformContent } from "./notion.js";
import _ from "lodash";
import diff from "microdiff";
import { getNavigation } from "../utils/navigation.js";
export async function getPositions(env, cached = false) {
try {
// If cached=true, try to get from Cloudflare Cache API first
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Positions`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Positions retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log("Cache miss or error, falling back to KV:", cacheError);
}
}
// Fall back to KV storage
const kvData = await env.CONTENT_KV.get(env.POSITIONS_KEY, {
type: "json",
});
// If we were trying to use cache but it failed, store the KV data in cache
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Positions`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"Positions-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Positions stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn("Error storing in cache after KV fallback:", cacheError);
}
}
return kvData || [];
} catch (error) {
console.log("Error fetching Positions cache:", error);
return null;
}
}
export async function storePositions(env, Positions) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.POSITIONS_KEY, JSON.stringify(Positions));
console.log("Positions stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Positions`
);
await cache.delete(cacheKey);
console.log("Positions cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing Positions cache:", error);
throw error;
}
}
export function diffPositions(newList, oldList) {
// Keys to check for changes
const keysToCheck = ["name", "duration", "company", "content"];
// Helper: index by notionId using lodash
const oldById = _.keyBy(oldList || [], "notionId");
const newById = _.keyBy(newList || [], "notionId");
// toAdd: in newList but not in oldList
const toAdd = newList.filter((p) => !oldById[p.notionId]);
// toDelete: in oldList but not in newList
const toDelete = oldList.filter((p) => !newById[p.notionId]);
// toUpdate: in both, but with different content (using microdiff)
const toUpdate = newList.filter((p) => {
const old = oldById[p.notionId];
if (!old) return false;
// Prepare objects for comparison
const { notionId, ...restNew } = p;
const { notionId: _, ...restOld } = old;
// Use microdiff to get differences
const differences = diff(restOld, restNew);
// Filter differences to only include keys we care about
const relevantDifferences = differences.filter((change) => {
// Check if the changed path starts with any of our keysToCheck
const path = change.path.join(".");
return keysToCheck.some((key) => path.startsWith(key));
});
return relevantDifferences.length > 0;
});
return { toAdd, toUpdate, toDelete };
}
// Transform Notion position data to desired format
export async function transformNotionPosition(env, notionPosition) {
const navigationItems = await getNavigation(env);
const properties = notionPosition.properties;
console.log("Notion Position:", notionPosition);
// Extract company information
let company = undefined;
if (properties.Company?.relation?.[0]?.id) {
company = properties.Company.relation[0].id;
}
// Extract duration
const duration = properties["Duration"].date;
// Extract name from title
const name = properties.Name?.title?.[0]?.plain_text || "Untitled";
console.log("Company:", company);
// Fetch and transform the actual page content from Notion blocks
const content = await transformContent(
env,
notionPosition.id,
navigationItems
);
return {
notionId: notionPosition.id,
name: name,
duration: duration,
company: company,
content,
};
}

225
src/utils/projects.js Normal file
View File

@ -0,0 +1,225 @@
import {
getItemByNotionId,
getPlainText,
transformContent,
toCamelCase,
} from "./notion.js";
import _ from "lodash";
import diff from "microdiff";
import { getNavigation } from "../utils/navigation.js";
import dayjs from "dayjs";
export async function getProjects(env, cached = false) {
try {
// If cached=true, try to get from Cloudflare Cache API first
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Projects`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Projects retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log("Cache miss or error, falling back to KV:", cacheError);
}
}
// Fall back to KV storage
const kvData = await env.CONTENT_KV.get(env.PROJECTS_KEY, {
type: "json",
});
// If we were trying to use cache but it failed, store the KV data in cache
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Projects`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"Projects-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Projects stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn("Error storing in cache after KV fallback:", cacheError);
}
}
return kvData || [];
} catch (error) {
console.log("Error fetching Projects cache:", error);
return null;
}
}
export async function storeProjects(env, Projects) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.PROJECTS_KEY, JSON.stringify(Projects));
console.log("Projects stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/Projects`
);
await cache.delete(cacheKey);
console.log("Projects cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing Projects cache:", error);
throw error;
}
}
export function diffProjects(newList, oldList) {
// Keys to check for changes
const keysToCheck = [
"name",
"date",
"image",
"content",
"published",
"externalLink",
"type",
"tools",
"client",
];
// Helper: index by notionId using lodash
const oldById = _.keyBy(oldList || [], "notionId");
const newById = _.keyBy(newList || [], "notionId");
// toAdd: in newList but not in oldList
const toAdd = newList.filter((p) => !oldById[p.notionId]);
// toDelete: in oldList but not in newList
const toDelete = oldList.filter((p) => !newById[p.notionId]);
// toUpdate: in both, but with different content (using microdiff)
const toUpdate = newList.filter((p) => {
const old = oldById[p.notionId];
if (!old) return false;
// Prepare objects for comparison
const { notionId, ...restNew } = p;
const { notionId: _, ...restOld } = old;
// Use microdiff to get differences
const differences = diff(restOld, restNew);
// Filter differences to only include keys we care about
const relevantDifferences = differences.filter((change) => {
// Check if the changed path starts with any of our keysToCheck
const path = change.path.join(".");
return keysToCheck.some((key) => path.startsWith(key));
});
return relevantDifferences.length > 0;
});
return { toAdd, toUpdate, toDelete };
}
// Transform Notion project data to desired format
export async function transformNotionProject(env, notionProject, settingsData) {
const navigationItems = await getNavigation(env);
const properties = notionProject.properties;
console.log("Notion Project:", notionProject);
// Extract theme information
let theme = undefined; // default
if (properties.Theme?.relation?.[0]?.id) {
const themeId = properties.Theme.relation[0].id;
console.log(settingsData.themes);
const themeData = getItemByNotionId(settingsData.themes, themeId);
if (themeData?.name) {
theme = themeData.name;
}
}
const published = properties["Published"]?.checkbox || false;
const slug = properties["Slug"]?.formula?.string || "unknown";
const status = toCamelCase(properties["Status"]?.status?.name || null);
const dateRaw = properties["Date"]?.date;
const date = dateRaw?.start ? dayjs(dateRaw.start).format("DD/MM/YY") : null;
// Extract image URL (handle both external and file images)
const imageFile = notionProject?.cover;
const image = imageFile?.external?.url || imageFile?.file?.url || null;
const subTitle = getPlainText(properties["Subtitle"]);
// Extract name from title
const name = properties.Name?.title?.[0]?.plain_text || "Untitled";
// Extract external link
const externalLink = properties["External Link"]?.url || null;
// Extract type (select)
const type = properties["Type"]?.select?.name || null;
// Extract tools (multi-select)
const tools = (properties["Tools"]?.multi_select || []).map(
(item) => item.name
);
// Extract client (text)
const client = getPlainText(properties["Client"]);
// Fetch and transform the actual page content from Notion blocks
const content = [{ type: "image", url: image, caption: subTitle }];
const projectContent = await transformContent(
env,
notionProject.id,
navigationItems
);
content.push(...projectContent);
if (published == false) {
return null;
}
return {
notionId: notionProject.id,
name: name,
date: date,
image: image,
subTitle: subTitle,
content,
published,
externalLink: externalLink,
type: type,
tools: tools,
client: client,
slug: slug,
status: status,
theme: theme,
};
}

177
src/utils/settings.js Normal file
View File

@ -0,0 +1,177 @@
import { processNotionIcon } from "./icon";
import { getNavigation } from "./navigation";
import { toCamelCase, getPlainText, getItemByNotionId } from "./notion";
export async function getSettings(env, cached = false) {
try {
// If cached=true, try to get from Cloudflare Cache API first
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/settings`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Settings retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log("Cache miss or error, falling back to KV:", cacheError);
}
}
// Fall back to KV storage
const kvData = await env.CONTENT_KV.get(env.SETTINGS_KEY, {
type: "json",
});
// If we were trying to use cache but it failed, store the KV data in cache
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/settings`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60", // 1 minute TTL
ETag: `"settings-${Date.now()}"`, // Add ETag for cache validation
},
});
await cache.put(cacheKey, response);
console.log("Settings stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn("Error storing in cache after KV fallback:", cacheError);
}
}
return kvData || {};
} catch (error) {
console.log("Error fetching settings cache:", error);
return null;
}
}
export async function storeSettings(env, settings) {
try {
// Always store in KV first
await env.CONTENT_KV.put(env.SETTINGS_KEY, JSON.stringify(settings));
console.log("Settings stored in KV.");
// Purge the Cloudflare Cache API
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/settings`
);
await cache.delete(cacheKey);
console.log("Settings cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing settings cache:", error);
throw error;
}
}
export async function transformRedirectsData(env, redirectsData = []) {
const redirects = {};
const navigationItems = await getNavigation(env);
// Iterate over redirects and build object
for (const redirect of redirectsData) {
const redirectName = getPlainText(redirect.properties?.Name);
const relationIds =
redirect.properties?.Page?.relation?.map((r) => r.id) || [];
// Map relations to full basicData entries
const relatedItem = relationIds
.map((notionId) => getItemByNotionId(navigationItems, notionId))
.filter(Boolean)[0];
const key = toCamelCase(redirectName);
redirects[key] = relatedItem.slug;
}
return redirects;
}
// Transform Notion themes data to desired format
export async function transformThemes(themesData) {
if (!themesData || !Array.isArray(themesData)) {
return [];
}
const transformed = await Promise.all(
themesData.map(async (theme) => {
const properties = theme.properties;
// Extract theme name
const name = properties.Name?.title?.[0]?.plain_text || "unknown";
// Extract background color
const backgroundColor =
properties["Background Color"]?.rich_text?.[0]?.plain_text || "#ffffff";
// Extract text color
const textColor =
properties["Text Color"]?.rich_text?.[0]?.plain_text || "#000000";
return {
notionId: theme.id,
name: toCamelCase(name),
backgroundColor,
textColor,
};
})
);
return transformed;
}
// Transform Notion page data to desired format
export async function transformSettingsData(
env,
globalThemesData = [],
redirectsData = [],
themesData = []
) {
const redirects = await transformRedirectsData(env, redirectsData);
const themes = await transformThemes(themesData);
const globalThemes = transformGlobalThemesData(env, globalThemesData, themes);
return { redirects, themes, globalThemes };
}
export function transformGlobalThemesData(
env,
globalThemesData = [],
themes = []
) {
const globalThemes = {};
// Iterate over redirects and build object
for (const globalTheme of globalThemesData) {
const globalThemeName = getPlainText(globalTheme.properties?.Name);
const relationIds =
globalTheme.properties?.Theme?.relation?.map((r) => r.id) || [];
// Map relations to full basicData entries
const relatedTheme = relationIds
.map((notionId) => getItemByNotionId(themes, notionId))
.filter(Boolean)[0];
const key = toCamelCase(globalThemeName);
globalThemes[key] = relatedTheme;
}
return globalThemes;
}

340
src/utils/videoCache.js Normal file
View File

@ -0,0 +1,340 @@
function generateVideoId(url) {
const urlObj = new URL(url);
const origin = urlObj.origin;
const pathname = urlObj.pathname;
const combined = `${origin}${pathname}`;
let hash = 0;
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
const positiveHash = Math.abs(hash);
const base36Hash = positiveHash.toString(36);
return base36Hash.padStart(8, "0").substring(0, 12);
}
function generateR2KeyFromVideoId(videoId, url) {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split("/").pop() || "video";
const extension = filename.includes(".") ? filename.split(".").pop() : "mp4";
const baseFilename = filename.replace(/\.[^/.]+$/, "");
return `videos/${videoId}-${baseFilename}.${extension}`;
}
function extractR2KeyFromUrl(mirrorUrl) {
const urlObj = new URL(mirrorUrl);
return urlObj.pathname.substring(1);
}
export async function getVideos(env, cached = false) {
try {
if (cached) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/videos`
);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const cachedData = await cachedResponse.json();
console.log("Videos retrieved from Cloudflare cache");
return cachedData;
}
} catch (cacheError) {
console.log(
"Video cache miss or error, falling back to KV:",
cacheError
);
}
}
const kvData = await env.CONTENT_KV.get(env.VIDEOS_KEY, {
type: "json",
});
if (cached && kvData) {
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/videos`
);
const response = new Response(JSON.stringify(kvData), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60",
ETag: `"videos-${Date.now()}"`,
},
});
await cache.put(cacheKey, response);
console.log("Videos stored in Cloudflare cache after KV fallback");
} catch (cacheError) {
console.warn(
"Error storing videos in cache after KV fallback:",
cacheError
);
}
}
return kvData || [];
} catch (error) {
console.log("Error fetching videos cache:", error);
return null;
}
}
export async function updateVideos(env, currentVideoUrls) {
console.log("Updating video cache...");
const currentVideos = currentVideoUrls.map((url) => ({
id: generateVideoId(url),
url,
}));
const existingVideos = await getVideos(env);
const existingVideoMap = new Map(
existingVideos.map((video) => [video.id, video])
);
const videosToAdd = currentVideos.filter(
(video) => !existingVideoMap.has(video.id)
);
const currentVideoMap = new Map(
currentVideos.map((video) => [video.id, video])
);
const videosToKeep = existingVideos
.filter((video) => currentVideoMap.has(video.id))
.map((video) => ({
...video,
url: currentVideoMap.get(video.id).url,
}));
const videosToRemove = existingVideos.filter(
(video) => !currentVideoMap.has(video.id)
);
console.log(
`Videos to add: ${videosToAdd.length}, Videos to keep: ${videosToKeep.length}, Videos to remove: ${videosToRemove.length}`
);
for (const video of videosToRemove) {
if (video.mirrorUrl) {
try {
const key = extractR2KeyFromUrl(video.mirrorUrl);
await env.TB_STORAGE.delete(key);
console.log(`Deleted ${key} from R2 for video ID ${video.id}`);
} catch (error) {
console.error(
`Failed to delete ${video.mirrorUrl} from R2 for video ID ${video.id}:`,
error
);
}
}
}
const newVideos = [];
for (let i = 0; i < videosToAdd.length; i++) {
const videoData = videosToAdd[i];
const { id, url } = videoData;
console.log(
`Uploading video to R2 (${i + 1}/${
videosToAdd.length
}): ${url} (ID: ${id})`
);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch video: ${response.status}`);
}
const contentType = response.headers.get("content-type") || null;
const contentLengthHeader = response.headers.get("content-length");
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: null;
const key = generateR2KeyFromVideoId(id, url);
const putOptions = contentType
? { httpMetadata: { contentType } }
: undefined;
const result = await env.TB_STORAGE.put(key, response.body, putOptions);
if (result == null) {
throw new Error(`Failed to upload video to R2: ${key}`);
}
const mirrorUrl = `${env.R2_PUBLIC_URL}/${key}`;
newVideos.push({
id,
url,
metadata:
contentType || contentLength ? { contentType, contentLength } : null,
mirrorUrl,
});
console.log(
`Successfully uploaded and mirrored video: ${url} -> ${mirrorUrl} (ID: ${id})`
);
} catch (error) {
console.error(`Failed to upload ${url} to R2 (ID: ${id}):`, error);
newVideos.push({
id,
url,
metadata: null,
mirrorUrl: null,
});
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const updatedVideos = [...videosToKeep, ...newVideos];
await storeVideos(env, updatedVideos);
return updatedVideos;
}
export async function storeVideos(env, videos) {
try {
await env.CONTENT_KV.put(env.VIDEOS_KEY, JSON.stringify(videos));
console.log("Videos stored in KV.");
try {
const cache = caches.default;
const cacheKey = new Request(
`https://${env.CACHE_URL || "cache"}/videos`
);
await cache.delete(cacheKey);
console.log("Videos cache purged successfully.");
} catch (cacheError) {
console.warn(
"Error purging Cloudflare video cache, but KV was updated successfully:",
cacheError
);
}
} catch (error) {
console.error("Error storing videos cache:", error);
throw error;
}
}
function findVideoChildren(obj, videoUrls = []) {
if (!obj || typeof obj !== "object") {
return videoUrls;
}
if (obj.type === "video") {
if (obj.url) {
videoUrls.push(obj.url);
} else if (obj.src) {
videoUrls.push(obj.src);
} else if (obj.video) {
videoUrls.push(obj.video);
}
}
if (obj.children && Array.isArray(obj.children)) {
for (const child of obj.children) {
findVideoChildren(child, videoUrls);
}
}
for (const [key, value] of Object.entries(obj)) {
if (
key !== "children" &&
key !== "content" &&
key !== "blocks" &&
typeof value === "object"
) {
findVideoChildren(value, videoUrls);
}
}
return videoUrls;
}
export function collectVideoUrls(contentObjects) {
const videoUrls = [];
for (const object of contentObjects) {
if (object.video) {
videoUrls.push(object.video);
}
if (object.videos) {
object.videos.forEach((url) => videoUrls.push(url));
}
if (object.content) {
findVideoChildren(object.content, videoUrls);
}
}
return videoUrls;
}
export async function handleVideoMetadataUpdate(request, env) {
const cachedVideos = await env.CONTENT_KV.get(env.VIDEOS_KEY, {
type: "json",
});
if (!cachedVideos) return;
let updated = false;
for (const video of cachedVideos) {
if (!video.metadata) {
const targetUrl = video.mirrorUrl || video.url;
try {
const response = await fetch(targetUrl, { method: "HEAD" });
if (response.ok) {
const contentType = response.headers.get("content-type") || null;
const contentLengthHeader = response.headers.get("content-length");
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: null;
if (contentType || contentLength) {
video.metadata = { contentType, contentLength };
updated = true;
console.log(
`Updated metadata for video ID ${video.id} (${video.url})`
);
}
}
} catch (error) {
console.warn(
`Failed to update metadata for video ID ${video.id} (${video.url}):`,
error
);
}
}
}
if (updated) {
await env.CONTENT_KV.put(env.VIDEOS_KEY, JSON.stringify(cachedVideos));
}
return cachedVideos;
}
export function getVideoById(cachedVideos, videoId) {
return cachedVideos.find((video) => video.id === videoId);
}
export function getVideoIdFromUrl(url) {
return generateVideoId(url);
}

BIN
tb-api.paw Normal file

Binary file not shown.

11
vitest.config.js Normal file
View File

@ -0,0 +1,11 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
},
},
},
});

140
wrangler.jsonc Normal file
View File

@ -0,0 +1,140 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "tombutcher-api",
"main": "src/index.js",
"compatibility_date": "2025-02-24",
"observability": {
"enabled": true,
"head_sampling_rate": 1
},
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
"vars": {
"THEMES_DB": "289dd26d-60b6-8195-843b-000b65b97c0a",
"GLOBAL_THEMES_DB": "289dd26d-60b6-81d8-8823-000b24e2a783",
"PAGES_DB": "289dd26d-60b6-81d0-b598-000becb15373",
"BLOGS_DB": "289dd26d-60b6-811d-9bdb-000bc5557136",
"MESSAGES_DB": "289dd26d-60b6-818e-9f49-000b5fb2ba34",
"REDIRECTS_DB": "289dd26d-60b6-817c-9f97-000ba041fcfb",
"PROJECTS_DB": "297dd26d-60b6-8031-9de2-000bb1de652b",
"COMPANIES_DB": "29fdd26d-60b6-80e8-8b70-000b0c24c7b7",
"POSITIONS_DB": "29fdd26d-60b6-8018-9fd5-000bdce63f48",
"CV_DB": "2a3dd26d-60b6-8098-85c0-000b12e171c4",
"BLOGS_KEY": "th-blogs-cache",
"COMPANIES_KEY": "th-companies-cache",
"POSITIONS_KEY": "th-positions-cache",
"NAVIGATION_KEY": "th-navigation-cache",
"PAGES_KEY": "th-pages-cache",
"IMAGES_KEY": "th-images-cache",
"BOOKINGS_KEY": "th-bookings-cache",
"PROJECTS_KEY": "th-projects-cache",
"VIDEOS_KEY": "th-videos-cache",
"FILES_KEY": "th-files-cache",
"PROPERTIES_KEY": "th-properties-cache",
"SETTINGS_KEY": "th-settings-cache",
"CV_KEY": "th-cv-cache",
"CACHE_URL": "https://api.tombutcherltd.com/cache",
"R2_PUBLIC_URL": "https://cdn2026.tombutcher.work",
"BLUR_HASH": "true",
"CORS_ORIGIN": "https://tombutcher.work"
},
"kv_namespaces": [
{
"binding": "CONTENT_KV", // the variable youll use in the Worker
"id": "05c5283d5b74488da7f90297ea350f9c" // ID from Cloudflare dashboard
}
],
"r2_buckets": [
{
"binding": "TB_STORAGE",
"bucket_name": "tb-2026-storage"
}
],
"images": {
"binding": "IMAGES" // i.e. available in your Worker on env.IMAGES
},
"env": {
"production": {
"kv_namespaces": [
{
"binding": "CONTENT_KV", // the variable youll use in the Worker
"id": "05c5283d5b74488da7f90297ea350f9c" // ID from Cloudflare dashboard
}
],
"r2_buckets": [
{
"binding": "TB_STORAGE",
"bucket_name": "tb-2026-storage"
}
],
"images": {
"binding": "IMAGES" // i.e. available in your Worker on env.IMAGES
},
"vars": {
"THEMES_DB": "26d4d3a4-6a6f-8052-966a-000bc1c836c1",
"GLOBAL_THEMES_DB": "26d4d3a4-6a6f-80a8-b5f8-000b1b040695",
"PAGES_DB": "26d4d3a4-6a6f-80f1-a0fc-000b3c4c5013",
"PROPERTIES_DB": "26e4d3a4-6a6f-80b8-ac80-000b7b236ebc",
"BOOKINGS_DB": "26e4d3a4-6a6f-80fe-960a-000be56680fd",
"GUESTS_DB": "2764d3a4-6a6f-808b-af87-000b072894d7",
"MESSAGES_DB": "2754d3a4-6a6f-80b5-8f8e-000b2ab6ae7a",
"REDIRECTS_DB": "2754d3a4-6a6f-80ca-b30f-000b828310d2",
"BRANDING_DB": "2764d3a4-6a6f-80f6-8354-000bf2304e00",
"NAVIGATION_KEY": "th-navigation-cache",
"PAGES_KEY": "th-pages-cache",
"IMAGES_KEY": "th-images-cache",
"BOOKINGS_KEY": "th-bookings-cache",
"GUESTS_KEY": "th-guests-cache",
"PROPERTIES_KEY": "th-properties-cache",
"SETTINGS_KEY": "th-settings-cache",
"CACHE_URL": "https://api.tombutcherltd.com/cache",
"R2_PUBLIC_URL": "https://cdn2026.tombutcher.work",
"BLUR_HASH": "true",
"CORS_ORIGIN": "https://tombutcher.work"
}
}
},
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" },
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
"dev": {
"ip": "0.0.0.0",
"port": 8787
}
}

3217
yarn.lock Normal file

File diff suppressed because it is too large Load Diff