xLog/src/models/page.model.ts

602 lines
13 KiB
TypeScript

import { NoteMetadata } from "crossbell.js"
import type {
CharacterEntity,
Contract,
ListResponse,
MintedNoteEntity,
NoteEntity,
} from "crossbell.js"
import type Unidata from "unidata.js"
import { GeneralAccount } from "@crossbell/connect-kit"
import { indexer } from "@crossbell/indexer"
import { expandCrossbellNote } from "~/lib/expand-unit"
import { notFound } from "~/lib/server-side-props"
import { checkSlugReservedWords } from "~/lib/slug-reserved-words"
import { getKeys, getStorage } from "~/lib/storage"
import { ExpandedNote, PageVisibilityEnum } from "~/lib/types"
export async function checkPageSlug(input: {
slug: string
characterId?: number
}) {
if (!input.characterId) {
return "Character not found"
}
const reserved = checkSlugReservedWords(input.slug)
if (reserved) {
return reserved
} else {
try {
const page = await getPage({
slug: input.slug,
characterId: input.characterId,
})
if (page) {
return `Slug "${input.slug}" has already been used`
}
} catch (error) {}
}
}
export async function createOrUpdatePage(
input: {
pageId?: string
siteId: string
slug?: string
tags?: string
title?: string
content?: string
published?: boolean
publishedAt?: string
excerpt?: string
/** Only needed when creating page */
isPost?: boolean
externalUrl?: string
applications?: string[]
},
customUnidata?: Unidata,
newbieToken?: string,
) {
const { default: unidata } = await import("~/queries/unidata.server")
if (!input.published) {
return await (customUnidata || unidata).notes.set(
{
source: "Crossbell Note",
identity: input.siteId,
platform: "Crossbell",
action: "remove",
},
{
id: input.pageId,
},
{
newbieToken,
},
)
}
return await (customUnidata || unidata).notes.set(
{
source: "Crossbell Note",
identity: input.siteId,
platform: "Crossbell",
action: input.pageId ? "update" : "add",
},
{
...(input.externalUrl && { related_urls: [input.externalUrl] }),
...(input.pageId && { id: input.pageId }),
...(input.title && { title: input.title }),
...(input.content && {
body: {
content: input.content,
mime_type: "text/markdown",
},
}),
...(input.publishedAt && {
date_published: input.publishedAt,
}),
...(input.excerpt && {
summary: {
content: input.excerpt,
mime_type: "text/markdown",
},
}),
tags: [
input.isPost ? "post" : "page",
...(input.tags
?.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag) || []),
],
applications: [
"xlog",
...(input.applications?.filter((app) => app !== "xlog") || []),
],
...(input.slug && {
attributes: [
{
trait_type: "xlog_slug",
value: input.slug,
},
],
}),
},
{
newbieToken,
},
)
}
export async function postNotes(
input: {
siteId: string
characterId: number
notes: NoteMetadata[]
},
contract?: Contract,
) {
return contract?.postNotes(
input.notes.map((note) => ({
characterId: input.characterId,
metadataOrUri: note,
})),
)
}
const getLocalPages = (input: { characterId: number; isPost?: boolean }) => {
const pages: ExpandedNote[] = []
getKeys(`draft-${input.characterId}-`).forEach((key) => {
const page = getStorage(key)
if (input.isPost === undefined || page.isPost === input.isPost) {
const note: ExpandedNote = {
characterId: input.characterId,
noteId: 0,
draftKey: key.replace(`draft-${input.characterId}-`, ""),
linkItemType: null,
linkKey: "",
toCharacterId: null,
toAddress: null,
toNoteId: null,
toHeadCharacterId: null,
toHeadNoteId: null,
toContractAddress: null,
toTokenId: null,
toLinklistId: null,
toUri: null,
deleted: false,
locked: false,
contractAddress: null,
uri: null,
operator: "",
owner: "",
createdAt: new Date(page.date).toISOString(),
updatedAt: new Date(page.date).toISOString(),
deletedAt: null,
transactionHash: "",
blockNumber: 0,
logIndex: 0,
updatedTransactionHash: "",
updatedBlockNumber: 0,
updatedLogIndex: 0,
metadata: {
content: {
title: page.values?.title,
content: page.values?.content,
date_published: page.values?.publishedAt,
summary: page.values?.excerpt,
tags: [
page.isPost ? "post" : "page",
...(page.values?.tags
?.split(",")
.map((tag: string) => tag.trim())
.filter((tag: string) => tag) || []),
],
sources: ["xlog"],
},
},
local: true,
}
pages.push(note)
}
})
return pages
}
export async function getPagesBySite(input: {
characterId?: number
type: "post" | "page"
visibility?: PageVisibilityEnum
limit?: number
cursor?: string
tags?: string[]
useStat?: boolean
keepBody?: boolean
}) {
if (!input.characterId) {
return {
count: 0,
list: [],
cursor: null,
}
}
const visibility = input.visibility || PageVisibilityEnum.All
const notes = await indexer.getNotes({
characterId: input.characterId,
limit: input.limit || 10,
cursor: input.cursor,
orderBy: "publishedAt",
tags: [...(input.tags || []), input.type],
sources: "xlog",
})
const list = await Promise.all(
notes?.list.map(async (note) => {
const expanded = await expandCrossbellNote(note, input.useStat)
if (!input.keepBody) {
delete expanded.metadata?.content?.content
}
return expanded
}),
)
const expandedNotes: {
list: ExpandedNote[]
count: number
cursor: string | null
} = Object.assign(notes, {
list,
})
const local = getLocalPages({
characterId: input.characterId,
isPost: input.type === "post",
})
local.forEach((localPage) => {
const index = expandedNotes.list.findIndex(
(page) => localPage.draftKey === page.noteId + "",
)
if (
index !== -1 &&
new Date(localPage.updatedAt) >
new Date(expandedNotes.list[index].updatedAt)
) {
expandedNotes.list[index] = {
...expandedNotes.list[index],
metadata: {
content: localPage.metadata?.content,
},
local: true,
}
} else {
expandedNotes.list.push(localPage)
expandedNotes.count++
}
})
switch (visibility) {
case PageVisibilityEnum.Published:
expandedNotes.list = expandedNotes.list.filter(
(page) =>
(!page.metadata?.content?.date_published ||
+new Date(page.metadata?.content?.date_published) <= +new Date()) &&
page.noteId,
)
break
case PageVisibilityEnum.Draft:
expandedNotes.list = expandedNotes.list.filter((page) => !page.noteId)
break
case PageVisibilityEnum.Scheduled:
expandedNotes.list = expandedNotes.list.filter(
(page) =>
page.metadata?.content?.date_published &&
+new Date(page.metadata?.content?.date_published) > +new Date(),
)
break
}
expandedNotes.list = expandedNotes.list.sort((a, b) =>
a.metadata?.content?.date_published && b.metadata?.content?.date_published
? +new Date(b.metadata?.content?.date_published) -
+new Date(a.metadata?.content?.date_published)
: 0,
)
return expandedNotes
}
export async function getSearchPagesBySite(input: {
characterId?: number
keyword?: string
cursor?: string
}) {
const result = await indexer.searchNotes(input.keyword || "", {
cursor: input.cursor,
characterId: input.characterId,
tags: ["post"],
sources: ["xlog"],
orderBy: "publishedAt",
})
const list: ExpandedNote[] = await Promise.all(
result.list.map(async (page: any) => {
return await expandCrossbellNote(page, true, false, input.keyword)
}),
)
return {
list,
cursor: result.cursor,
count: result.count,
}
}
export async function getPage<TRender extends boolean = false>(input: {
slug?: string
characterId: number
useStat?: boolean
noteId?: number
}) {
const mustLocal = input.slug?.startsWith("local-")
let page: NoteEntity | null = null
if (!mustLocal) {
if (!input.noteId) {
const response = await fetch(
`/api/slug2id?${new URLSearchParams({
characterId: input.characterId + "",
slug: input.slug!,
}).toString()}`,
)
input.noteId = (await response.json())?.noteId
}
if (input.noteId) {
page = await indexer.getNote(input.characterId, input.noteId)
}
}
// local page
const local = getLocalPages({
characterId: input.characterId,
})
const localPage = local.find(
(page) =>
page.draftKey === input.noteId + "" || page.draftKey === input.slug,
)
let expandedNote: ExpandedNote | undefined
if (page) {
expandedNote = await expandCrossbellNote(page, input.useStat)
}
if (localPage) {
if (expandedNote) {
if (new Date(localPage.updatedAt) > new Date(expandedNote.updatedAt)) {
expandedNote = {
...expandedNote,
metadata: {
content: localPage.metadata?.content,
},
local: true,
}
}
} else {
expandedNote = localPage
}
}
if (!expandedNote && !mustLocal) {
throw notFound(`page ${input.slug} not found`)
}
return expandedNote
}
export async function deletePage(
{ site, id }: { site: string; id: string },
customUnidata?: Unidata,
newbieToken?: string,
) {
const { default: unidata } = await import("~/queries/unidata.server")
return await (customUnidata || unidata).notes.set(
{
source: "Crossbell Note",
identity: site,
platform: "Crossbell",
action: "remove",
},
{
id,
},
{
newbieToken,
},
)
}
export async function getLikes({
characterId,
noteId,
cursor,
includeCharacter,
}: {
characterId: number
noteId: number
cursor?: string
includeCharacter?: boolean
}) {
const res = await indexer.getBacklinksOfNote(characterId, noteId, {
linkType: "like",
cursor,
})
if (includeCharacter) {
res.list?.forEach((item) => {
;(item as any).character = item.fromCharacter
})
}
return res
}
export async function checkLike({
account,
characterId,
noteId,
}: {
account: GeneralAccount
characterId: number
noteId: number
}) {
if (!account.characterId) {
throw notFound(`character not found`)
} else {
return indexer.getLinks(account.characterId, {
linkType: "like",
toCharacterId: characterId,
toNoteId: noteId,
})
}
}
export async function mintPage(
{
address,
characterId,
noteId,
}: {
address: string
characterId: number
noteId: number
},
contract?: Contract,
) {
return contract?.mintNote(characterId, noteId, address)
}
export async function getMints({
characterId,
noteId,
cursor,
includeCharacter,
}: {
characterId: number
noteId: number
cursor?: string
includeCharacter?: boolean
}) {
const data = await indexer.getMintedNotesOfNote(characterId, noteId, {
cursor,
limit: 5,
})
if (includeCharacter) {
await Promise.all(
data.list.map(async (item: any) => {
if (!item.character) {
item.character = await indexer.getPrimaryCharacter(item.owner)
}
}),
)
}
return data as ListResponse<
MintedNoteEntity & {
character: CharacterEntity
}
>
}
export async function checkMint({
address,
noteCharacterId,
noteId,
}: {
address: string
noteCharacterId: number
noteId: number
}) {
return indexer.getMintedNotesOfAddress(address, {
noteCharacterId: noteCharacterId,
noteId: noteId,
})
}
export async function getComments({
characterId,
noteId,
cursor,
}: {
characterId: number
noteId: number
cursor?: string
}) {
const options = {
toCharacterId: characterId,
toNoteId: noteId,
cursor,
includeCharacter: true,
includeNestedNotes: true,
nestedNotesDepth: 3 as 3,
nestedNotesLimit: 20,
limit: 5,
}
const res = (await indexer.getNotes(options)) || {
total: 0,
list: [],
}
return res
}
export async function updateComment(
{
content,
externalUrl,
characterId,
noteId,
}: {
content: string
externalUrl: string
characterId: number
noteId: number
},
contract: Contract,
) {
return contract.setNoteMetadata(characterId, noteId, {
content,
external_urls: [externalUrl],
tags: ["comment"],
sources: ["xlog"],
})
}
export async function getSummary({
cid,
lang,
}: {
cid: string
lang?: string
}) {
return (
await (await fetch(`/api/summary?cid=${cid}&lang=${lang || "en"}`)).json()
).data
}
export async function checkMirror(characterId: number) {
const notes = await indexer.getNotes({
characterId,
sources: "xlog",
tags: ["post", "Mirror.xyz"],
limit: 0,
})
return notes.count === 0
}