Initial Commit
This commit is contained in:
commit
6d5265dfe0
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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
28
package.json
Normal 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
67
src/index.js
Normal 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
45
src/objects/blogs.js
Normal 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
62
src/objects/companies.js
Normal 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
43
src/objects/cv.js
Normal 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
27
src/objects/files.js
Normal 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
26
src/objects/images.js
Normal 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
22
src/objects/navigation.js
Normal 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
53
src/objects/pages.js
Normal 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
50
src/objects/positions.js
Normal 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
54
src/objects/projects.js
Normal 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
34
src/objects/settings.js
Normal 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
24
src/objects/videos.js
Normal 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
24
src/routes/blurHash.js
Normal 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
85
src/routes/contact.js
Normal 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
92
src/routes/content.js
Normal 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
277
src/routes/hooks.js
Normal 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
6
src/utils/api.js
Normal 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
203
src/utils/blogs.js
Normal 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
110
src/utils/blurHash.js
Normal 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
237
src/utils/companies.js
Normal 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
44
src/utils/contentCache.js
Normal 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
154
src/utils/cv.js
Normal 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
369
src/utils/fileCache.js
Normal 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
72
src/utils/icon.js
Normal 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
349
src/utils/imageCache.js
Normal 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
90
src/utils/navigation.js
Normal 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
627
src/utils/notion.js
Normal 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
214
src/utils/pages.js
Normal 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
164
src/utils/positions.js
Normal 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
225
src/utils/projects.js
Normal 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
177
src/utils/settings.js
Normal 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
340
src/utils/videoCache.js
Normal 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
BIN
tb-api.paw
Normal file
Binary file not shown.
11
vitest.config.js
Normal file
11
vitest.config.js
Normal 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
140
wrangler.jsonc
Normal 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 you’ll 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 you’ll 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user