Skip to content

Commit c8d5603

Browse files
committed
Conversion utilities and routes
1 parent d45ad70 commit c8d5603

5 files changed

Lines changed: 733 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { NextResponse, NextRequest } from "next/server";
2+
import { createClient } from "~/utils/supabase/server";
3+
import { asPostgrestFailure } from "@repo/database/lib/contextFunctions";
4+
import {
5+
defaultOptionsHandler,
6+
createApiResponse,
7+
} from "~/utils/supabase/apiUtils";
8+
import { asJsonLD } from "~/utils/conversion/jsonld";
9+
import { Tables } from "@repo/database/dbTypes";
10+
import { convert, initRT, MIMETYPES } from "~/utils/conversion/relationaltext";
11+
12+
type Concept = Tables<"Concept">;
13+
type Content = Tables<"Content">;
14+
type Space = Tables<"Space">;
15+
type PlatformAccount = Tables<"PlatformAccount">;
16+
17+
export type SegmentDataType = { params: Promise<Record<string, string>> };
18+
19+
export const GET = async (
20+
request: NextRequest,
21+
segmentData: SegmentDataType,
22+
): Promise<NextResponse> => {
23+
const { space_id, resource_id } = await segmentData.params;
24+
const targetFormat = request.nextUrl.searchParams.get("format") ?? "html";
25+
const targetMimetype = MIMETYPES[targetFormat];
26+
if (!targetMimetype) {
27+
return createApiResponse(
28+
request,
29+
asPostgrestFailure("Unsupported format", "404", 404),
30+
);
31+
}
32+
const includeData =
33+
targetFormat === "html" &&
34+
request.nextUrl.searchParams.get("data") !== "false";
35+
const spaceIdN = Number.parseInt(space_id || "NaN");
36+
if (isNaN(spaceIdN)) {
37+
return createApiResponse(
38+
request,
39+
asPostgrestFailure(`${space_id} is not a number`, "type"),
40+
);
41+
}
42+
const resourceIdN = Number.parseInt(resource_id || "NaN");
43+
if (isNaN(resourceIdN)) {
44+
return createApiResponse(
45+
request,
46+
asPostgrestFailure(`${resource_id} is not a number`, "type"),
47+
);
48+
}
49+
const supabase = await createClient();
50+
const spaceResponse = await supabase
51+
.from("Space")
52+
.select()
53+
.eq("id", spaceIdN)
54+
.maybeSingle();
55+
if (spaceResponse.error) {
56+
return createApiResponse(request, spaceResponse);
57+
}
58+
if (!spaceResponse.data) {
59+
// consideration: We may not see it because we don't have access,
60+
// so it would be worth re-fetching as superuser to see if I should redirect to login.
61+
return createApiResponse(
62+
request,
63+
asPostgrestFailure("Space not found", "401", 401),
64+
);
65+
}
66+
const space: Space = spaceResponse.data;
67+
const conceptResponse = await supabase
68+
.from("Concept")
69+
.select()
70+
.eq("id", resourceIdN)
71+
.maybeSingle();
72+
if (conceptResponse.error) {
73+
return createApiResponse(request, conceptResponse);
74+
}
75+
const concept = conceptResponse.data;
76+
if (!concept) {
77+
return createApiResponse(
78+
request,
79+
asPostgrestFailure("Resource not found", "401", 401),
80+
);
81+
}
82+
const contentResponse = await supabase
83+
.from("Content")
84+
.select()
85+
.eq("source_local_id", concept.source_local_id!);
86+
if (contentResponse.error) {
87+
return createApiResponse(request, conceptResponse);
88+
}
89+
const contents: Content[] = contentResponse.data;
90+
const requestUrlParts = request.url.split("/");
91+
const baseUrl = requestUrlParts
92+
.slice(0, requestUrlParts.length - 1)
93+
.join("/");
94+
const fullContentsArray = contents.filter((c) => c.variant === "full");
95+
const fullContents = fullContentsArray.length
96+
? fullContentsArray[0]
97+
: undefined;
98+
const titleArray = contents.filter((c) => c.variant === "direct");
99+
const title = titleArray.length ? titleArray[0] : undefined;
100+
101+
if (!fullContents) {
102+
return createApiResponse(
103+
request,
104+
asPostgrestFailure("Resource not found", "401", 401),
105+
);
106+
}
107+
108+
const rootUrl = baseUrl.split("/").slice(0, 3).join("/");
109+
await initRT(rootUrl);
110+
const source: string | undefined =
111+
space.platform === "Obsidian"
112+
? "obsidian"
113+
: space.platform === "Roam"
114+
? "roam"
115+
: undefined;
116+
let text =
117+
source && source !== targetFormat
118+
? await convert(fullContents.text, source, targetFormat)
119+
: fullContents.text;
120+
if (includeData) {
121+
const isSchema = concept.is_schema;
122+
let schema: Concept | undefined = undefined;
123+
if (!isSchema && concept.schema_id) {
124+
const schemaResponse = await supabase
125+
.from("Concept")
126+
.select()
127+
.eq("id", concept.schema_id)
128+
.maybeSingle();
129+
if (schemaResponse.error) {
130+
return createApiResponse(request, schemaResponse);
131+
}
132+
if (!schemaResponse.data) {
133+
return createApiResponse(
134+
request,
135+
asPostgrestFailure("Resource schema not found", "401", 401),
136+
);
137+
}
138+
schema = schemaResponse.data;
139+
}
140+
141+
const authorId = concept.author_id ?? (contents ?? [{}])[0]?.author_id;
142+
let author: PlatformAccount | undefined = undefined;
143+
if (authorId) {
144+
const authorResponse = await supabase
145+
.from("PlatformAccount")
146+
.select()
147+
.eq("id", authorId)
148+
.maybeSingle();
149+
if (authorResponse.data) author = authorResponse.data;
150+
}
151+
152+
const jsonLdData = await asJsonLD({
153+
space,
154+
concept,
155+
baseUrl,
156+
title,
157+
schema,
158+
content: undefined,
159+
author,
160+
targetFormat,
161+
wrap: true,
162+
});
163+
text = `<div id="content">\n<script type="application/ld+json">${JSON.stringify(jsonLdData)}</script>\n${text}\n</div>`;
164+
}
165+
166+
return new NextResponse(text, {
167+
headers: { "Content-Type": targetMimetype },
168+
});
169+
};
170+
171+
export const OPTIONS = defaultOptionsHandler;
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { NextResponse, NextRequest } from "next/server";
2+
import { createClient } from "~/utils/supabase/server";
3+
import { asPostgrestFailure } from "@repo/database/lib/contextFunctions";
4+
import {
5+
defaultOptionsHandler,
6+
createApiResponse,
7+
} from "~/utils/supabase/apiUtils";
8+
import { asJsonLD, wrapJsonLd } from "~/utils/conversion/jsonld";
9+
import { Tables } from "@repo/database/dbTypes";
10+
import { PostgrestMaybeSingleResponse } from "@supabase/supabase-js";
11+
12+
type Concept = Tables<"Concept">;
13+
type Content = Tables<"Content">;
14+
type Space = Tables<"Space">;
15+
type PlatformAccount = Tables<"PlatformAccount">;
16+
17+
export type SegmentDataType = { params: Promise<Record<string, string>> };
18+
19+
export const GET = async (
20+
request: NextRequest,
21+
segmentData: SegmentDataType,
22+
): Promise<NextResponse> => {
23+
const { space_id, resource_id } = await segmentData.params;
24+
const targetFormat = request.nextUrl.searchParams.get("format") ?? "html";
25+
const withContext = request.nextUrl.searchParams.get("context");
26+
const withSchema = request.nextUrl.searchParams.get("schema");
27+
const spaceIdN = Number.parseInt(space_id || "NaN");
28+
if (isNaN(spaceIdN)) {
29+
return createApiResponse(
30+
request,
31+
asPostgrestFailure(`${space_id} is not a number`, "type"),
32+
);
33+
}
34+
const resourceIdN = Number.parseInt(resource_id || "NaN");
35+
if (isNaN(resourceIdN)) {
36+
return createApiResponse(
37+
request,
38+
asPostgrestFailure(`${resource_id} is not a number`, "type"),
39+
);
40+
}
41+
const supabase = await createClient();
42+
const spaceResponse = await supabase
43+
.from("Space")
44+
.select()
45+
.eq("id", spaceIdN)
46+
.maybeSingle();
47+
if (spaceResponse.error) {
48+
return createApiResponse(request, spaceResponse);
49+
}
50+
if (!spaceResponse.data) {
51+
// consideration: We may not see it because we don't have access,
52+
// so it would be worth re-fetching as superuser to see if I should redirect to login.
53+
return createApiResponse(
54+
request,
55+
asPostgrestFailure("Space not found", "401", 401),
56+
);
57+
}
58+
const space: Space = spaceResponse.data;
59+
const conceptResponse = await supabase
60+
.from("Concept")
61+
.select()
62+
.eq("id", resourceIdN)
63+
.maybeSingle();
64+
if (conceptResponse.error) {
65+
return createApiResponse(request, conceptResponse);
66+
}
67+
const concept = conceptResponse.data;
68+
if (!concept) {
69+
return createApiResponse(
70+
request,
71+
asPostgrestFailure("Resource not found", "401", 401),
72+
);
73+
}
74+
const contentResponse = await supabase
75+
.from("Content")
76+
.select()
77+
.eq("source_local_id", concept.source_local_id!);
78+
if (contentResponse.error) {
79+
return createApiResponse(request, conceptResponse);
80+
}
81+
const contents: Content[] = contentResponse.data;
82+
const fullContentsArray = contents.filter((c) => c.variant === "full");
83+
const fullContents = fullContentsArray.length
84+
? fullContentsArray[0]
85+
: undefined;
86+
const titleArray = contents.filter((c) => c.variant === "direct");
87+
const title = titleArray.length ? titleArray[0] : undefined;
88+
89+
const requestUrlParts = request.url.split("/");
90+
const baseUrl = requestUrlParts
91+
.slice(0, requestUrlParts.length - 1)
92+
.join("/");
93+
const rootUrl = baseUrl.split("/").slice(0, 3).join("/");
94+
const pageUrl = `${rootUrl}/api/content/${baseUrl.split("/")[5]}/${concept.id}#`;
95+
let schemas: Record<number, Concept> = {};
96+
let authors: Record<number, PlatformAccount> = {};
97+
let relations: Concept[] = [];
98+
let schemaIds = new Set<number>();
99+
if (withContext) {
100+
const relationsResult = (await supabase
101+
.from("Concept")
102+
.select("relations:concept_in_relations!inner(*)")
103+
.eq("id", concept.id)
104+
.maybeSingle()) as PostgrestMaybeSingleResponse<{ relations: Concept[] }>;
105+
if (relationsResult.data?.relations?.length) {
106+
relations = relationsResult.data.relations;
107+
if (withSchema)
108+
schemaIds = new Set(
109+
relations.map((c) => c.schema_id).filter((id) => id !== null),
110+
);
111+
}
112+
}
113+
if (concept.schema_id) schemaIds.add(concept.schema_id);
114+
if (schemaIds.size > 0) {
115+
const schemaResponse = await supabase
116+
.from("Concept")
117+
.select()
118+
.in("id", [...schemaIds]);
119+
if (schemaResponse.error) {
120+
return createApiResponse(request, schemaResponse);
121+
}
122+
if (!schemaResponse.data) {
123+
return createApiResponse(
124+
request,
125+
asPostgrestFailure("Resource schema not found", "401", 401),
126+
);
127+
}
128+
schemas = Object.fromEntries(schemaResponse.data.map((s) => [s.id, s]));
129+
}
130+
const authorIds = new Set<number>([
131+
...relations.map((r) => r.author_id).filter((id) => id !== null),
132+
...Object.values(schemas)
133+
.map((s) => s.author_id)
134+
.filter((id) => id !== null),
135+
]);
136+
if (concept.author_id) authorIds.add(concept.author_id);
137+
if (authorIds.size > 0) {
138+
const authorsResponse = await supabase
139+
.from("PlatformAccount")
140+
.select()
141+
.in("id", [...authorIds]);
142+
if (authorsResponse.error) {
143+
return createApiResponse(request, authorsResponse);
144+
}
145+
if (!authorsResponse.data) {
146+
return createApiResponse(
147+
request,
148+
asPostgrestFailure("Resource schema not found", "401", 401),
149+
);
150+
}
151+
authors = Object.fromEntries(authorsResponse.data.map((a) => [a.id, a]));
152+
}
153+
154+
const relationsJLD = withContext
155+
? await Promise.all(
156+
relations.map((c) =>
157+
asJsonLD({
158+
space,
159+
concept: c,
160+
baseUrl,
161+
schema: c.schema_id ? schemas[c.schema_id] : undefined,
162+
author: c.author_id ? authors[c.author_id] : undefined,
163+
}),
164+
),
165+
)
166+
: [];
167+
const schemasJLD = withSchema
168+
? await Promise.all(
169+
Object.values(schemas).map((c) =>
170+
asJsonLD({
171+
space,
172+
concept: c,
173+
baseUrl,
174+
author: c.author_id ? authors[c.author_id] : undefined,
175+
}),
176+
),
177+
)
178+
: [];
179+
180+
const baseJLDData = await asJsonLD({
181+
space,
182+
concept,
183+
baseUrl,
184+
title,
185+
schema: concept.schema_id ? schemas[concept.schema_id] : undefined,
186+
content: targetFormat === "none" ? undefined : fullContents,
187+
author: concept.author_id ? authors[concept.author_id] : undefined,
188+
targetFormat,
189+
});
190+
191+
const jsonLdData =
192+
relationsJLD.length > 0 || schemasJLD.length > 0
193+
? [baseJLDData, ...relationsJLD, ...schemasJLD]
194+
: baseJLDData;
195+
return NextResponse.json(wrapJsonLd(jsonLdData, baseUrl, pageUrl), {
196+
status: 200,
197+
headers: { "Content-Type": "application/ld+json" },
198+
});
199+
};
200+
201+
export const OPTIONS = defaultOptionsHandler;

0 commit comments

Comments
 (0)