Initial commit
This commit is contained in:
commit
4cf801a488
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/
|
||||||
6896
package-lock.json
generated
Normal file
6896
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "thehideout-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"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/index.js
Normal file
78
src/index.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { handleContactRequest } from "./routes/contact.js";
|
||||||
|
import { handleContentRequest } from "./routes/content.js";
|
||||||
|
import { handleNotionHook, handleSmoobuHook } from "./routes/hooks.js";
|
||||||
|
import { globalHeaders } from "./utils/api.js";
|
||||||
|
import { handleBlurhashUpdate } from "./utils/imageCache.js";
|
||||||
|
import { refreshBookingCache } from "./objects/bookings.js";
|
||||||
|
import { updateAllSmoobuData } from "./routes/hooks.js";
|
||||||
|
import { importImages } from "./objects/images.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.method === "POST" &&
|
||||||
|
request.url.split("?")[0].endsWith("/smoobuHook")
|
||||||
|
) {
|
||||||
|
return await handleSmoobuHook(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);
|
||||||
|
},
|
||||||
|
};
|
||||||
222
src/objects/bookings.js
Normal file
222
src/objects/bookings.js
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import {
|
||||||
|
transformSmoobuBooking,
|
||||||
|
transformNotionBooking,
|
||||||
|
diffBookings,
|
||||||
|
addBookingToNotion,
|
||||||
|
updateNotionBooking,
|
||||||
|
deleteNotionBooking,
|
||||||
|
} from "../utils/bookings.js";
|
||||||
|
import { fetchSmoobuBookings } from "../utils/smoobu.js";
|
||||||
|
import { storeBookingsCache, getBookingsCache } from "../utils/bookings.js";
|
||||||
|
import { queryNotionDataSource, getNotionPage } from "../utils/notion.js";
|
||||||
|
import { getProperties } from "../utils/properties.js";
|
||||||
|
import { getGuestsCache } from "../utils/guests.js";
|
||||||
|
import { unionBy } from "lodash";
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
// Fetch Notion pages (raw data)
|
||||||
|
export async function importNotionBookings(env, notionId = null) {
|
||||||
|
console.log("Importing bookings from Notion...");
|
||||||
|
|
||||||
|
let bookingsData = [];
|
||||||
|
if (notionId !== null) {
|
||||||
|
bookingsData = [await getNotionPage(notionId)];
|
||||||
|
} else {
|
||||||
|
const currentDate = new Date().toISOString(); // current date-time in ISO 8601
|
||||||
|
bookingsData = await queryNotionDataSource(env.BOOKINGS_DB, {
|
||||||
|
filter: {
|
||||||
|
or: [
|
||||||
|
{
|
||||||
|
property: "Status",
|
||||||
|
status: {
|
||||||
|
equals: "Hosting",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: "Date",
|
||||||
|
date: {
|
||||||
|
on_or_after: currentDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertiesData = await getProperties(env);
|
||||||
|
const guestsData = await getGuestsCache(env);
|
||||||
|
const bookings = (
|
||||||
|
await Promise.all(
|
||||||
|
bookingsData.map(async (booking) =>
|
||||||
|
transformNotionBooking(env, booking, propertiesData, guestsData)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((booking) => booking !== null);
|
||||||
|
|
||||||
|
if (bookings.length === 0) {
|
||||||
|
console.log("No bookings to import.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If notionId is not null, use lodash unionBy to replace existing bookings
|
||||||
|
if (notionId !== null) {
|
||||||
|
const cachedBookings = (await getBookingsCache(env)) || [];
|
||||||
|
const mergedBookings = unionBy(bookings, cachedBookings, "notionId");
|
||||||
|
await storeBookingsCache(env, mergedBookings);
|
||||||
|
console.log("Bookings imported from Notion and merged with cache.");
|
||||||
|
return mergedBookings;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeBookingsCache(env, bookings);
|
||||||
|
console.log("Bookings imported from Notion.");
|
||||||
|
return bookings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importSmoobuBookings(env) {
|
||||||
|
console.log("Importing bookings from Smoobu...");
|
||||||
|
const bookingsData = await fetchSmoobuBookings(env, {
|
||||||
|
showCancellation: true,
|
||||||
|
});
|
||||||
|
const propertiesData = await getProperties(env);
|
||||||
|
const guestsData = await getGuestsCache(env);
|
||||||
|
const smoobuBookings = (
|
||||||
|
await Promise.all(
|
||||||
|
bookingsData.map(async (booking) =>
|
||||||
|
transformSmoobuBooking(env, booking, propertiesData, guestsData)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((booking) => booking !== null);
|
||||||
|
|
||||||
|
// Get the current cache (source of truth)
|
||||||
|
const cachedBookings = (await getBookingsCache(env)) || [];
|
||||||
|
// Diff Smoobu bookings with cache
|
||||||
|
const { toAdd, toUpdate, toDelete } = diffBookings(
|
||||||
|
smoobuBookings,
|
||||||
|
cachedBookings
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Smoobu bookings:", smoobuBookings.length);
|
||||||
|
console.log(
|
||||||
|
"Add:",
|
||||||
|
toAdd.length,
|
||||||
|
"Update:",
|
||||||
|
toUpdate.length,
|
||||||
|
"Delete:",
|
||||||
|
toDelete.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new bookings to Notion
|
||||||
|
const added = [];
|
||||||
|
for (const booking of toAdd) {
|
||||||
|
const res = await addBookingToNotion(env, booking);
|
||||||
|
added.push(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing bookings in Notion
|
||||||
|
const updated = [];
|
||||||
|
for (const booking of toUpdate) {
|
||||||
|
const cached = cachedBookings.find((b) => b.smoobuId === booking.smoobuId);
|
||||||
|
if (!cached?.notionId) continue;
|
||||||
|
const res = await updateNotionBooking(env, booking, cached.notionId);
|
||||||
|
updated.push(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete bookings from Notion (archive)
|
||||||
|
for (const booking of toDelete) {
|
||||||
|
if (booking.notionId) {
|
||||||
|
await deleteNotionBooking(env, booking.notionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new cache
|
||||||
|
const { buildListCache } = await import("../utils/notion.js");
|
||||||
|
const newCache = buildListCache(cachedBookings, {
|
||||||
|
added,
|
||||||
|
updated,
|
||||||
|
deleted: toDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (added.length > 0 || updated.length > 0 || toDelete.length > 0) {
|
||||||
|
await storeBookingsCache(env, newCache);
|
||||||
|
console.log("Imported bookings from Smoobu.");
|
||||||
|
} else {
|
||||||
|
console.log("No changes detected, cache not updated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshBookingCache(env) {
|
||||||
|
console.log("Refreshing booking cache...");
|
||||||
|
const bookingsData = await getBookingsCache(env, true);
|
||||||
|
const now = new Date();
|
||||||
|
const bookings = [];
|
||||||
|
let hasChanges = false;
|
||||||
|
console.log("Bookings data:", bookingsData.length);
|
||||||
|
|
||||||
|
for (const booking of bookingsData) {
|
||||||
|
const checkInDate = new Date(booking.checkIn);
|
||||||
|
const checkOutDate = new Date(booking.checkOut);
|
||||||
|
|
||||||
|
const isHosting = now >= checkInDate && now <= checkOutDate;
|
||||||
|
const isComplete = now >= checkOutDate;
|
||||||
|
|
||||||
|
if (isHosting && booking.status == "confirmed") {
|
||||||
|
console.log(
|
||||||
|
"Booking state changed (Confirmed -> Hosting):",
|
||||||
|
booking.notionId
|
||||||
|
);
|
||||||
|
const updatedBooking = await updateNotionBooking(
|
||||||
|
env,
|
||||||
|
{ ...booking, status: "hosting" },
|
||||||
|
booking.notionId
|
||||||
|
);
|
||||||
|
bookings.push({ ...updatedBooking });
|
||||||
|
hasChanges = true;
|
||||||
|
} else if (isComplete && booking.status == "hosting") {
|
||||||
|
console.log(
|
||||||
|
"Booking state changed (Hosting -> Complete):",
|
||||||
|
booking.notionId
|
||||||
|
);
|
||||||
|
await updateNotionBooking(
|
||||||
|
env,
|
||||||
|
{ ...booking, status: "complete" },
|
||||||
|
booking.notionId
|
||||||
|
);
|
||||||
|
hasChanges = true;
|
||||||
|
continue;
|
||||||
|
} else if (
|
||||||
|
isComplete &&
|
||||||
|
(booking.status == "cancelled" || booking.status == "complete")
|
||||||
|
) {
|
||||||
|
console.log("Flushing old booking:", booking.notionId);
|
||||||
|
// Skip cancelled bookings that have passed checkout
|
||||||
|
hasChanges = true;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
bookings.push({ ...booking });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update cache if there were changes
|
||||||
|
if (hasChanges) {
|
||||||
|
await storeBookingsCache(env, bookings);
|
||||||
|
console.log("Booking cache refreshed with changes.");
|
||||||
|
} else {
|
||||||
|
console.log("No changes detected, cache not updated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return bookings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotionBookingFromCache(env, notionId) {
|
||||||
|
const cachedBookings = await getBookingsCache(env);
|
||||||
|
const newCache = cachedBookings.filter((b) => b.notionId !== notionId);
|
||||||
|
await storeBookingsCache(env, newCache);
|
||||||
|
console.log("Deleted booking from cache.");
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
118
src/objects/guests.js
Normal file
118
src/objects/guests.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
transformSmoobuGuest,
|
||||||
|
transformNotionGuest,
|
||||||
|
diffGuests,
|
||||||
|
addGuestToNotion,
|
||||||
|
updateNotionGuest,
|
||||||
|
deleteNotionGuest,
|
||||||
|
} from "../utils/guests.js";
|
||||||
|
import { fetchSmoobuGuests } from "../utils/smoobu.js";
|
||||||
|
import { storeGuestsCache, getGuestsCache } from "../utils/guests.js";
|
||||||
|
import { queryNotionDataSource, getNotionPage } from "../utils/notion.js";
|
||||||
|
import { unionBy } from "lodash";
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
// Fetch Notion pages (raw data)
|
||||||
|
export async function importNotionGuests(env, notionId = null) {
|
||||||
|
console.log("Importing guests from Notion...");
|
||||||
|
|
||||||
|
let guestsData = [];
|
||||||
|
if (notionId !== null) {
|
||||||
|
guestsData = [await getNotionPage(notionId)];
|
||||||
|
} else {
|
||||||
|
guestsData = await queryNotionDataSource(env.GUESTS_DB);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guests = (
|
||||||
|
await Promise.all(
|
||||||
|
guestsData.map(async (guest) => transformNotionGuest(env, guest))
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
// If notionId is not null, use lodash unionBy to replace existing guests
|
||||||
|
if (notionId !== null) {
|
||||||
|
const cachedGuests = (await getGuestsCache(env)) || [];
|
||||||
|
const mergedGuests = unionBy(guests, cachedGuests, "notionId");
|
||||||
|
await storeGuestsCache(env, mergedGuests);
|
||||||
|
console.log("Guests imported from Notion and merged with cache.");
|
||||||
|
return mergedGuests;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeGuestsCache(env, guests);
|
||||||
|
console.log("Guests imported from Notion.");
|
||||||
|
return guests;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importSmoobuGuests(env) {
|
||||||
|
console.log("Importing guests from Smoobu...");
|
||||||
|
const guestsData = await fetchSmoobuGuests(env);
|
||||||
|
const smoobuGuests = (
|
||||||
|
await Promise.all(
|
||||||
|
guestsData.map(async (guest) => transformSmoobuGuest(env, guest))
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
// Get the current cache (source of truth)
|
||||||
|
const cachedGuests = (await getGuestsCache(env)) || [];
|
||||||
|
|
||||||
|
// Diff Smoobu guests with cache
|
||||||
|
const { toAdd, toUpdate, toDelete } = diffGuests(smoobuGuests, cachedGuests);
|
||||||
|
|
||||||
|
console.log("Smoobu guests:", smoobuGuests.length);
|
||||||
|
console.log(
|
||||||
|
"Add:",
|
||||||
|
toAdd.length,
|
||||||
|
"Update:",
|
||||||
|
toUpdate.length,
|
||||||
|
"Delete:",
|
||||||
|
toDelete.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new guests to Notion
|
||||||
|
const added = [];
|
||||||
|
for (const guest of toAdd) {
|
||||||
|
const res = await addGuestToNotion(env, guest);
|
||||||
|
added.push(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing guests in Notion
|
||||||
|
const updated = [];
|
||||||
|
for (const guest of toUpdate) {
|
||||||
|
const cached = cachedGuests.find((g) => g.smoobuId === guest.smoobuId);
|
||||||
|
if (!cached?.notionId) continue;
|
||||||
|
const res = await updateNotionGuest(env, guest, cached.notionId);
|
||||||
|
updated.push(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete guests from Notion (archive)
|
||||||
|
for (const guest of toDelete) {
|
||||||
|
if (guest.notionId) {
|
||||||
|
await deleteNotionGuest(env, guest.notionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new cache
|
||||||
|
const { buildListCache } = await import("../utils/notion.js");
|
||||||
|
const newCache = buildListCache(cachedGuests, {
|
||||||
|
added,
|
||||||
|
updated,
|
||||||
|
deleted: toDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store updated cache
|
||||||
|
if (added.length > 0 || updated.length > 0 || toDelete.length > 0) {
|
||||||
|
await storeGuestsCache(env, newCache);
|
||||||
|
console.log("Imported guests from Smoobu.");
|
||||||
|
} else {
|
||||||
|
console.log("No changes detected, cache not updated.");
|
||||||
|
}
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotionGuestFromCache(env, notionId) {
|
||||||
|
const cachedGuests = await getGuestsCache(env);
|
||||||
|
const newCache = cachedGuests.filter((g) => g.notionId !== notionId);
|
||||||
|
await storeGuestsCache(env, newCache);
|
||||||
|
console.log("Deleted guest from cache.");
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
17
src/objects/images.js
Normal file
17
src/objects/images.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { getPages } from "../utils/pages.js";
|
||||||
|
import { getProperties } from "../utils/properties.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 properties = (await getProperties(env)) || [];
|
||||||
|
|
||||||
|
// Collect all image URLs
|
||||||
|
const imageUrls = collectImageUrls(pages, properties);
|
||||||
|
|
||||||
|
const updatedImages = await updateImages(env, imageUrls);
|
||||||
|
console.log("Imported images from Notion.");
|
||||||
|
return updatedImages;
|
||||||
|
}
|
||||||
24
src/objects/navigation.js
Normal file
24
src/objects/navigation.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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 notionProperties = await queryNotionDataSource(env.PROPERTIES_DB);
|
||||||
|
// Transform the incoming pages for the given type
|
||||||
|
const navigationItems = (
|
||||||
|
await Promise.all(
|
||||||
|
notionPages.map(async (item) => transformNavigation(item, "page")),
|
||||||
|
notionProperties.map(async (item) =>
|
||||||
|
transformNavigation(item, "property")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
// Store the updated cache
|
||||||
|
await storeNavigation(env, navigationItems);
|
||||||
|
|
||||||
|
console.log("Navigation imported from Notion.");
|
||||||
|
|
||||||
|
return navigationItems;
|
||||||
|
}
|
||||||
64
src/objects/pages.js
Normal file
64
src/objects/pages.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { queryNotionDataSource, getNotionPage } from "../utils/notion.js";
|
||||||
|
import { transformPageData, storePages, getPages } from "../utils/pages.js";
|
||||||
|
import { getProperties } from "../utils/properties.js";
|
||||||
|
import { getSettings } from "../utils/settings.js";
|
||||||
|
|
||||||
|
// Fetch Notion pages (raw data)
|
||||||
|
export async function importNotionPages(env, notionId = null) {
|
||||||
|
console.log("Importing pages from Notion...");
|
||||||
|
const propertiesData = await getProperties(env);
|
||||||
|
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, propertiesData, settingsData)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
if (notionId !== null) {
|
||||||
|
const cachedPages = await getPages(env);
|
||||||
|
console.log("Cached pages:", cachedPages);
|
||||||
|
console.log("Pages:", pages);
|
||||||
|
// Create a map of new pages by notionId for quick lookup
|
||||||
|
const newPagesMap = new Map(pages.map((page) => [page.notionId, page]));
|
||||||
|
|
||||||
|
// Merge: preserve cached order, but replace with new data if available
|
||||||
|
const mergedPages = cachedPages.map((cachedPage) => {
|
||||||
|
return newPagesMap.has(cachedPage.notionId)
|
||||||
|
? newPagesMap.get(cachedPage.notionId)
|
||||||
|
: cachedPage;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add any new pages that weren't in cache
|
||||||
|
const existingNotionIds = new Set(cachedPages.map((page) => page.notionId));
|
||||||
|
const newPages = pages.filter(
|
||||||
|
(page) => !existingNotionIds.has(page.notionId)
|
||||||
|
);
|
||||||
|
mergedPages.push(...newPages);
|
||||||
|
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;
|
||||||
|
}
|
||||||
134
src/objects/properties.js
Normal file
134
src/objects/properties.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
buildListCache,
|
||||||
|
queryNotionDataSource,
|
||||||
|
getNotionPage,
|
||||||
|
} from "../utils/notion.js";
|
||||||
|
import {
|
||||||
|
addPropertyToNotion,
|
||||||
|
updateNotionProperty,
|
||||||
|
deleteNotionProperty,
|
||||||
|
getProperties,
|
||||||
|
storeProperties,
|
||||||
|
transformSmoobuProperty,
|
||||||
|
transformNotionProperty,
|
||||||
|
diffProperties,
|
||||||
|
} from "../utils/properties.js";
|
||||||
|
import {
|
||||||
|
fetchSmoobuProperties,
|
||||||
|
fetchSmoobuPropertyDetails,
|
||||||
|
} from "../utils/smoobu.js";
|
||||||
|
import { unionBy } from "lodash";
|
||||||
|
|
||||||
|
export async function importSmoobuProperties(env) {
|
||||||
|
console.log("Importing properties from Smoobu...");
|
||||||
|
const propertiesData = await fetchSmoobuProperties(env);
|
||||||
|
const smoobuProperties = (
|
||||||
|
await Promise.all(
|
||||||
|
propertiesData.map(async (property) => {
|
||||||
|
const propertyData = await fetchSmoobuPropertyDetails(env, property.id);
|
||||||
|
return transformSmoobuProperty(env, propertyData);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
// Get the current cache (source of truth)
|
||||||
|
var cachedProperties = (await getProperties(env)) || [];
|
||||||
|
// Diff Smoobu properties with cache
|
||||||
|
const { toAdd, toUpdate, toDelete } = diffProperties(
|
||||||
|
smoobuProperties,
|
||||||
|
cachedProperties
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Smoobu properties:", smoobuProperties.length);
|
||||||
|
console.log(
|
||||||
|
"Add:",
|
||||||
|
toAdd.length,
|
||||||
|
"Update:",
|
||||||
|
toUpdate.length,
|
||||||
|
"Delete:",
|
||||||
|
toDelete.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new properties to Notion
|
||||||
|
const added = [];
|
||||||
|
for (const property of toAdd) {
|
||||||
|
const res = await addPropertyToNotion(env, property);
|
||||||
|
added.push(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing properties in Notion
|
||||||
|
const updated = [];
|
||||||
|
for (const property of toUpdate) {
|
||||||
|
const cached = cachedProperties.find(
|
||||||
|
(g) => g.smoobuId === property.smoobuId
|
||||||
|
);
|
||||||
|
if (!cached?.notionId) continue;
|
||||||
|
const res = await updateNotionProperty(env, property, cached.notionId);
|
||||||
|
updated.push(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete properties from Notion (archive)
|
||||||
|
for (const property of toDelete) {
|
||||||
|
if (property.notionId) {
|
||||||
|
await deleteNotionProperty(env, property.notionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new cache
|
||||||
|
const { buildListCache } = await import("../utils/notion.js");
|
||||||
|
const newCache = buildListCache(cachedProperties, {
|
||||||
|
added,
|
||||||
|
updated,
|
||||||
|
deleted: toDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store updated cache
|
||||||
|
if (added.length > 0 || updated.length > 0 || toDelete.length > 0) {
|
||||||
|
await storeProperties(env, newCache);
|
||||||
|
console.log("Imported properties from Smoobu.");
|
||||||
|
} else {
|
||||||
|
console.log("No changes detected, cache not updated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importNotionProperties(env, notionId = null) {
|
||||||
|
console.log("Importing properties from Notion...");
|
||||||
|
|
||||||
|
let propertiesData = [];
|
||||||
|
if (notionId !== null) {
|
||||||
|
propertiesData = [await getNotionPage(notionId)];
|
||||||
|
} else {
|
||||||
|
propertiesData = await queryNotionDataSource(env.PROPERTIES_DB);
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = (
|
||||||
|
await Promise.all(
|
||||||
|
propertiesData.map(async (property) =>
|
||||||
|
transformNotionProperty(env, property)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
// If notionId is not null, use lodash unionBy to replace existing properties
|
||||||
|
if (notionId !== null) {
|
||||||
|
const cachedProperties = await getProperties(env);
|
||||||
|
const mergedProperties = unionBy(properties, cachedProperties, "notionId");
|
||||||
|
await storeProperties(env, mergedProperties);
|
||||||
|
console.log("Imported properties from Notion and merged with cache.");
|
||||||
|
return mergedProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeProperties(env, properties);
|
||||||
|
console.log("Imported properties from Notion.");
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotionPropertyFromCache(env, notionId) {
|
||||||
|
const cachedProperties = await getProperties(env);
|
||||||
|
const newCache = cachedProperties.filter((b) => b.notionId !== notionId);
|
||||||
|
await storeProperties(env, newCache);
|
||||||
|
console.log("Deleted property from cache.");
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
40
src/objects/settings.js
Normal file
40
src/objects/settings.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { queryNotionDataSource } from "../utils/notion.js";
|
||||||
|
import {
|
||||||
|
transformThemes,
|
||||||
|
transformBranding,
|
||||||
|
transformRedirectsData,
|
||||||
|
transformGlobalThemesData,
|
||||||
|
storeSettings,
|
||||||
|
} from "../utils/settings.js";
|
||||||
|
|
||||||
|
export async function importNotionSettings(env) {
|
||||||
|
console.log("Importing settings from Notion...");
|
||||||
|
const [globalThemesData, themesData, redirectsData, brandingData] =
|
||||||
|
await Promise.all([
|
||||||
|
queryNotionDataSource(env.GLOBAL_THEMES_DB),
|
||||||
|
queryNotionDataSource(env.THEMES_DB),
|
||||||
|
queryNotionDataSource(env.REDIRECTS_DB),
|
||||||
|
queryNotionDataSource(env.BRANDING_DB),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Fetched settings from Notion.");
|
||||||
|
|
||||||
|
const themes = await transformThemes(themesData);
|
||||||
|
const globalThemes = transformGlobalThemesData(env, globalThemesData, themes);
|
||||||
|
const branding = await transformBranding(brandingData);
|
||||||
|
const redirects = await transformRedirectsData(env, redirectsData);
|
||||||
|
|
||||||
|
await storeSettings(env, {
|
||||||
|
globalThemes,
|
||||||
|
themes,
|
||||||
|
branding,
|
||||||
|
redirects,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
globalThemes,
|
||||||
|
themes,
|
||||||
|
branding,
|
||||||
|
redirects,
|
||||||
|
};
|
||||||
|
}
|
||||||
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/routes/content.js
Normal file
69
src/routes/content.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { globalHeaders } from "../utils/api.js";
|
||||||
|
import { getCombinedCachedContent } from "../utils/contentCache.js";
|
||||||
|
import { importNotionProperties } from "../objects/properties.js";
|
||||||
|
import { importNotionPages } from "../objects/pages.js";
|
||||||
|
import { importNotionSettings } from "../objects/settings.js";
|
||||||
|
import { importNotionNavigation } from "../objects/navigation.js";
|
||||||
|
import { importImages } from "../objects/images.js";
|
||||||
|
|
||||||
|
// Fetch or return cached content
|
||||||
|
export async function getCachedContent(env) {
|
||||||
|
// Try to get combined cached content
|
||||||
|
var cachedContent = await getCombinedCachedContent(env);
|
||||||
|
|
||||||
|
const noProperties = cachedContent.properties?.length === 0;
|
||||||
|
const noPages = cachedContent.pages?.length === 0;
|
||||||
|
const noSettings = cachedContent.settings == {};
|
||||||
|
|
||||||
|
if (noProperties || noPages || noSettings) {
|
||||||
|
await importNotionNavigation(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noProperties) {
|
||||||
|
cachedContent.properties = await importNotionProperties(env);
|
||||||
|
cachedContent.images = await importImages(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noPages) {
|
||||||
|
cachedContent.pages = await importNotionPages(env);
|
||||||
|
cachedContent.images = await importImages(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noSettings) {
|
||||||
|
cachedContent.settings = await importNotionSettings(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedContent) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
...cachedContent,
|
||||||
|
properties: cachedContent.properties.filter(
|
||||||
|
(property) => property.active == 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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/routes/hooks.js
Normal file
259
src/routes/hooks.js
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import { importNotionNavigation } from "../objects/navigation";
|
||||||
|
import { importNotionSettings } from "../objects/settings";
|
||||||
|
import { importNotionPages, deleteNotionPageFromCache } from "../objects/pages";
|
||||||
|
import {
|
||||||
|
importNotionProperties,
|
||||||
|
importSmoobuProperties,
|
||||||
|
deleteNotionPropertyFromCache,
|
||||||
|
} from "../objects/properties";
|
||||||
|
import { globalHeaders } from "../utils/api";
|
||||||
|
import { importImages } from "../objects/images";
|
||||||
|
import {
|
||||||
|
importNotionBookings,
|
||||||
|
importSmoobuBookings,
|
||||||
|
deleteNotionBookingFromCache,
|
||||||
|
} from "../objects/bookings";
|
||||||
|
import {
|
||||||
|
importSmoobuGuests,
|
||||||
|
importNotionGuests,
|
||||||
|
deleteNotionGuestFromCache,
|
||||||
|
} from "../objects/guests";
|
||||||
|
import { handleBlurhashUpdate } from "../utils/imageCache";
|
||||||
|
|
||||||
|
async function updateAllNotionData(env) {
|
||||||
|
await importNotionNavigation(env);
|
||||||
|
const settings = await importNotionSettings(env);
|
||||||
|
const pages = await importNotionPages(env);
|
||||||
|
const properties = await importNotionProperties(env);
|
||||||
|
const bookings = await importNotionBookings(env);
|
||||||
|
return { settings, pages, properties, bookings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAllSmoobuData(env) {
|
||||||
|
const properties = await importSmoobuProperties(env);
|
||||||
|
const guests = await importSmoobuGuests(env);
|
||||||
|
const bookings = await importSmoobuBookings(env);
|
||||||
|
return { properties, guests, bookings };
|
||||||
|
}
|
||||||
|
|
||||||
|
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.PROPERTIES_DB) {
|
||||||
|
await importNotionNavigation(env);
|
||||||
|
const properties = await importNotionProperties(env, entityId);
|
||||||
|
const images = await importImages(env);
|
||||||
|
await handleBlurhashUpdate(request, env);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
status: "OK",
|
||||||
|
content: { properties, images },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dataSourceId === env.BOOKINGS_DB) {
|
||||||
|
const bookings = await importNotionBookings(env); // don't include entityId so whole list gets updated with filters.
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: "OK", content: { bookings } }),
|
||||||
|
{
|
||||||
|
headers: globalHeaders,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dataSourceId === env.GUESTS_DB) {
|
||||||
|
const guests = await importNotionGuests(env, entityId);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: "OK", content: { guests } }),
|
||||||
|
{
|
||||||
|
headers: globalHeaders,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("Page properties 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:
|
||||||
|
await importNotionSettings(env);
|
||||||
|
const pages = await deleteNotionPageFromCache(env, entityId);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: "OK", content: { pages } }),
|
||||||
|
{
|
||||||
|
headers: globalHeaders,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
case env.PROPERTIES_DB:
|
||||||
|
await importNotionNavigation(env);
|
||||||
|
const properties = await deleteNotionPropertyFromCache(env, entityId);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: "OK", content: { properties } }),
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
case env.BOOKINGS_DB:
|
||||||
|
const bookings = await deleteNotionBookingFromCache(env, entityId);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: "OK", content: { bookings } }),
|
||||||
|
{
|
||||||
|
headers: globalHeaders,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
case env.GUESTS_DB:
|
||||||
|
const guests = await deleteNotionGuestFromCache(env, entityId);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: "OK", content: { guests } }),
|
||||||
|
{
|
||||||
|
headers: globalHeaders,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("Page properties 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSmoobuHook(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("Body received:", body);
|
||||||
|
if (
|
||||||
|
body.type == "page.properties_updated" &&
|
||||||
|
body.data?.parent?.data_source_id
|
||||||
|
) {
|
||||||
|
console.log("Got body to parse. Calling updateAllSmoobuData...");
|
||||||
|
const content = await updateAllSmoobuData(env);
|
||||||
|
return new Response(JSON.stringify({ status: "OK", content }), {
|
||||||
|
headers: globalHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
console.log("No body to parse. Calling call fetch requests...");
|
||||||
|
const content = await updateAllSmoobuData(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,
|
||||||
|
};
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
455
src/utils/bookings.js
Normal file
455
src/utils/bookings.js
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
import { getItemByNotionId, toCamelCase } from "./notion.js";
|
||||||
|
import { getItemBySmoobuId } from "./smoobu.js";
|
||||||
|
import { updateNotionPage, addToNotionDataSource } from "./notion.js";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import diff from "microdiff";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
function convertBookingDatesToISO(booking, timezone) {
|
||||||
|
const arrivalISO =
|
||||||
|
booking.arrival && booking["check-in"]
|
||||||
|
? dayjs
|
||||||
|
.tz(`${booking.arrival}T${booking["check-in"]}:00`, timezone || "UTC")
|
||||||
|
.toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const departureISO =
|
||||||
|
booking.departure && booking["check-out"]
|
||||||
|
? dayjs
|
||||||
|
.tz(
|
||||||
|
`${booking.departure}T${booking["check-out"]}:00`,
|
||||||
|
timezone || "UTC"
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let finalArrivalISO = arrivalISO;
|
||||||
|
let finalDepartureISO = departureISO;
|
||||||
|
|
||||||
|
if (arrivalISO && departureISO) {
|
||||||
|
if (dayjs(departureISO).isBefore(dayjs(arrivalISO))) {
|
||||||
|
// Swap if departure is before arrival
|
||||||
|
finalArrivalISO = departureISO;
|
||||||
|
finalDepartureISO = arrivalISO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { arrivalISO: finalArrivalISO, departureISO: finalDepartureISO };
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertISOToOffetTime(iso, timezone) {
|
||||||
|
const date = dayjs(iso);
|
||||||
|
if (timezone) {
|
||||||
|
return date.tz(timezone).format("YYYY-MM-DDTHH:mm:ssZ");
|
||||||
|
}
|
||||||
|
return date.format("YYYY-MM-DDTHH:mm:ssZ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBookingsCache(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"}/bookings`
|
||||||
|
);
|
||||||
|
const cachedResponse = await cache.match(cacheKey);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
const cachedData = await cachedResponse.json();
|
||||||
|
console.log("Bookings 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.BOOKINGS_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"}/bookings`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = new Response(JSON.stringify(kvData), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "max-age=180", // 2 minute TTL
|
||||||
|
ETag: `"bookings-${Date.now()}"`, // Add ETag for cache validation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await cache.put(cacheKey, response);
|
||||||
|
console.log("Bookings 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 bookings cache:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeBookingsCache(env, bookings) {
|
||||||
|
try {
|
||||||
|
// Always store in KV first
|
||||||
|
await env.CONTENT_KV.put(env.BOOKINGS_KEY, JSON.stringify(bookings));
|
||||||
|
console.log("Bookings stored in KV.");
|
||||||
|
|
||||||
|
// Purge the Cloudflare Cache API
|
||||||
|
try {
|
||||||
|
const cache = caches.default;
|
||||||
|
const cacheKey = new Request(
|
||||||
|
`https://${env.CACHE_URL || "cache"}/bookings`
|
||||||
|
);
|
||||||
|
|
||||||
|
await cache.delete(cacheKey);
|
||||||
|
console.log("Bookings cache purged successfully.");
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn(
|
||||||
|
"Error purging Cloudflare cache, but KV was updated successfully:",
|
||||||
|
cacheError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error storing bookings cache:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertSmoobuSource(source) {
|
||||||
|
if (source === "Booking.com") {
|
||||||
|
return "bookingcom";
|
||||||
|
} else if (source === "Blocked channel") {
|
||||||
|
return "blocked";
|
||||||
|
} else if (source === "Airbnb") {
|
||||||
|
return "airbnb";
|
||||||
|
} else {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transformSmoobuBooking(
|
||||||
|
env,
|
||||||
|
booking,
|
||||||
|
propertiesData,
|
||||||
|
guestsData
|
||||||
|
) {
|
||||||
|
console.log("Transforming booking:", booking.id);
|
||||||
|
const bookingSource = convertSmoobuSource(booking.channel.name);
|
||||||
|
const smoobuPropertyId = booking.apartment.id;
|
||||||
|
const smoobuGuestId = booking.guestId;
|
||||||
|
|
||||||
|
const property = getItemBySmoobuId(propertiesData, smoobuPropertyId);
|
||||||
|
const guest = getItemBySmoobuId(guestsData, smoobuGuestId);
|
||||||
|
const status = getBookingStatus(booking, property.timezone);
|
||||||
|
|
||||||
|
const checkInDate = new Date(
|
||||||
|
convertBookingDatesToISO(booking, property.timezone).arrivalISO
|
||||||
|
);
|
||||||
|
const checkOutDate = new Date(
|
||||||
|
convertBookingDatesToISO(booking, property.timezone).departureISO
|
||||||
|
);
|
||||||
|
|
||||||
|
if (status == "complete") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
smoobuId: booking.id,
|
||||||
|
reference: booking["reference-id"] || null,
|
||||||
|
checkIn: checkInDate.toISOString(),
|
||||||
|
checkOut: checkOutDate.toISOString(),
|
||||||
|
status: status,
|
||||||
|
adults: booking.adults || 0,
|
||||||
|
children: booking.children || 0,
|
||||||
|
price: booking.price || 0,
|
||||||
|
source: bookingSource,
|
||||||
|
property: {
|
||||||
|
notionId: property?.notionId,
|
||||||
|
smoobuId: property?.smoobuId,
|
||||||
|
timezone: property?.timezone,
|
||||||
|
name: property?.name,
|
||||||
|
},
|
||||||
|
guest: guest
|
||||||
|
? {
|
||||||
|
notionId: guest?.notionId,
|
||||||
|
smoobuId: guest?.smoobuId,
|
||||||
|
name: guest?.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transformNotionBooking(
|
||||||
|
env,
|
||||||
|
notionBooking,
|
||||||
|
propertiesData,
|
||||||
|
guestsData
|
||||||
|
) {
|
||||||
|
const properties = notionBooking.properties;
|
||||||
|
|
||||||
|
const source = properties["Source"].select.name
|
||||||
|
.replaceAll(".", "")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
const status = properties["Status"].status.name.toLowerCase();
|
||||||
|
|
||||||
|
const adults = properties["Adults"]?.number || 0;
|
||||||
|
const children = properties["Children"]?.number || 0;
|
||||||
|
const reference = properties["Reference"]?.rich_text?.[0]?.plain_text || null;
|
||||||
|
const price = properties["Price"]?.number || 0;
|
||||||
|
const smoobuId = properties["Smoobu ID"]?.number || null;
|
||||||
|
|
||||||
|
const checkIn = new Date(properties["Date"]?.date?.start || undefined);
|
||||||
|
const checkOut = new Date(properties["Date"]?.date?.end || undefined);
|
||||||
|
|
||||||
|
const notionPropertyId =
|
||||||
|
properties["Property"]?.relation?.[0]?.id || undefined;
|
||||||
|
|
||||||
|
const notionGuestId = properties["Guest"]?.relation?.[0]?.id || undefined;
|
||||||
|
|
||||||
|
const notionProperty = getItemByNotionId(propertiesData, notionPropertyId);
|
||||||
|
let notionGuest = null;
|
||||||
|
if (notionGuestId) {
|
||||||
|
notionGuest = getItemByNotionId(guestsData, notionGuestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == "complete") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract slug from formula
|
||||||
|
|
||||||
|
return {
|
||||||
|
notionId: notionBooking.id,
|
||||||
|
reference: reference,
|
||||||
|
status: status,
|
||||||
|
source: source,
|
||||||
|
adults: adults,
|
||||||
|
children: children,
|
||||||
|
price: price,
|
||||||
|
smoobuId: smoobuId,
|
||||||
|
checkIn: checkIn.toISOString(),
|
||||||
|
checkOut: checkOut.toISOString(),
|
||||||
|
property: {
|
||||||
|
notionId: notionProperty?.notionId,
|
||||||
|
smoobuId: notionProperty?.smoobuId,
|
||||||
|
timezone: notionProperty?.timezone,
|
||||||
|
name: notionProperty?.name,
|
||||||
|
},
|
||||||
|
guest:
|
||||||
|
notionGuest !== null
|
||||||
|
? {
|
||||||
|
notionId: notionGuest?.notionId,
|
||||||
|
smoobuId: notionGuest?.smoobuId,
|
||||||
|
name: notionGuest?.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff bookings by smoobuId (or notionId if present)
|
||||||
|
export function diffBookings(newList, oldList) {
|
||||||
|
// Keys to check for changes (set to null to check all keys)
|
||||||
|
const keysToCheck = [
|
||||||
|
"reference",
|
||||||
|
"checkIn",
|
||||||
|
"checkOut",
|
||||||
|
"status",
|
||||||
|
"adults",
|
||||||
|
"children",
|
||||||
|
"price",
|
||||||
|
"source",
|
||||||
|
"property",
|
||||||
|
"guest",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper: index by smoobuId
|
||||||
|
const oldById = Object.fromEntries(
|
||||||
|
(oldList || []).map((b) => [b.smoobuId, b])
|
||||||
|
);
|
||||||
|
const newById = Object.fromEntries(
|
||||||
|
(newList || []).map((b) => [b.smoobuId, b])
|
||||||
|
);
|
||||||
|
|
||||||
|
// toAdd: in newList but not in oldList
|
||||||
|
const toAdd = newList.filter((b) => !oldById[b.smoobuId]);
|
||||||
|
|
||||||
|
// toDelete: in oldList but not in newList
|
||||||
|
const toDelete = oldList.filter((b) => !newById[b.smoobuId]);
|
||||||
|
|
||||||
|
// toUpdate: in both, but with different content (using microdiff)
|
||||||
|
const toUpdate = newList.filter((b) => {
|
||||||
|
const old = oldById[b.smoobuId];
|
||||||
|
if (!old) return false;
|
||||||
|
|
||||||
|
// Exclude notionId from comparison
|
||||||
|
const { notionId, ...restNew } = b;
|
||||||
|
const { notionId: _, ...restOld } = old;
|
||||||
|
|
||||||
|
// Use microdiff to get differences
|
||||||
|
const differences = diff(restOld, restNew);
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertSourceToNotionSource(source) {
|
||||||
|
if (source === "bookingcom") {
|
||||||
|
return "Booking.com";
|
||||||
|
} else if (source === "blocked") {
|
||||||
|
return "Blocked";
|
||||||
|
} else if (source === "airbnb") {
|
||||||
|
return "Airbnb";
|
||||||
|
} else {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertStatusToNotionStatus(status) {
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return "Cancelled";
|
||||||
|
} else if (status === "complete") {
|
||||||
|
return "Complete";
|
||||||
|
} else if (status === "confirmed") {
|
||||||
|
return "Confirmed";
|
||||||
|
} else if (status === "hosting") {
|
||||||
|
return "Hosting";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notion helpers for bookings
|
||||||
|
export async function addBookingToNotion(env, booking) {
|
||||||
|
console.log("Adding booking to Notion:", booking.smoobuId);
|
||||||
|
const name = `${dayjs(booking.checkIn).format("DD/MM/YYYY")} - ${
|
||||||
|
booking?.guest?.name || "Unknown Guest"
|
||||||
|
}`;
|
||||||
|
const notionProps = {
|
||||||
|
Name: name,
|
||||||
|
"Smoobu ID": booking.smoobuId,
|
||||||
|
Status: { status: { name: convertStatusToNotionStatus(booking.status) } },
|
||||||
|
Reference: booking.reference,
|
||||||
|
Date: {
|
||||||
|
date: {
|
||||||
|
start: convertISOToOffetTime(
|
||||||
|
booking.checkIn,
|
||||||
|
booking.property.timezone
|
||||||
|
),
|
||||||
|
end: convertISOToOffetTime(booking.checkOut, booking.property.timezone),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Adults: booking.adults,
|
||||||
|
Children: booking.children,
|
||||||
|
Price: booking.price,
|
||||||
|
Source: { select: { name: convertSourceToNotionSource(booking.source) } },
|
||||||
|
Property: booking.property?.notionId
|
||||||
|
? { relation: [{ id: booking.property.notionId }] }
|
||||||
|
: undefined,
|
||||||
|
Guest: booking.guest?.notionId
|
||||||
|
? { relation: [{ id: booking.guest.notionId }] }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
Object.keys(notionProps).forEach(
|
||||||
|
(k) => notionProps[k] === undefined && delete notionProps[k]
|
||||||
|
);
|
||||||
|
const res = await addToNotionDataSource(
|
||||||
|
notionProps,
|
||||||
|
env.BOOKINGS_DB,
|
||||||
|
"https://www.notion.so/icons/book_gray.svg"
|
||||||
|
);
|
||||||
|
return { ...booking, notionId: res.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNotionBooking(env, booking, notionId) {
|
||||||
|
const notionProps = {
|
||||||
|
Reference: booking.reference,
|
||||||
|
Status: { status: { name: convertStatusToNotionStatus(booking.status) } },
|
||||||
|
Date: {
|
||||||
|
date: {
|
||||||
|
start: convertISOToOffetTime(
|
||||||
|
booking.checkIn,
|
||||||
|
booking.property.timezone
|
||||||
|
),
|
||||||
|
end: convertISOToOffetTime(booking.checkOut, booking.property.timezone),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Adults: booking.adults,
|
||||||
|
Children: booking.children,
|
||||||
|
Price: booking.price,
|
||||||
|
Source: { select: { name: convertSourceToNotionSource(booking.source) } },
|
||||||
|
Property: booking.property?.notionId
|
||||||
|
? { relation: [{ id: booking.property.notionId }] }
|
||||||
|
: undefined,
|
||||||
|
Guest: booking.guest?.notionId
|
||||||
|
? { relation: [{ id: booking.guest.notionId }] }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
Object.keys(notionProps).forEach(
|
||||||
|
(k) => notionProps[k] === undefined && delete notionProps[k]
|
||||||
|
);
|
||||||
|
await updateNotionPage(notionId, notionProps);
|
||||||
|
return { ...booking, notionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotionBooking(env, notionId) {
|
||||||
|
await updateNotionPage(notionId, {}, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current time is during or after a booking period
|
||||||
|
* @param {string|Date} checkIn - The check-in time (ISO string or Date object)
|
||||||
|
* @param {string|Date} checkOut - The check-out time (ISO string or Date object)
|
||||||
|
* @returns {boolean} - True if current time is during or after the booking period
|
||||||
|
*/
|
||||||
|
export function getBookingStatus(smoobuBooking, timezone) {
|
||||||
|
const now = new Date();
|
||||||
|
const checkInDate = new Date(
|
||||||
|
convertBookingDatesToISO(smoobuBooking, timezone).arrivalISO
|
||||||
|
);
|
||||||
|
const checkOutDate = new Date(
|
||||||
|
convertBookingDatesToISO(smoobuBooking, timezone).departureISO
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return true if current time is on or after the check-in date
|
||||||
|
|
||||||
|
if (smoobuBooking.type == "cancellation") {
|
||||||
|
return "cancelled";
|
||||||
|
}
|
||||||
|
if (now >= checkInDate) {
|
||||||
|
return "hosting";
|
||||||
|
} else if (now >= checkOutDate) {
|
||||||
|
return "complete";
|
||||||
|
} else {
|
||||||
|
return "confirmed";
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/utils/contentCache.js
Normal file
30
src/utils/contentCache.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { getImages } from "./imageCache.js";
|
||||||
|
import { getPages } from "./pages.js";
|
||||||
|
import { getProperties } from "./properties.js";
|
||||||
|
import { getSettings } from "./settings.js";
|
||||||
|
|
||||||
|
export async function getCombinedCachedContent(env) {
|
||||||
|
try {
|
||||||
|
// Get current images from image cache
|
||||||
|
const cachedImages = await getImages(env, true);
|
||||||
|
const cachedPages = await getPages(env, true);
|
||||||
|
const cachedProperties = await getProperties(env, true);
|
||||||
|
const cachedSettings = await getSettings(env, true);
|
||||||
|
|
||||||
|
// Combine content with current images
|
||||||
|
const content = {
|
||||||
|
pages: cachedPages,
|
||||||
|
properties: cachedProperties,
|
||||||
|
settings: cachedSettings,
|
||||||
|
images: cachedImages,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Serving content...");
|
||||||
|
return content;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error getting combined cached content:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/utils/guests.js
Normal file
221
src/utils/guests.js
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { updateNotionPage, addToNotionDataSource } from "./notion.js";
|
||||||
|
import diff from "microdiff";
|
||||||
|
|
||||||
|
export async function getGuestsCache(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"}/guests`
|
||||||
|
);
|
||||||
|
const cachedResponse = await cache.match(cacheKey);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
const cachedData = await cachedResponse.json();
|
||||||
|
console.log("Guests 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.GUESTS_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"}/guests`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = new Response(JSON.stringify(kvData), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "max-age=300", // 5 minute TTL
|
||||||
|
ETag: `"guests-${Date.now()}"`, // Add ETag for cache validation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await cache.put(cacheKey, response);
|
||||||
|
console.log("Guests 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 guests cache:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeGuestsCache(env, guests) {
|
||||||
|
try {
|
||||||
|
// Always store in KV first
|
||||||
|
await env.CONTENT_KV.put(env.GUESTS_KEY, JSON.stringify(guests));
|
||||||
|
console.log("Guests stored in KV.");
|
||||||
|
|
||||||
|
// Purge the Cloudflare Cache API
|
||||||
|
try {
|
||||||
|
const cache = caches.default;
|
||||||
|
const cacheKey = new Request(
|
||||||
|
`https://${env.CACHE_URL || "cache"}/guests`
|
||||||
|
);
|
||||||
|
|
||||||
|
await cache.delete(cacheKey);
|
||||||
|
console.log("Guests cache purged successfully.");
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn(
|
||||||
|
"Error purging Cloudflare cache, but KV was updated successfully:",
|
||||||
|
cacheError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error storing guests cache:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transformSmoobuGuest(env, guest) {
|
||||||
|
console.log("Transforming guest:", guest.id);
|
||||||
|
return {
|
||||||
|
smoobuId: guest.id,
|
||||||
|
name:
|
||||||
|
guest.name || guest.firstName + " " + guest.lastName || "Unknown Guest",
|
||||||
|
email: guest.emails[0] || "",
|
||||||
|
phone: guest?.telephoneNumbers[0] || "",
|
||||||
|
firstName: guest.firstName || "",
|
||||||
|
lastName: guest.lastName || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transformNotionGuest(env, notionGuest) {
|
||||||
|
const properties = notionGuest.properties;
|
||||||
|
|
||||||
|
const name = properties["Name"]?.title?.[0]?.plain_text || "Unknown Guest";
|
||||||
|
const email = properties["Email"]?.email || "";
|
||||||
|
const phone = properties["Phone"]?.phone_number || "";
|
||||||
|
const firstName = properties["First Name"]?.rich_text?.[0]?.plain_text || "";
|
||||||
|
const lastName = properties["Last Name"]?.rich_text?.[0]?.plain_text || "";
|
||||||
|
|
||||||
|
const smoobuId = properties["Smoobu ID"]?.number || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
notionId: notionGuest.id,
|
||||||
|
smoobuId: smoobuId,
|
||||||
|
name: name,
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff guests by smoobuId (or notionId if present)
|
||||||
|
export function diffGuests(newList, oldList) {
|
||||||
|
// Keys to check for changes (set to null to check all keys)
|
||||||
|
const keysToCheck = ["name", "email", "phone", "firstName", "lastName"];
|
||||||
|
|
||||||
|
// Helper: index by smoobuId
|
||||||
|
const oldById = Object.fromEntries(
|
||||||
|
(oldList || []).map((g) => [g.smoobuId, g])
|
||||||
|
);
|
||||||
|
const newById = Object.fromEntries(
|
||||||
|
(newList || []).map((g) => [g.smoobuId, g])
|
||||||
|
);
|
||||||
|
|
||||||
|
// toAdd: in newList but not in oldList
|
||||||
|
const toAdd = newList.filter((g) => !oldById[g.smoobuId]);
|
||||||
|
|
||||||
|
// toDelete: in oldList but not in newList
|
||||||
|
const toDelete = oldList.filter((g) => !newById[g.smoobuId]);
|
||||||
|
|
||||||
|
// toUpdate: in both, but with different content (using microdiff)
|
||||||
|
const toUpdate = newList.filter((g) => {
|
||||||
|
const old = oldById[g.smoobuId];
|
||||||
|
if (!old) return false;
|
||||||
|
|
||||||
|
// Exclude notionId from comparison
|
||||||
|
const { notionId, ...restNew } = g;
|
||||||
|
const { notionId: _, ...restOld } = old;
|
||||||
|
|
||||||
|
// Use microdiff to get differences
|
||||||
|
const differences = diff(restOld, restNew);
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notion helpers for guests
|
||||||
|
export async function addGuestToNotion(env, guest) {
|
||||||
|
console.log("Adding guest to Notion:", guest.smoobuId);
|
||||||
|
const name =
|
||||||
|
guest.name ||
|
||||||
|
`${guest.firstName || ""} ${guest.lastName || ""}`.trim() ||
|
||||||
|
"Unknown Guest";
|
||||||
|
const notionProps = {
|
||||||
|
Name: name,
|
||||||
|
"Smoobu ID": guest.smoobuId,
|
||||||
|
Email: guest.email,
|
||||||
|
Phone: guest.phone
|
||||||
|
? {
|
||||||
|
phone_number: guest.phone,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
"First Name": guest.firstName,
|
||||||
|
"Last Name": guest.lastName,
|
||||||
|
};
|
||||||
|
Object.keys(notionProps).forEach(
|
||||||
|
(k) => notionProps[k] === undefined && delete notionProps[k]
|
||||||
|
);
|
||||||
|
const res = await addToNotionDataSource(
|
||||||
|
notionProps,
|
||||||
|
env.GUESTS_DB,
|
||||||
|
"https://www.notion.so/icons/user_gray.svg"
|
||||||
|
);
|
||||||
|
return { ...guest, notionId: res.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNotionGuest(env, guest, notionId) {
|
||||||
|
const notionProps = {
|
||||||
|
Email: guest.email,
|
||||||
|
Phone: guest.phone
|
||||||
|
? {
|
||||||
|
phone_number: guest.phone,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
"First Name": guest.firstName,
|
||||||
|
"Last Name": guest.lastName,
|
||||||
|
};
|
||||||
|
Object.keys(notionProps).forEach(
|
||||||
|
(k) => notionProps[k] === undefined && delete notionProps[k]
|
||||||
|
);
|
||||||
|
await updateNotionPage(notionId, notionProps);
|
||||||
|
return { ...guest, notionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotionGuest(env, notionId) {
|
||||||
|
await updateNotionPage(notionId, {}, true, true);
|
||||||
|
}
|
||||||
56
src/utils/icon.js
Normal file
56
src/utils/icon.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { parse, stringify } from "svgson";
|
||||||
|
|
||||||
|
async function processSVG(svgText, cssClass) {
|
||||||
|
try {
|
||||||
|
// Parse SVG to JSON
|
||||||
|
const json = await parse(svgText);
|
||||||
|
|
||||||
|
// Remove all fill attributes recursively
|
||||||
|
const removeFill = (node) => {
|
||||||
|
if (node.attributes?.fill) delete node.attributes.fill;
|
||||||
|
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.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;
|
||||||
|
}
|
||||||
313
src/utils/imageCache.js
Normal file
313
src/utils/imageCache.js
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
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.THD_CONTENT.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.THD_CONTENT.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectImageUrls(pages, properties) {
|
||||||
|
const imageUrls = [];
|
||||||
|
|
||||||
|
// Collect from pages
|
||||||
|
for (const page of pages) {
|
||||||
|
page.images?.forEach((url) => imageUrls.push(url));
|
||||||
|
page.content?.forEach(
|
||||||
|
(block) =>
|
||||||
|
block.type === "image" && block.url && imageUrls.push(block.url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect from properties
|
||||||
|
for (const property of properties) {
|
||||||
|
property.images?.forEach((url) => imageUrls.push(url));
|
||||||
|
property.content?.forEach(
|
||||||
|
(block) =>
|
||||||
|
block.type === "image" && block.url && imageUrls.push(block.url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "th-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 == "property" ? `properties/${slug}` : slug,
|
||||||
|
name: name,
|
||||||
|
icon: icon,
|
||||||
|
notionId: id,
|
||||||
|
type: type,
|
||||||
|
};
|
||||||
|
}
|
||||||
573
src/utils/notion.js
Normal file
573
src/utils/notion.js
Normal file
@ -0,0 +1,573 @@
|
|||||||
|
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...");
|
||||||
|
console.log("Properties:", properties);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
console.log(`Transforming content for page: ${pageId}`);
|
||||||
|
|
||||||
|
const navigationItems = await getNavigation(env);
|
||||||
|
|
||||||
|
// 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":
|
||||||
|
const paragraphText = await extractRichText(
|
||||||
|
block.paragraph.rich_text,
|
||||||
|
navigationItems
|
||||||
|
);
|
||||||
|
if (paragraphText.trim()) {
|
||||||
|
content.push({
|
||||||
|
type: "paragraph",
|
||||||
|
text: paragraphText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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":
|
||||||
|
content.push({
|
||||||
|
type: "callout",
|
||||||
|
text: await extractRichText(
|
||||||
|
block.callout.rich_text,
|
||||||
|
navigationItems
|
||||||
|
),
|
||||||
|
icon: block.callout.icon?.emoji || null,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
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(text, href, navigationItems) {
|
||||||
|
const icon = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g>
|
||||||
|
<path d="M56.855,1.184L56.816,1.184L9.356,1.184C6.044,1.184 3.356,3.872 3.356,7.184C3.356,10.495 6.044,13.184 9.356,13.184L42.331,13.184L2.941,52.574C0.599,54.915 0.599,58.718 2.941,61.059C5.282,63.401 9.085,63.401 11.426,61.059L50.816,21.669L50.816,54.644C50.816,57.956 53.505,60.644 56.816,60.644C60.128,60.644 62.816,57.956 62.816,54.644L62.816,7.184C62.816,6.374 62.656,5.601 62.364,4.896L62.357,4.879C62.065,4.174 61.632,3.514 61.059,2.941C60.486,2.368 59.826,1.935 59.121,1.643L59.104,1.636C58.827,1.521 58.539,1.427 58.243,1.354L58.204,1.345L58.166,1.336L58.128,1.328L58.09,1.319L58.052,1.311L58.014,1.303L57.988,1.298L57.952,1.291L57.915,1.284L57.878,1.277L57.841,1.271L57.804,1.265L57.767,1.259L57.73,1.253L57.692,1.247L57.655,1.242L57.618,1.237L57.58,1.232L57.543,1.227L57.505,1.223L57.467,1.218L57.43,1.215L57.392,1.211L57.354,1.207L57.316,1.204L57.278,1.201L57.24,1.198L57.202,1.196L57.163,1.193L57.125,1.191L57.087,1.19L57.048,1.188L57.01,1.187L56.971,1.185L56.933,1.185L56.894,1.184L56.855,1.184Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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("#", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textSpan = `<span class="th-link-text">${text}</span>`;
|
||||||
|
const iconSpan = `<span class="th-link-icon">${icon}</span>`;
|
||||||
|
|
||||||
|
if (pageIdNoDashes != null) {
|
||||||
|
const linkedNavigationItem = getItemByNotionId(
|
||||||
|
navigationItems,
|
||||||
|
pageIdNoDashes
|
||||||
|
);
|
||||||
|
return `<a href="/${linkedNavigationItem.slug}" class="th-link">${textSpan}</a>`;
|
||||||
|
} else {
|
||||||
|
return `<a href="${href}" class="th-link">${textSpan}${iconSpan}</a>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
// Handle custom emoji mentions
|
||||||
|
if (
|
||||||
|
textObj.type === "mention" &&
|
||||||
|
textObj.mention?.type === "custom_emoji" &&
|
||||||
|
textObj.mention.custom_emoji?.url
|
||||||
|
) {
|
||||||
|
const emoji = await processNotionIcon(
|
||||||
|
textObj.mention.custom_emoji,
|
||||||
|
"th-inline-emoji"
|
||||||
|
);
|
||||||
|
return emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = textObj.plain_text || "";
|
||||||
|
|
||||||
|
// Handle formatting
|
||||||
|
if (textObj.annotations) {
|
||||||
|
const annotations = textObj.annotations;
|
||||||
|
|
||||||
|
if (annotations.bold) {
|
||||||
|
text = `<strong>${text}</strong>`;
|
||||||
|
}
|
||||||
|
if (annotations.italic) {
|
||||||
|
text = `<i>${text}</i>`;
|
||||||
|
}
|
||||||
|
if (annotations.underline) {
|
||||||
|
text = `<u>${text}</u>`;
|
||||||
|
}
|
||||||
|
if (annotations.strikethrough) {
|
||||||
|
text = `<s>${text}</s>`;
|
||||||
|
}
|
||||||
|
if (annotations.code) {
|
||||||
|
text = `<code>${text}</code>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle links
|
||||||
|
if (textObj.href) {
|
||||||
|
text = processLink(text, textObj.href, navigationItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return textPieces.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 properties.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;
|
||||||
|
}
|
||||||
172
src/utils/pages.js
Normal file
172
src/utils/pages.js
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
transformContent,
|
||||||
|
toCamelCase,
|
||||||
|
getItemByNotionId,
|
||||||
|
} from "../utils/notion.js";
|
||||||
|
import { getProperties } from "./properties";
|
||||||
|
import { getSettings } from "./settings";
|
||||||
|
// 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,
|
||||||
|
propertiesData,
|
||||||
|
settingsData
|
||||||
|
) {
|
||||||
|
const properties = notionPage.properties;
|
||||||
|
|
||||||
|
// Extract theme information
|
||||||
|
let theme = "light"; // 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"];
|
||||||
|
// Check if page is published - if not, return null to filter it out
|
||||||
|
if (published?.checkbox == false) {
|
||||||
|
return 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);
|
||||||
|
|
||||||
|
const pageType = toCamelCase(properties["Page Type"].select.name);
|
||||||
|
// 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 = await transformContent(env, notionPage.id);
|
||||||
|
|
||||||
|
const showProperties = properties["Show Properties"]?.checkbox || false;
|
||||||
|
const showContactForm = properties["Show Contact Form"]?.checkbox || false;
|
||||||
|
const hideMobileImage = properties["Hide Mobile Image"]?.checkbox || false;
|
||||||
|
const invertHeader = properties["Invert Header"]?.checkbox || false;
|
||||||
|
|
||||||
|
const showScroll = properties["Show Scroll Icon"]?.checkbox || false;
|
||||||
|
|
||||||
|
if (showProperties == true) {
|
||||||
|
content.push({
|
||||||
|
type: "properties",
|
||||||
|
properties: propertiesData
|
||||||
|
.filter((property) => property.active == true)
|
||||||
|
.map((property) => ({
|
||||||
|
name: property.name,
|
||||||
|
address: property.address,
|
||||||
|
features: property.features,
|
||||||
|
images: property.images,
|
||||||
|
slug: property.slug,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showContactForm == true) {
|
||||||
|
content.push({
|
||||||
|
type: "contactForm",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
notionId: notionPage.id,
|
||||||
|
slug: slug,
|
||||||
|
name: name,
|
||||||
|
pageType: pageType,
|
||||||
|
content: content,
|
||||||
|
theme: theme,
|
||||||
|
images: images,
|
||||||
|
showScroll: showScroll,
|
||||||
|
showProperties: showProperties,
|
||||||
|
showContactForm: showContactForm,
|
||||||
|
hideMobileImage: hideMobileImage,
|
||||||
|
invertHeader: invertHeader,
|
||||||
|
};
|
||||||
|
}
|
||||||
334
src/utils/properties.js
Normal file
334
src/utils/properties.js
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import { extractRichText, transformContent } from "../utils/notion.js";
|
||||||
|
import { addToNotionDataSource, updateNotionPage } from "../utils/notion.js";
|
||||||
|
import {
|
||||||
|
fetchSmoobuProperties,
|
||||||
|
fetchSmoobuPropertyDetails,
|
||||||
|
} from "../utils/smoobu.js";
|
||||||
|
import _ from "lodash";
|
||||||
|
import diff from "microdiff";
|
||||||
|
|
||||||
|
export async function getProperties(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"}/properties`
|
||||||
|
);
|
||||||
|
const cachedResponse = await cache.match(cacheKey);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
const cachedData = await cachedResponse.json();
|
||||||
|
console.log("Properties 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.PROPERTIES_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"}/properties`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = new Response(JSON.stringify(kvData), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "max-age=60", // 1 minute TTL
|
||||||
|
ETag: `"properties-${Date.now()}"`, // Add ETag for cache validation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await cache.put(cacheKey, response);
|
||||||
|
console.log("Properties 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 properties cache:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeProperties(env, properties) {
|
||||||
|
try {
|
||||||
|
// Always store in KV first
|
||||||
|
await env.CONTENT_KV.put(env.PROPERTIES_KEY, JSON.stringify(properties));
|
||||||
|
console.log("Properties stored in KV.");
|
||||||
|
|
||||||
|
// Purge the Cloudflare Cache API
|
||||||
|
try {
|
||||||
|
const cache = caches.default;
|
||||||
|
const cacheKey = new Request(
|
||||||
|
`https://${env.CACHE_URL || "cache"}/properties`
|
||||||
|
);
|
||||||
|
|
||||||
|
await cache.delete(cacheKey);
|
||||||
|
console.log("Properties cache purged successfully.");
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn(
|
||||||
|
"Error purging Cloudflare cache, but KV was updated successfully:",
|
||||||
|
cacheError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error storing properties cache:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transformSmoobuProperty(env, property) {
|
||||||
|
console.log("Transforming property:", property.id);
|
||||||
|
return {
|
||||||
|
smoobuId: property.id,
|
||||||
|
name: property.name || "Unknown Property",
|
||||||
|
maxOccupancy: property.rooms?.maxOccupancy || 0,
|
||||||
|
timezone: property.timeZone || "UTC",
|
||||||
|
bedrooms: property.rooms?.bedrooms || 0,
|
||||||
|
bathrooms: property.rooms?.bathrooms || 0,
|
||||||
|
doubleBeds: property.rooms?.doubleBeds || 0,
|
||||||
|
singleBeds: property.rooms?.singleBeds || 0,
|
||||||
|
sofaBeds: property.rooms?.sofaBeds || 0,
|
||||||
|
sofas: property.rooms?.couches || 0,
|
||||||
|
childBeds: property.rooms?.childBeds || 0,
|
||||||
|
queenSizeBeds: property.rooms?.queenSizeBeds || 0,
|
||||||
|
kingSizeBeds: property.rooms?.kingSizeBeds || 0,
|
||||||
|
address: property.location
|
||||||
|
? `${property.location.street || ""}\n${property.location.city || ""}\n${
|
||||||
|
property.location.zip || ""
|
||||||
|
}`.trim()
|
||||||
|
: "Unknown Address",
|
||||||
|
features:
|
||||||
|
property.amenities.map((feature) => feature.replaceAll(",", ";")) || [],
|
||||||
|
minPrice: property.price?.minimal ? parseFloat(property.price.minimal) : 0,
|
||||||
|
maxPrice: property.price?.maximal ? parseFloat(property.price.maximal) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffProperties(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 property data to desired format
|
||||||
|
export async function transformNotionProperty(env, notionProperty) {
|
||||||
|
const properties = notionProperty.properties;
|
||||||
|
|
||||||
|
const active = properties["Active"].checkbox || false;
|
||||||
|
|
||||||
|
// 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 maxPrice = properties["Max Price"]?.number || 0;
|
||||||
|
const minPrice = properties["Min Price"]?.number || 0;
|
||||||
|
const maxOccupancy = properties["Max Occupancy"]?.number || 0;
|
||||||
|
const bedrooms = properties["Bedrooms"]?.number || 0;
|
||||||
|
const bathrooms = properties["Bathrooms"]?.number || 0;
|
||||||
|
const doubleBeds = properties["Double Beds"]?.number || 0;
|
||||||
|
const singleBeds = properties["Single Beds"]?.number || 0;
|
||||||
|
const sofaBeds = properties["Sofa Beds"]?.number || 0;
|
||||||
|
const sofas = properties["Sofas"]?.number || 0;
|
||||||
|
const childBeds = properties["Child Beds"]?.number || 0;
|
||||||
|
const queenSizeBeds = properties["Queen Size Beds"]?.number || 0;
|
||||||
|
const kingSizeBeds = properties["King Size Beds"]?.number || 0;
|
||||||
|
const timezone = properties["Timezone"]?.select?.name || "UTC";
|
||||||
|
|
||||||
|
const bookingcomLink = properties["Booking.com Link"]?.url || "#";
|
||||||
|
const airbnbLink = properties["Airbnb Link"]?.url || "#";
|
||||||
|
|
||||||
|
const address = await extractRichText(
|
||||||
|
properties["Address"]?.rich_text || "unknown"
|
||||||
|
);
|
||||||
|
|
||||||
|
const smoobuId = properties["Smoobu ID"]?.number || null;
|
||||||
|
|
||||||
|
const syncName = properties["Sync Name"]?.checkbox || false;
|
||||||
|
|
||||||
|
const features = properties["Features"]?.["multi_select"].map((feature) =>
|
||||||
|
feature.name.replaceAll(",", ";")
|
||||||
|
);
|
||||||
|
// 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, notionProperty.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notionId: notionProperty.id,
|
||||||
|
name: name,
|
||||||
|
images: images,
|
||||||
|
slug: slug,
|
||||||
|
maxPrice: maxPrice,
|
||||||
|
minPrice: minPrice,
|
||||||
|
maxOccupancy: maxOccupancy,
|
||||||
|
bedrooms: bedrooms,
|
||||||
|
bathrooms: bathrooms,
|
||||||
|
doubleBeds: doubleBeds,
|
||||||
|
singleBeds: singleBeds,
|
||||||
|
sofaBeds: sofaBeds,
|
||||||
|
sofas: sofas,
|
||||||
|
childBeds: childBeds,
|
||||||
|
queenSizeBeds: queenSizeBeds,
|
||||||
|
kingSizeBeds: kingSizeBeds,
|
||||||
|
timezone: timezone,
|
||||||
|
address: address,
|
||||||
|
features: features,
|
||||||
|
bookingcomLink: bookingcomLink,
|
||||||
|
airbnbLink: airbnbLink,
|
||||||
|
content,
|
||||||
|
smoobuId,
|
||||||
|
syncName,
|
||||||
|
active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Property Utilities moved from routes/properties.js ---
|
||||||
|
|
||||||
|
export async function addPropertyToNotion(env, property) {
|
||||||
|
console.log("Adding property to Notion:", property.smoobuId);
|
||||||
|
const details = await fetchSmoobuPropertyDetails(env, property.smoobuId);
|
||||||
|
const notionProps = {
|
||||||
|
Name: details.name,
|
||||||
|
"Max Occupancy": details.rooms.maxOccupancy,
|
||||||
|
Bedrooms: details.rooms.bedrooms,
|
||||||
|
Bathrooms: details.rooms.bathrooms,
|
||||||
|
"Double Beds": details.rooms.doubleBeds,
|
||||||
|
"Single Beds": details.rooms.singleBeds,
|
||||||
|
"Sofa Beds": details.rooms.sofaBeds,
|
||||||
|
Sofas: details.rooms.couches,
|
||||||
|
"Child Beds": details.rooms.childBeds,
|
||||||
|
"Queen Size Beds": details.rooms.queenSizeBeds,
|
||||||
|
"King Size Beds": details.rooms.kingSizeBeds,
|
||||||
|
Timezone: { select: { name: details.timeZone || "UTC" } },
|
||||||
|
Address: `${details.location.street}\n${details.location.city}\n${details.location.zip}`,
|
||||||
|
Features: details.amenities,
|
||||||
|
"Min Price": parseFloat(details.price.minimal),
|
||||||
|
"Max Price": parseFloat(details.price.maximal),
|
||||||
|
"Smoobu ID": details.id,
|
||||||
|
};
|
||||||
|
Object.keys(notionProps).forEach(
|
||||||
|
(k) => notionProps[k] === undefined && delete notionProps[k]
|
||||||
|
);
|
||||||
|
const res = await addToNotionDataSource(notionProps, env.PROPERTIES_DB);
|
||||||
|
return { ...property, notionId: res.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNotionProperty(env, property, notionId) {
|
||||||
|
console.log("Updating property in Notion:", property.smoobuId);
|
||||||
|
const details = await fetchSmoobuPropertyDetails(env, property.smoobuId);
|
||||||
|
const notionProps = {
|
||||||
|
...(property.syncName ? { Name: details.name } : {}),
|
||||||
|
"Max Occupancy": details.rooms.maxOccupancy,
|
||||||
|
Bedrooms: details.rooms.bedrooms,
|
||||||
|
Bathrooms: details.rooms.bathrooms,
|
||||||
|
"Double Beds": details.rooms.doubleBeds,
|
||||||
|
"Single Beds": details.rooms.singleBeds,
|
||||||
|
"Sofa Beds": details.rooms.sofaBeds,
|
||||||
|
Sofas: details.rooms.couches,
|
||||||
|
"Child Beds": details.rooms.childBeds,
|
||||||
|
"Queen Size Beds": details.rooms.queenSizeBeds,
|
||||||
|
"King Size Beds": details.rooms.kingSizeBeds,
|
||||||
|
Timezone: { select: { name: details.timeZone || "UTC" } },
|
||||||
|
Address: `${details.location.street}\n${details.location.city}\n${details.location.zip}`,
|
||||||
|
Features: details.amenities,
|
||||||
|
"Min Price": parseFloat(details.price.minimal),
|
||||||
|
"Max Price": parseFloat(details.price.maximal),
|
||||||
|
};
|
||||||
|
Object.keys(notionProps).forEach(
|
||||||
|
(k) => notionProps[k] === undefined && delete notionProps[k]
|
||||||
|
);
|
||||||
|
await updateNotionPage(notionId, notionProps);
|
||||||
|
return { ...property, notionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotionProperty(env, notionId) {
|
||||||
|
console.log("Deleting property from Notion:", notionId);
|
||||||
|
await updateNotionPage(notionId, {}, true, true);
|
||||||
|
}
|
||||||
212
src/utils/settings.js
Normal file
212
src/utils/settings.js
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
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 branding data to desired format
|
||||||
|
export async function transformBranding(brandingData) {
|
||||||
|
if (!brandingData || !Array.isArray(brandingData)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformed = await Promise.all(
|
||||||
|
brandingData.map(async (theme) => {
|
||||||
|
const properties = theme.properties;
|
||||||
|
|
||||||
|
// Extract theme name
|
||||||
|
const name = properties.Name?.title?.[0]?.plain_text || "Unknown";
|
||||||
|
|
||||||
|
// Extract background color
|
||||||
|
const size = properties["Size"].select.name || "Unknown";
|
||||||
|
|
||||||
|
const type = properties["Type"].select.name || "Unknown";
|
||||||
|
|
||||||
|
const content = await processNotionIcon(properties["File"].files[0].file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: toCamelCase(name),
|
||||||
|
size: toCamelCase(size),
|
||||||
|
type: toCamelCase(type),
|
||||||
|
content: content,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return transformed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform Notion page data to desired format
|
||||||
|
export async function transformSettingsData(
|
||||||
|
env,
|
||||||
|
globalThemesData = [],
|
||||||
|
redirectsData = [],
|
||||||
|
themesData = [],
|
||||||
|
brandingData = []
|
||||||
|
) {
|
||||||
|
const redirects = await transformRedirectsData(env, redirectsData);
|
||||||
|
const themes = await transformThemes(themesData);
|
||||||
|
const branding = await transformBranding(brandingData);
|
||||||
|
const globalThemes = transformGlobalThemesData(env, globalThemesData, themes);
|
||||||
|
|
||||||
|
return { redirects, themes, branding, 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;
|
||||||
|
}
|
||||||
119
src/utils/smoobu.js
Normal file
119
src/utils/smoobu.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// Smoobu API fetchers
|
||||||
|
|
||||||
|
export async function fetchSmoobuProperties(env) {
|
||||||
|
const response = await fetch(`${env.SMOOBU_API_URL}/apartments`, {
|
||||||
|
headers: { "Api-key": env.SMOOBU_API_KEY },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch properties: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data.apartments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSmoobuPropertyDetails(env, id) {
|
||||||
|
const res = await fetch(`${env.SMOOBU_API_URL}/apartments/${id}`, {
|
||||||
|
headers: { "Api-key": env.SMOOBU_API_KEY },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch details for apartment ${id}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
function buildQueryString(params) {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
// Add pagination params
|
||||||
|
queryParams.append("page", params.page || 1);
|
||||||
|
queryParams.append("pageSize", params.pageSize || PAGE_SIZE);
|
||||||
|
|
||||||
|
// Add other params if they exist
|
||||||
|
Object.keys(params).forEach((key) => {
|
||||||
|
if (
|
||||||
|
key !== "page" &&
|
||||||
|
key !== "pageSize" &&
|
||||||
|
params[key] !== undefined &&
|
||||||
|
params[key] !== null
|
||||||
|
) {
|
||||||
|
queryParams.append(key, params[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return queryParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSmoobuBookings(env, params = {}) {
|
||||||
|
let page = 1;
|
||||||
|
let allBookings = [];
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
// Build query string with current page and other params
|
||||||
|
const currentParams = { ...params, page, pageSize: PAGE_SIZE };
|
||||||
|
const queryString = buildQueryString(currentParams);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${env.SMOOBU_API_URL}/reservations?${queryString}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Api-key": env.SMOOBU_API_KEY,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to fetch page ${page}: ${response.statusText}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
allBookings.push(...data.bookings);
|
||||||
|
if (data.bookings.length < PAGE_SIZE) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allBookings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSmoobuGuests(env) {
|
||||||
|
let page = 1;
|
||||||
|
let allGuests = [];
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${env.SMOOBU_API_URL}/guests?page=${page}&pageSize=${PAGE_SIZE}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Api-key": env.SMOOBU_API_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(
|
||||||
|
`Failed to fetch guests page ${page}: ${response.statusText}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
allGuests.push(...data.guests);
|
||||||
|
if (data.guests.length < PAGE_SIZE) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allGuests;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemBySmoobuId(items, smoobuId) {
|
||||||
|
const item = items.find((item) => item.smoobuId === smoobuId);
|
||||||
|
if (item) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
BIN
th-api.paw
Normal file
BIN
th-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' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
137
wrangler.jsonc
Normal file
137
wrangler.jsonc
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 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": "thehideout-api",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"compatibility_date": "2025-02-24",
|
||||||
|
"observability": {
|
||||||
|
"enabled": true,
|
||||||
|
"head_sampling_rate": 1
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"crons": ["*/5 * * * *", "* * * * *"]
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 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": "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://thehideout.tombutcher.work/api/cache",
|
||||||
|
"R2_PUBLIC_URL": "https://pub-1fbfc6d5593a4a2a8e58813f4718d6bf.r2.dev",
|
||||||
|
"BLUR_HASH": "true",
|
||||||
|
"SMOOBU_API_URL": "https://login.smoobu.com/api"
|
||||||
|
},
|
||||||
|
|
||||||
|
"kv_namespaces": [
|
||||||
|
{
|
||||||
|
"binding": "CONTENT_KV", // the variable you’ll use in the Worker
|
||||||
|
"id": "05c5283d5b74488da7f90297ea350f9c" // ID from Cloudflare dashboard
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "THD_CONTENT",
|
||||||
|
"bucket_name": "thd-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": "THD_CONTENT",
|
||||||
|
"bucket_name": "thd-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.thehideoutltd.com/cache",
|
||||||
|
"R2_PUBLIC_URL": "https://cdn.thehideoutltd.com",
|
||||||
|
"BLUR_HASH": "true",
|
||||||
|
"CORS_ORIGIN": "https://thehideoutltd.com",
|
||||||
|
"SMOOBU_API_URL": "https://login.smoobu.com/api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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