import type { CharacterEntity, Contract, ListResponse, MintedNoteEntity, NoteEntity, NoteMetadata, } from "crossbell" import type { Address } from "viem" import { GeneralAccount } from "@crossbell/connect-kit" import { indexer } from "@crossbell/indexer" import { extractCharacterAttribute } from "@crossbell/util-metadata" import { gql } from "@urql/core" import { expandCrossbellNote } from "~/lib/expand-unit" import { filterCommentCharacter } from "~/lib/filter-character" import { getNoteSlug } from "~/lib/helpers" import { checkSlugReservedWords } from "~/lib/slug-reserved-words" import { getKeys, getStorage } from "~/lib/storage" import { ExpandedNote, PageVisibilityEnum } from "~/lib/types" import { client } from "~/queries/graphql" export const PINNED_PAGE_KEY = "xlog_pinned_page" export async function checkPageSlug(input: { slug: string characterId?: number noteId?: 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 && page.noteId !== input.noteId) { return `Slug "${input.slug}" has already been used` } } catch (error) {} } } export async function postNotes( input: { siteId: string characterId: number notes: NoteMetadata[] }, contract?: Contract, ) { return contract?.note.postMany({ notes: input.notes.map((note) => ({ characterId: input.characterId, metadataOrUri: note, })), }) } const getLocalPages = (input: { characterId: number isPost?: boolean handle?: string }) => { const pages: ExpandedNote[] = [] getKeys([`draft-${input.characterId}-`, `draft-${input.handle}-`]).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}-`, "") .replace(`draft-${input.handle}-${input.characterId}-`, ""), // In order to be compatible with old drafts 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: "" as Address, // TODO: check usage and replace it with viem's `zeroAddress`. owner: "" as Address, // TODO: check usage and replace it with viem's `zeroAddress`. createdAt: new Date(page.date).toISOString(), publishedAt: new Date(page.date).toISOString(), updatedAt: new Date(page.date).toISOString(), deletedAt: null, transactionHash: "" as Address, // TODO: check usage and replace it with viem's `zeroAddress`. blockNumber: 0, logIndex: 0, updatedTransactionHash: "" as Address, // TODO: check usage and replace it with viem's `zeroAddress`. 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) || []), ], slug: page.values?.slug, sources: ["xlog"], disableAISummary: page.values?.disableAISummary, }, }, 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 useHTML?: boolean keepBody?: boolean handle?: string // In order to be compatible with old drafts skipExpansion?: boolean }) { if (!input.characterId) { return { count: 0, list: [], cursor: null, } } const visibility = input.visibility || PageVisibilityEnum.All const expandedNotes = await (async (): Promise<{ list: ExpandedNote[] pinnedNoteId?: number count: number cursor: string | null }> => { const [pinnedNote, notes] = await Promise.all([ !input.cursor ? getPinnedPage(input) : null, await indexer.note.getMany({ characterId: input.characterId, limit: input.limit || 12, cursor: input.cursor, orderBy: "publishedAt", tags: [...(input.tags || []), input.type], sources: "xlog", }), ]) const list: NoteEntity[] = pinnedNote ? [ pinnedNote, ...notes.list.filter((note) => note.noteId !== pinnedNote.noteId), ] : notes.list return { ...notes, pinnedNoteId: pinnedNote?.noteId, list: await Promise.all( list.map(async (note) => { let expanded = note as ExpandedNote if (input.skipExpansion) { expanded.metadata.content.slug = getNoteSlug(expanded) } else { expanded = await expandCrossbellNote({ note, useStat: input.useStat, useHTML: input.useHTML, useScore: false, }) } if (!input.keepBody) { delete expanded.metadata?.content?.content } return expanded }), ), } })() const local = getLocalPages({ characterId: input.characterId, isPost: input.type === "post", handle: input.handle, }) local.forEach((localPage) => { const index = expandedNotes.list.findIndex( (page) => localPage.draftKey === (page.noteId || page.draftKey) + "", ) if (index !== -1) { if ( new Date(localPage.updatedAt) > new Date(expandedNotes.list[index].updatedAt) ) { expandedNotes.list[index] = { ...expandedNotes.list[index], metadata: { content: localPage.metadata?.content, }, local: true, draftKey: localPage.draftKey, } } } 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.sort((a, b) => { if ( !a.metadata?.content?.date_published || a.noteId === expandedNotes.pinnedNoteId ) { return -1 } else if ( !b.metadata?.content?.date_published || b.noteId === expandedNotes.pinnedNoteId ) { return 1 } else { return ( +new Date(b.metadata?.content?.date_published) - +new Date(a.metadata?.content?.date_published) ) } }) return expandedNotes } export async function getSearchPagesBySite(input: { characterId?: number keyword?: string cursor?: string }) { const result = await indexer.search.notes(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({ note: page, useStat: true, useScore: false, keyword: input.keyword, }) }), ) return { list, cursor: result.cursor, count: result.count, } } export async function getPage(input: { slug?: string characterId: number useStat?: boolean noteId?: number handle?: string // In order to be compatible with old drafts }) { const mustLocal = input.slug?.startsWith("!local-") && !input.noteId let page: NoteEntity | null = null if (!mustLocal) { if (!input.noteId) { const result = await client .query( gql` query getNotes($characterId: Int!, $slug: JSON!) { notes( where: { characterId: { equals: $characterId, }, deleted: { equals: false, }, metadata: { content: { path: ["sources"], array_contains: ["xlog"] }, OR: [ { content: { path: ["attributes"], array_contains: [{ trait_type: "xlog_slug", value: $slug, }] } }, { content: { path: ["title"], equals: $slug }, } ] }, }, orderBy: [{ createdAt: desc }], take: 1, ) { characterId noteId uri metadata { uri content } owner createdAt updatedAt publishedAt transactionHash blockNumber updatedTransactionHash updatedBlockNumber ${ input.useStat ? ` stat { viewDetailCount } ` : "" } } } `, { characterId: input.characterId, slug: decodeURIComponent(input.slug!), }, ) .toPromise() page = result.data.notes[0] } else { const result = await client .query( gql` query getNote($characterId: Int!, $noteId: Int!) { note( where: { note_characterId_noteId_unique: { characterId: $characterId, noteId: $noteId, }, }, ) { characterId noteId uri metadata { uri content } owner createdAt updatedAt publishedAt transactionHash blockNumber updatedTransactionHash updatedBlockNumber ${ input.useStat ? ` stat { viewDetailCount } ` : "" } } } `, { characterId: input.characterId, noteId: input.noteId, }, ) .toPromise() page = result.data.note } } // local page const local = getLocalPages({ characterId: input.characterId, handle: input.handle, }) const localPages = local.filter( (page) => page.draftKey === input.noteId + "" || page.draftKey === input.slug, ) const localPage = localPages.length && localPages.reduce((prev, current) => { return prev.updatedAt > current.updatedAt ? prev : current }) let expandedNote: ExpandedNote | undefined if (page) { expandedNote = await expandCrossbellNote({ note: page, useStat: 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 } } return expandedNote } export async function reportStats(input: { characterId: number noteId: number }) { return await indexer.note.get(input.characterId, input.noteId) } export async function getLikes({ characterId, noteId, cursor, includeCharacter, }: { characterId: number noteId: number cursor?: string includeCharacter?: boolean }) { const res = await indexer.link.getBacklinksByNote(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) { return indexer.link.getMany(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?.note.mint({ characterId, noteId, toAddress: address as Address, }) } export async function getMints({ characterId, noteId, cursor, includeCharacter, }: { characterId: number noteId: number cursor?: string includeCharacter?: boolean }) { const data = await indexer.mintedNote.getManyOfNote(characterId, noteId, { cursor, limit: 5, }) if (includeCharacter) { await Promise.all( data.list.map(async (item: any) => { if (!item.character) { item.character = await indexer.character.getPrimary(item.owner) } }), ) } return data as ListResponse< MintedNoteEntity & { character: CharacterEntity } > } export async function checkMint({ address, noteCharacterId, noteId, }: { address: string noteCharacterId: number noteId: number }) { return indexer.mintedNote.getManyOfAddress(address as Address, { noteCharacterId: noteCharacterId, noteId: noteId, }) } export async function checkComment({ characterId, noteCharacterId, noteId, }: { characterId: number noteCharacterId: number noteId: number }) { return indexer.note.getMany({ characterId: characterId, toCharacterId: noteCharacterId, toNoteId: noteId, limit: 0, }) } 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.note.getMany(options)) || { total: 0, list: [], } res.list = res.list.filter(filterCommentCharacter) return res } export async function anonymousComment(input: { targetCharacterId: number targetNoteId: number content: string name: string email: string }) { return await fetch("/api/anonymous/comment", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ targetCharacterId: input.targetCharacterId, targetNoteId: input.targetNoteId, content: input.content, name: input.name, email: input.email, }), }) } export async function updateComment( { content, characterId, noteId, }: { content: string characterId: number noteId: number }, contract: Contract, ) { return contract.note.setMetadata({ characterId, noteId, metadata: { content, tags: ["comment"], sources: ["xlog"], }, }) } export async function checkMirror(characterId: number) { const notes = await indexer.note.getMany({ characterId, sources: "xlog", tags: ["post", "Mirror.xyz"], limit: 0, }) return notes.count === 0 } export const getDistinctNoteTagsOfCharacter = async (characterId: number) => { const result = await indexer.note.getDistinctTagsOfCharacter(characterId, { sources: "xlog", }) result.list = result.list.filter( (tag) => ["post", "comment", "page"].findIndex((t) => t === tag) === -1, ) return result } export async function getPinnedPage({ characterId }: { characterId?: number }) { if (!characterId) return null const character = await indexer.character.get(characterId) const noteId = extractCharacterAttribute(character, PINNED_PAGE_KEY)?.value if (!character || !noteId || typeof noteId !== "number") return null return indexer.note.get(character.characterId, noteId) }