diff --git a/app/package.json b/app/package.json index 4b6a209..f1aabf2 100644 --- a/app/package.json +++ b/app/package.json @@ -38,7 +38,6 @@ "@vitest/browser": "^4.0.16", "@vlcn.io/crsqlite-wasm": "^0.16.0", "@vlcn.io/xplat-api": "^0.15.0", - "arena-ts": "^1.0.2", "arktype": "^2.1.29", "convex": "^1.31.2", "convex-helpers": "^0.1.108", @@ -46,8 +45,11 @@ "happy-dom": "^20.0.11", "idb": "^8.0.3", "micromark": "^4.0.2", + "openapi-fetch": "^0.15.0", + "openapi-typescript": "^7.10.1", "prettier": "^3.7.4", "prettier-plugin-svelte": "^3.4.1", + "runed": "^0.37.1", "superstruct": "^2.0.2", "svelte": "5.46.1", "svelte-check": "^4.3.5", @@ -61,4 +63,4 @@ "dependencies": { "@tauri-apps/plugin-fs": "~2.4.4" } -} +} \ No newline at end of file diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 5f9b422..b15fda7 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: '@vitest/browser': specifier: ^2.1.9 version: 2.1.9(@types/node@22.7.2)(typescript@5.8.2)(vite@5.4.14(@types/node@22.7.2))(vitest@2.1.9) - arena-ts: - specifier: ^1.0.2 - version: 1.0.2 happy-dom: specifier: ^15.11.7 version: 15.11.7 @@ -623,9 +620,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - arena-ts@1.0.2: - resolution: {integrity: sha512-lEgHiqf38y7gxldyzakkzgiFfnAZTOequ1pLjq2l/8rQc5lzFhKdb9q0169sK3sHEj4fYxA7WZvvv0JxWlSAZQ==} - aria-query@5.1.3: resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} @@ -1932,8 +1926,6 @@ snapshots: ansi-styles@5.2.0: {} - arena-ts@1.0.2: {} - aria-query@5.1.3: dependencies: deep-equal: 2.2.3 diff --git a/app/src/lib/components/BlockTypeCard.svelte b/app/src/lib/components/BlockTypeCard.svelte index 5c97087..c195835 100644 --- a/app/src/lib/components/BlockTypeCard.svelte +++ b/app/src/lib/components/BlockTypeCard.svelte @@ -22,11 +22,7 @@ return link } const source = !c.source ? null : c.type !== 'channel' ? c.source : makeLink() - let content = $state("") - // c.type === 'text' ? micromark() : null - c.content?.then(c => { - content = micromark(c) - }) + const content = c.type === 'text' ? micromark(c.content ?? '') : null const imgLoad = (e: Event) => { const el = e.target as HTMLImageElement el.classList.add('loaded') diff --git a/app/src/lib/components/omnibar.svelte b/app/src/lib/components/omnibar.svelte index e9a0e39..79fde15 100644 --- a/app/src/lib/components/omnibar.svelte +++ b/app/src/lib/components/omnibar.svelte @@ -58,8 +58,8 @@ ~ / {#if page.params.channel} {@render route(page)} - {:else if 'channel' in ($tree.at(-1)?.params ?? {})} - {@render route($tree.at(-1))} + {:else if 'channel' in (tree.at(-1)?.params ?? {})} + {@render route(tree.at(-1))} {/if}
diff --git a/app/src/lib/components/views/GridView.svelte b/app/src/lib/components/views/GridView.svelte index 1ec3775..e4e84e7 100644 --- a/app/src/lib/components/views/GridView.svelte +++ b/app/src/lib/components/views/GridView.svelte @@ -12,7 +12,7 @@
- {#each content as c, i (c._id)} + {#each content as c, i (c.id)} {/each}
diff --git a/app/src/lib/components/views/MillerView.svelte b/app/src/lib/components/views/MillerView.svelte index 0fc5bea..b1f3b04 100644 --- a/app/src/lib/components/views/MillerView.svelte +++ b/app/src/lib/components/views/MillerView.svelte @@ -43,7 +43,7 @@
{#if previous && previous.length > 1} - {#each previous as p (p._id)} + {#each previous as p (p.key)} {@render entry(p)} {/each} {:else} diff --git a/app/src/lib/data/block.svelte.ts b/app/src/lib/data/block.svelte.ts index 0c1b43a..54a9688 100644 --- a/app/src/lib/data/block.svelte.ts +++ b/app/src/lib/data/block.svelte.ts @@ -1,20 +1,51 @@ // SPDX-License-Identifier: MPL-2.0 -import type { ArenaBlock } from "arena-ts" +import type { ArenaBlock } from "$lib/services/arena/types" import { entries, channels, users, populateUser } from "./maps.svelte" import type { Collectable } from "./types" +import { User } from "./user.svelte" + +type BlockI = { + id: string, + author_slug: User['key'], + title: string, + description: string, + media?: string, + content?: string, + type: Block['type'], + created_at: number, + updated_at: number, + filename?: string, + provider_url?: string, + image?: string, + source?: string, + attachment?: string, +} + +const ArenaTypes: Readonly< + Record +> = Object.freeze({ + Text: 'text', + Image: 'media', + Link: 'link', + Embed: 'link', + Attachment: 'attachment', +}) export class Block implements Collectable { key: string id: string title: string = $state('') description: string = $state('') - media?: string = $state('') - content?: string = $state('') + media?: string | undefined = $state('') + content?: string | undefined = $state('') + // Media and attachment may make sense to merge. One is Media represents file formats directly + // viewable in a browser, while attachements usually need an external renderer. + // building additional viwers should not require changing the data type type: 'text' | 'media' | 'link' | 'attachment' created_at: number - updated_at: number = $state() - filename: string + updated_at: number = $state(0) + filename?: string | undefined provider_url: string image: string source: string @@ -28,16 +59,13 @@ export class Block implements Collectable { } #connections = new Set() get connections() { - return [...this.#connections.values()].map(c => channels.get(c)) + return [...this.#connections.values()].map(c => channels.get(c)).filter(c => c !== undefined) } addConnection(slug: string) { this.#connections.add(slug) } - rmConnection(slug: string) { - return false - } - constructor(b: Block) { + constructor(b: BlockI) { this.type = b.type this.created_at = b.created_at this.updated_at = b.updated_at @@ -56,10 +84,12 @@ export class Block implements Collectable { this.#author = b.author_slug entries.set(this.key, this) - populateUser(this.key, this.#author) + const user = users.get(this.#author) + if (user) user.addEntry(this.key, 'blocks') } + write() { - return { + return JSON.stringify({ id: this.id, type: this.type, title: this.title, @@ -74,29 +104,27 @@ export class Block implements Collectable { author_slug: this.#author, attachment: this.attachment, connections: [...this.#connections.values()] - } + }) } - static fromArena(block: ArenaBlock): Block { - const data = { - id: block.id, - type: block.class.toLowerCase(), + static fromArena(block: ArenaBlock) { + const data: BlockI = { + id: block.id.toString(), + type: ArenaTypes[block.type], title: block.title ?? '', - description: block.description ?? '', + description: block.description?.markdown ?? '', created_at: new Date(block.created_at).valueOf(), updated_at: new Date(block.updated_at).valueOf(), - content: block.content && block.content, - filename: block.attachment && block.attachment.content_type, - provider_url: block.source && block.source.provider.url, - image: block.image && block.image.original.url, - source: null, + content: block.type === 'Text' ? block.content.markdown : '', + filename: block.type === 'Attachment' ? block.attachment?.url : '', + provider_url: block.source?.provider ? block.source.provider.url : '', + image: block.type !== 'Text' && block.image ? block.image.large.src : undefined, + source: block.source?.url || '', author_slug: block.user.slug, - attachment: block.attachment?.url + attachment: block.type === 'Attachment' ? block.attachment.url : undefined } - if (block.class === 'Text') - data.source = block.source ? block.source.url : block.source - else - data.source = block.source && block.source.url + new User(block.user.slug, block.user.name, block.user.avatar ?? '') + // db.exec(`insert or ignore into Providers values (?,?);`, [ // data.source.provider.url, // data.source.provider.name diff --git a/app/src/lib/data/channel.svelte.ts b/app/src/lib/data/channel.svelte.ts index 0aedc3a..26ae79b 100644 --- a/app/src/lib/data/channel.svelte.ts +++ b/app/src/lib/data/channel.svelte.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MPL-2.0 -import type { ArenaChannel, ArenaChannelContents, ArenaChannelWithDetails, ArenaUser } from "arena-ts" +import type { ArenaChannel, ArenaConnection, ArenaEntry } from "$lib/services/arena/types" import { entries, channels, users, populateUser } from "./maps.svelte" -import type { Child, Collectable, Entry, Collection, User, ArenaConnectionEventData } from "./types" +import type { Collectable, Entry, Collection, User } from "./types" + type ConnectionI = { key: string parent_id: string @@ -12,6 +13,7 @@ type ConnectionI = { connected_at: number connected_by: string } + export class Connection { key: string parent_id: string @@ -21,7 +23,6 @@ export class Connection { connected_at: number #connected_by: User['key'] - constructor(obj: ConnectionI) { this.parent_id = `${obj.parent_id}` this.child_id = `${obj.child_id}` @@ -40,20 +41,31 @@ export class Connection { console.error(`Child not found for ${this.key} `); return } - return Object.assign(child, this) as Child + return Object.assign(child, this) as Entry & Connection } - static fromArena(data: ArenaConnectionEventData): ConnectionI { + static fromArena(conn: ArenaConnection, child: ArenaEntry): ConnectionI { return { - key: `${data.parent.id}:${data.child.id}`, - parent_id: data.parent.slug, - child_id: data.is_channel === true ? data.child.slug : data.child.id.toString(), - position: data.position, - pinned: data.selected ? true : false, - connected_at: new Date(data.connected_at).valueOf(), - connected_by: data.child.connected_by_user_slug + key: `${conn.id}:${child.id}`, + parent_id: conn.id.toString(), + child_id: child.type === 'Channel' ? child.slug : child.id.toString(), + position: conn.position, + pinned: conn.pinned ? true : false, + connected_at: new Date(conn.connected_at).valueOf(), + connected_by: conn.connected_by?.slug ?? '' } } } +type ChannelI = { + slug: string, + id: string, + title: string, + description: string, + created_at: number, + updated_at: number, + status: string, + image?: string, + author: string, +} export class Channel implements Collection, Collectable { key: string slug: string @@ -63,20 +75,21 @@ export class Channel implements Collection, Collectable { description: string created_at: number updated_at: number - status: string + status: 'private' | 'public' | 'closed' image: string #author: string #keys = new Set() + #blocks: Connection[] = $state([]) + #connections = new Set() + get author() { return users.get(this.#author) } - #blocks: Connection[] = $state([]) get entries() { - return this.#blocks.map(conn => conn.get()).sort((a, b) => a.position - b.position) + return this.#blocks.map(conn => conn.get()).filter(e => e !== undefined).sort((a, b) => a.position - b.position) } - #connections = new Set() get connections() { - return [...this.#connections.values()].map(slug => channels.get(slug)) + return [...this.#connections.values()].map(slug => channels.get(slug)).filter(e => e !== undefined) } addConnection(slug: string) { this.#connections.add(slug) @@ -84,7 +97,8 @@ export class Channel implements Collection, Collectable { rmConnection(slug: string) { return false } - constructor(obj: Channel) { + constructor(obj: ChannelI) { + // const existing = channels.get(`${obj.id}`) this.id = `${obj.id}` this.title = obj.title this.slug = obj.slug @@ -93,7 +107,7 @@ export class Channel implements Collection, Collectable { this.description = obj.description this.created_at = obj.created_at this.updated_at = obj.updated_at - this.image = obj.image + this.image = obj.image ?? '' this.#author = obj.author channels.set(this.key, this) entries.set(this.key, this) @@ -111,22 +125,21 @@ export class Channel implements Collection, Collectable { get length() { return this.#blocks.length } - static fromArena(c: ArenaChannel | ArenaChannelWithDetails): Channel { - const flags = [c.kind] as ChannelParsed['flags'] - if (c.collaboration) flags.push('collaboration') - if (c.published) flags.push('published') + static fromArena(c: ArenaChannel): ChannelI { + // const flags = [c.kind] as ChannelParsed['flags'] + // if (c.collaboration) flags.push('collaboration') + // if (c.published) flags.push('published') return { id: c.slug, - type: 'channel', title: c.title, slug: c.slug, + description: c.description?.markdown ?? '', created_at: new Date(c.created_at).valueOf(), updated_at: new Date(c.updated_at).valueOf(), - flags, - status: c.status, - source: 'arena', - author: c.user?.slug ?? c.user_id, + status: c.visibility, + // source: 'arena', + author: c.owner.slug, } } } diff --git a/app/src/lib/data/types.ts b/app/src/lib/data/types.ts index b9d8332..f246889 100644 --- a/app/src/lib/data/types.ts +++ b/app/src/lib/data/types.ts @@ -3,30 +3,13 @@ import type { Channel, Connection } from "./channel.svelte"; import type { User } from './user.svelte' import type { Block } from './block.svelte' -import type { ArenaBlock, ArenaChannel, ArenaChannelContents, ArenaUser, ConnectionData } from "arena-ts"; export type Media = { key: string, value: string } export type Entry = ({ type: 'channel' } & Channel) | Block -export type Child = Connection & Entry -export type Parent = Collection -export type ArenaConnectionEventData = { - parent: ArenaChannel, - position: ConnectionData['position'], - selected: ConnectionData['selected'], - connected_at: ConnectionData['connected_at'], - connected_by: ConnectionData['connected_by_user_slug'] -} & ({ - is_channel: true - child: ArenaChannel & ConnectionData, -} | { - is_channel: false - child: ArenaBlock & ConnectionData, -} - ) export interface Base { key: string /** serialize data for storage */ @@ -35,6 +18,7 @@ export interface Base { // read: (value: string) => Base } +/** object with the ability to contain entries */ export interface Collection extends Base { addEntry: (key: Connection | Entry['key'], type?: 'channels' | 'blocks') => void, removeEntry: (key: Entry['key']) => boolean, @@ -43,7 +27,7 @@ export interface Collection extends Base { } export interface Collectable extends Base { - addConnection: (key: Parent['key']) => void, + addConnection: (key: Collection['key']) => void, connections: Channel[] } diff --git a/app/src/lib/data/user.svelte.ts b/app/src/lib/data/user.svelte.ts index 37d14a3..679c1ca 100644 --- a/app/src/lib/data/user.svelte.ts +++ b/app/src/lib/data/user.svelte.ts @@ -2,7 +2,7 @@ import type { Collection, Channel, Entry } from "./types" import { users, channels, entries } from "./maps.svelte" -import type { ArenaUser } from "arena-ts" +import type { ArenaUser } from "$lib/services/arena/types" export class User implements Collection { /** url safe representatnion of user's name */ @@ -28,6 +28,19 @@ export class User implements Collection { } return new User(key, name, avatar) } + static upsert( + key: string, + name: string, + avatar?: string, + ) { + const existing = users.get(key) + if (existing) { + existing.name = name + existing.avatar = avatar + return existing + } + return new User(key, name, avatar) + } addEntry(key: string, type: 'blocks' | 'channels') { if (this.#keys.has(key)) return @@ -41,10 +54,10 @@ export class User implements Collection { return this.#keys.delete(key) } get channels() { - return this.#channels.map(channels.get) + return this.#channels.map(e => channels.get(e)).filter(e => e !== undefined) } get entries() { - return this.#entries.map(entries.get) + return this.#entries.map(e => entries.get(e)).filter(e => e !== undefined) } static fromObject({ key, name, avatar }: { key: string, name: string, avatar: string }) { @@ -53,7 +66,7 @@ export class User implements Collection { static fromArena(user: ArenaUser) { return { key: user.slug, - name: user.username, + name: user.name, avatar: user.avatar, } } diff --git a/app/src/lib/database/connectionPool.svelte.ts b/app/src/lib/database/connectionPool.svelte.ts index b6c5700..febf216 100644 --- a/app/src/lib/database/connectionPool.svelte.ts +++ b/app/src/lib/database/connectionPool.svelte.ts @@ -28,8 +28,7 @@ export type QueryData = { } export class DbPool { - #maxConnections: number = 1 - #connections = new Set() + #connection: DB | null #sqlite: SQLite3 dbName: string status = $state<'available' | 'loading' | 'error'>('loading') @@ -50,13 +49,9 @@ export class DbPool { async #connect() { this.#sqlite = this.#sqlite || await this.#initSql() - if (this.#connections.size < this.#maxConnections) { - let connection: DB | undefined - try { - connection = await this.#sqlite.open(this.dbName) - } catch (err) { - console.error(err) - } + if (this.#connection) return this.#connection + try { + const connection = await this.#sqlite.open(this.dbName) connection.onUpdate((type, db, table, row) => { if (!this.#updateBuffer.has(`${table}:${type}`)) { this.#updateBuffer.set(`${table}:${type}`, new Set()) @@ -69,11 +64,13 @@ export class DbPool { // this.#subscribe(type, db, table, row) }) - - this.#connections.add(connection) - return connection + this.#connection = connection + return this.#connection + } catch (err) { + console.error(err) + return { error: err } } - return [...this.#connections.values()][0] + } #batchSubscribe() { this.#timeout = null @@ -84,32 +81,35 @@ export class DbPool { async exec(fn: (tx: TXAsync, db: DB) => R) { try { const db = await this.#connect() + if ('error' in db) { + console.error(db) + return db.error + } try { await db.tx(async (tx) => { await fn(tx, db) }) } catch (err) { console.error(`Error while running transaction: ${err}`) + console.trace(err) } - return () => this.#close(db) + return () => this.#close() } catch (err) { console.error(err) } } - async #close(connection: DB) { + async #close() { + if (!this.#connection) return try { - const res = connection && await connection.close() - this.#connections.delete(connection) + const res = await this.#connection.close() + this.#connection = null return res } catch (err) { if (!(err.message === 'Error: not a database')) console.warn(err) } } async closeAll() { - for (const connection of this.#connections) { - await connection.close() - } - this.#connections.clear() + this.#close() } async #initSql() { try { diff --git a/app/src/lib/database/createTables.ts b/app/src/lib/database/createTables.ts index bd491c4..2f24201 100644 --- a/app/src/lib/database/createTables.ts +++ b/app/src/lib/database/createTables.ts @@ -3,8 +3,8 @@ import type { DB } from "@vlcn.io/crsqlite-wasm" import type { TXAsync } from "@vlcn.io/xplat-api" +/** create sqlite tables for Users and event log */ export async function initStore(db: DB | TXAsync) { - console.log('init...') if ( localStorage.getItem('deviceId') === null || (await db.execA(`SELECT name FROM sqlite_master WHERE type='table' AND name='log';`)).length === 0 diff --git a/app/src/lib/database/events.ts b/app/src/lib/database/events.ts index d96185b..7225532 100644 --- a/app/src/lib/database/events.ts +++ b/app/src/lib/database/events.ts @@ -1,15 +1,23 @@ // SPDX-License-Identifier: MPL-2.0 import type { ULID } from "ulidx" -import type { ArenaBlock, ArenaChannel, ArenaChannelContents, ArenaChannelWithDetails, ArenaUser } from "arena-ts" +import type { ArenaChannel, ArenaEntry, ArenaConnection, ArenaUser } from "$lib/services/arena/types" import type { DB } from "@vlcn.io/crsqlite-wasm" import { Hlc, type HLC } from "./hlc" import type { StmtAsync, TXAsync } from "@vlcn.io/xplat-api" import type { Entry } from "$lib/data/types" +import { browser } from "$app/environment" const VERSION = 1 -let stmt: StmtAsync = null -const hlc = new Hlc(localStorage.getItem('deviceId')) +let stmt: StmtAsync | null = null +let _hlc: Hlc | null = null +const hlc = () => { + if (!browser) throw new Error('client side only script') + if (_hlc) return _hlc + _hlc = new Hlc(localStorage.getItem('deviceId')!) + return _hlc +} +/** record to event log */ export const record = async (db: TXAsync | DB, { originId, data, objectId, type }: Pick, 'objectId' | 'data' | 'type'> & { originId?: HLC } @@ -18,7 +26,7 @@ export const record = async (db: TXAsync | DB, insert into log (version, localId, originId, data, type, objectId) values(?,?,?,?,?,?) `) : stmt - const localId = hlc.inc() + const localId = hlc().inc() originId = originId ?? localId // if (type === 'add:user') // console.log({ originId, type, objectId, data }) @@ -29,7 +37,7 @@ export const record = async (db: TXAsync | DB, console.error(`Error recording log: ${err}`) } } -export const ev_stmt_close = async (tx?: TXAsync) => { +export const ev_stmt_close = async (tx: TXAsync | null = null) => { stmt && await stmt.finalize(tx) stmt = null console.log('finalized stmt', stmt) @@ -55,11 +63,11 @@ export type EventSchema = { objectId: string } -export const diffEntry = (db: DB | TXAsync, +export const diffEntry = (db: DB | TXAsync, { data, current, objectId, originId }: { data: D, - current?: Entry, + current: Entry, originId: () => HLC, objectId: HLC } @@ -71,41 +79,44 @@ export const diffEntry = (db: DB | TXAsync, title: data.title } })) - if (data.class === 'Channel' && current.type === 'channel') { - if (data.status && current.status !== data.status) + if (data.type === 'Channel' && current.type === 'channel') { + if (data.visibility && current.status !== data.visibility) promises.push(record(db, { objectId, type: 'mod:status', originId: originId(), data: { - status: data.status + status: data.visibility } })) - if (data.metadata?.description && current.description !== data.metadata.description) + if (data.description?.markdown && current.description !== data.description.markdown) promises.push(record(db, { objectId, type: 'mod:description', originId: originId(), data: { - description: data.metadata.description + description: data.description.markdown } })) - } else if (data.class !== 'Channel' && current.type === 'block') { - if (data.content && current.content !== data.content) + } else if (data.type !== 'Channel' && current.type !== 'channel') { + + if ('content' in data && current.content !== data.content?.markdown) promises.push(record(db, { objectId, type: 'mod:content', originId: originId(), data: { - content: data.content + content: data.content?.markdown } })) - if (data.description && current.description !== data.description) + if (data.description?.markdown && current.description !== data.description.markdown) promises.push(record(db, { objectId, type: 'mod:description', originId: originId(), data: { - description: data.description + description: data.description.markdown } })) } return Promise.all(promises) } -export const arena_entry_sync = async (db: DB | TXAsync, data: D, current?: Entry) => { - const classType = data.base_class.toLowerCase() - const objectId = `${classType}:${data.id}` + +/** @warning check if object has already been recorded to avoid bloating event log */ +export const arena_entry_sync = async (db: DB | TXAsync, data: D, current?: Entry) => { + const classType = data.type == 'Channel' ? 'channel' : 'block' + const objectId = `${classType}:${data.id}` as const const updated_at = new Date(data.updated_at).valueOf() let c = -1 - const originId = (): HLC => hlc.receive(`${updated_at}:${c++}:arena`) + const originId = (): HLC => hlc().receive(`${updated_at}:${c++}:arena`) if (!current) { return record(db, { @@ -121,34 +132,33 @@ export const arena_entry_sync = async (db: DB | } -/** - * CHECK IF CONNECTION EXISTS BEFORE CALLING THIS - */ +/** @warning check if object has already been recorded to avoid bloating event log */ export const arena_user_import = async (db: DB | TXAsync, user: Partial) => { const objectId = `user:${user.id}` - const originId: HLC = hlc.receive(`${Date.now()}:0:arena`) + const originId: HLC = hlc().receive(`${Date.now()}:0:arena`) return record(db, { objectId, type: `add:user`, originId, data: user }) } -/** CHECK IF CONNECTION EXISTS BEFORE CALLING THIS */ +/** @warning check if object has already been recorded to avoid bloating event log */ export const arena_connection_import = ( db: DB | TXAsync, - parent: ArenaChannelWithDetails, - child: ArenaChannelContents, + parent: ArenaChannel, + { connection: conn, ...child }: ArenaEntry, ) => { + if (!conn) throw Error('invalid input: child does contain connection') + const objectId = `connection:${JSON.stringify([parent.id, child.id])}` - const connected_at = new Date(child.connected_at).valueOf() - const originId: HLC = hlc.receive(`${connected_at}:0:arena`) + const connected_at = new Date(conn.connected_at).valueOf() + const originId: HLC = hlc().receive(`${connected_at}:0:arena`) - let { contents, ...parentData } = parent return record(db, { objectId, type: `connect`, originId, data: { - parent: parentData, + parent, child, - position: child.position, + position: conn.position, connected_at, - is_channel: child.class === 'Channel', - selected: child.selected, + is_channel: child.type === 'Channel', + selected: conn.pinned, } }) } diff --git a/app/src/lib/database/watchEvents.ts b/app/src/lib/database/watchEvents.ts index 03b8716..1baecf1 100644 --- a/app/src/lib/database/watchEvents.ts +++ b/app/src/lib/database/watchEvents.ts @@ -6,27 +6,29 @@ import type { DB, StmtAsync, TXAsync } from "@vlcn.io/xplat-api" import { Block } from '$lib/data/block.svelte' import { Channel, Connection } from '$lib/data/channel.svelte' import { User } from '$lib/data/user.svelte' -import type { ArenaBlock, ArenaChannel, ArenaChannelContents } from "arena-ts" +import type { ArenaBlock, ArenaChannel, ArenaEntry } from "$lib/services/arena/types" import { channels, entries, media, persistData } from "$lib/data/maps.svelte" import { pool } from "./connectionPool.svelte" +import { PersistedState } from 'runed' +import { browser } from "$app/environment" -class LastRow { - #val: bigint - constructor() { - this.#val = BigInt(localStorage.getItem('lastRow')) ?? 0n - localStorage.setItem('lastRow', lastRow.toString()) - } - get() { - return this.#val - } - set(val: bigint) { - localStorage.setItem('lastRow', val.toString()) - this.#val = BigInt(localStorage.getItem('lastRow')) ?? 0n +let lastRow = new PersistedState('lastRow', 0n, { + serializer: { + deserialize: (val) => BigInt(val), + serialize: (val) => val.toString(), } +}) + +let channel: BroadcastChannel +const getChannel = () => { + if (channel) return channel + channel = new BroadcastChannel('updates') + return channel } -let lastRow = new LastRow() +/** Load past events into data maps */ export async function bootstrap(db: TXAsync | DB) { + if (!browser) return const events = await db.execO('select rowid,* from log') console.debug(`loading ${events.length} events into memory`) parseEvent(events) @@ -34,36 +36,41 @@ export async function bootstrap(db: TXAsync | DB) { return } -const channel = new BroadcastChannel('updates') -export const watchEvents = () => channel.addEventListener('message', ev => { - if (ev.data) { - const ub: bigint[] = [...ev.data.values()] - pool.exec(async (tx, db) => { - await db.execO('select *,rowid from log where rowid between ? and ?', [ub[0], ub.at(-1)]) - .then((events) => { - parseEvent(events) - persistData().then(() => lastRow.set(ub.at(-1))) - }) - // .catch((err) => { console.error(err) }) - }) - } -}) +/** initialize broadcast channel watcher. auto-pulls new events into maps */ +export const watchEvents = () => { + if (!browser) return + getChannel().addEventListener('message', ev => { + if (ev.data) { + const ub: bigint[] = [...ev.data.values()] + pool.exec(async (tx, db) => { + await db.execO('select *,rowid from log where rowid between ? and ?', [ub[0]!, ub.at(-1)!]) + .then((events) => { + parseEvent(events) + console.error('FINISH THE PERSIST FUNCTION!!') + // persistData().then(() => lastRow.current = ub.at(-1)!) + }) + // .catch((err) => { console.error(err) }) + }) + } + }) +} -const pullUsers = (data: ArenaChannel | ArenaChannelContents) => { - if ('user' in data) { - User.create( - data.user.slug, - data.user.first_name + ' ' + data.user.last_name, - data.user.avatar_image.display, +const pullUsers = (data: ArenaEntry) => { + if (data.type === 'Channel') { + User.upsert( + data.owner.slug, + data.owner.name, + data.owner.avatar || undefined, ) - } - if ('connected_by_username' in data) { - User.create( - data.connected_by_user_slug, - data.connected_by_username, + } else if (data.base_type === 'Block') { + User.upsert( + data.user.slug, + data.user.name, + data.user.avatar || undefined ) } } + function parseEvent(events: object[]) { for (const e of events) { let { @@ -75,7 +82,7 @@ function parseEvent(events: object[]) { const from_arena = device === 'arena' // add external users to object graph if (from_arena) { - pullUsers(data) + pullUsers(data as ArenaEntry) } if (action === 'add') { @@ -120,6 +127,7 @@ function parseEvent(events: object[]) { } } +/* async function insertO(tx: TXAsync, row: O, table: string, stmts: Map): Promise<[string, StmtAsync]> { const keys = Object.keys(row) const sql = `INSERT INTO ${table}(${keys.join(',')}) VALUES (${Array(keys.length).fill('?').join(',')});` @@ -132,3 +140,4 @@ async function insertO(tx: TXAsync, row: O, table: string, stm console.error({ error, sql, row }) } } +*/ diff --git a/app/src/lib/services/arena/arenav2.ts b/app/src/lib/services/arena/arenav2.ts deleted file mode 100644 index 5521d6a..0000000 --- a/app/src/lib/services/arena/arenav2.ts +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -import { type GetBlockApiResponse, type GetChannelsApiResponse, type GetUserChannelsApiResponse } from 'arena-ts' -import { pullArena } from './sync' -import { pool } from '$lib/database/connectionPool.svelte' -interface FetchError extends Error { - status: number - statusText: string - url: string -} -export async function getChannels(id: number | string): Promise { - let res = await fetch(`https://api.are.na/v2/users/${id}/channels?per=50`, { - headers: { - 'Content-Type': 'application/json', - }, - method: 'GET', - }) - try { - if (!res.ok) { - const error: FetchError = new Error(`HTTP Error ${res.status}`) - error.status = res.status - error.statusText = res.statusText - error.url = res.url - throw error - } - const data = await res.json() - pool.exec(async (tx) => { - await pullArena(tx, ...data.channels) - }) - return data - - } catch (err) { - if (err instanceof TypeError) { - throw new Error('Type error: Fetch failed') - } - } -} - -export async function getBlocks(channel: string): Promise { - const res = await fetch( - `https://api.are.na/v2/channels/${channel}?per=100&sort=position&direction=asc`, - { - headers: { - 'Content-Type': 'application/json', - }, - method: 'GET', - } - ) - try { - if (!res.ok) { - const error: FetchError = new Error(`HTTP Error ${res.status}`) - error.status = res.status - error.statusText = res.statusText - error.url = res.url - throw error - } - const data = await res.json() - console.debug(data) - pool.exec(async (tx) => { - await pullArena(tx, data) - }) - return data - - } catch (err) { - if (err instanceof TypeError) { - throw new Error('Type error: Fetch failed') - } - } -} diff --git a/app/src/lib/services/arena/client.ts b/app/src/lib/services/arena/client.ts new file mode 100644 index 0000000..c8a3142 --- /dev/null +++ b/app/src/lib/services/arena/client.ts @@ -0,0 +1,4 @@ +import createClient from 'openapi-fetch' +import type { paths } from './schema' + +export const arenaClient = createClient({ baseUrl: 'https://api.are.na' }) diff --git a/app/src/lib/services/arena/queries.remote.ts b/app/src/lib/services/arena/queries.remote.ts new file mode 100644 index 0000000..2da77e1 --- /dev/null +++ b/app/src/lib/services/arena/queries.remote.ts @@ -0,0 +1,31 @@ +import { query } from "$app/server"; +import { type } from 'arktype' +import { arenaClient } from "./client"; + +export const userContents = query(type({ + id: 'string', + page: 'number' +}), async ({ id, page }) => arenaClient.GET('/v3/users/{id}/contents', { + params: { + query: { + per: 100, + page, + }, + path: { id } + } +}) +) + +export const channelContents = query(type({ + id: 'string', + page: 'number' +}), async ({ id, page }) => arenaClient.GET('/v3/channels/{id}/contents', { + params: { + query: { + per: 100, + page, + }, + path: { id } + } +}) +) diff --git a/app/src/lib/services/arena/schema.d.ts b/app/src/lib/services/arena/schema.d.ts new file mode 100644 index 0000000..36f1b91 --- /dev/null +++ b/app/src/lib/services/arena/schema.d.ts @@ -0,0 +1,2367 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/v3/oauth/token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Obtain access token + * @description Exchange credentials for an access token. This is the OAuth 2.0 token endpoint. + * + * **Supported Grant Types:** + * + * - `authorization_code`: Exchange an authorization code for an access token + * - `authorization_code` + PKCE: For public clients without a client secret + * - `client_credentials`: Authenticate as your application (server-to-server) + * + * **PKCE Support:** For public clients (mobile apps, SPAs), use PKCE by sending + * `code_challenge` and `code_challenge_method` in the authorization request, then + * include `code_verifier` when exchanging the code. See [RFC 7636](https://tools.ietf.org/html/rfc7636). + * + * Access tokens do not expire and can be used indefinitely. Register your application + * at [are.na/oauth/applications](https://www.are.na/oauth/applications) to obtain client credentials. + */ + post: operations["createOAuthToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/openapi": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get OpenAPI specification + * @description Returns the OpenAPI 3.0 specification for this API in YAML format. This endpoint provides the complete API contract for programmatic access and documentation generation. + */ + get: operations["getOpenapiSpec"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/openapi.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get OpenAPI specification (JSON) + * @description Returns the OpenAPI 3.0 specification for this API in JSON format. This endpoint provides the complete API contract in JSON for tools that prefer JSON over YAML. + */ + get: operations["getOpenapiSpecJson"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/ping": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Ping endpoint + * @description Public utility endpoint for API health checks and connection testing. + */ + get: operations["getPing"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/blocks/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a block + * @description Returns detailed information about a specific block by its ID. Respects visibility rules and user permissions. + */ + get: operations["getBlock"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/blocks/{id}/connections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get block connections + * @description Returns paginated list of channels where this block appears. + * This shows all channels that contain this block, respecting visibility rules and user permissions. + */ + get: operations["getBlockConnections"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/blocks/{id}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get block comments + * @description Returns paginated list of comments on this block. + * Comments are ordered by creation date (ascending by default, oldest first). + */ + get: operations["getBlockComments"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/channels/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a channel + * @description Returns detailed information about a specific channel by its ID or slug. Respects visibility rules and user permissions. + */ + get: operations["getChannel"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/channels/{id}/contents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get channel contents + * @description Returns paginated contents (blocks and channels) from a channel. + * Respects visibility rules and user permissions. + */ + get: operations["getChannelContents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/channels/{id}/connections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get channel connections + * @description Returns paginated list of channels where this channel appears. + * This shows all channels that contain this channel, respecting visibility rules and user permissions. + */ + get: operations["getChannelConnections"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/channels/{id}/followers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get channel followers + * @description Returns paginated list of users who follow this channel. + * All followers are users. + */ + get: operations["getChannelFollowers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get current user + * @description Returns the currently authenticated user's profile + */ + get: operations["getCurrentUser"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a user + * @description Returns detailed information about a specific user by their slug. Includes user profile, bio, and counts. + */ + get: operations["getUser"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/users/{id}/contents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user contents + * @description Returns paginated contents (blocks and channels) created by a user. + * Uses the search API to find all content added by the specified user. + * Respects visibility rules and user permissions. + */ + get: operations["getUserContents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/users/{id}/followers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user followers + * @description Returns paginated list of users who follow this user. + * All followers are users. + */ + get: operations["getUserFollowers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/users/{id}/following": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user following + * @description Returns paginated list of users, channels, and groups that this user follows. + * Can be filtered by type to return only specific followable types. + */ + get: operations["getUserFollowing"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/groups/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a group + * @description Returns detailed information about a specific group by its slug. Includes group profile, bio, owner, and counts. + */ + get: operations["getGroup"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/groups/{id}/contents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get group contents + * @description Returns paginated contents (blocks and channels) created by a group. + * Uses the search API to find all content added by the specified group. + * Respects visibility rules and user permissions. + */ + get: operations["getGroupContents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/groups/{id}/followers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get group followers + * @description Returns paginated list of users who follow this group. + * All followers are users. + */ + get: operations["getGroupFollowers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search across Are.na + * @description Search across blocks, channels, users, and groups. + * + * **⚠️ Premium Only**: This endpoint requires a Premium subscription. + * + * **Examples:** + * - Simple: `/v3/search?q=brutalism` + * - Images only: `/v3/search?q=architecture&type=Image` + * - My content: `/v3/search?q=*&scope=my` + * - In a channel: `/v3/search?q=design&scope=channel:12345` + * - PDFs sorted by date: `/v3/search?q=*&ext=pdf&sort=created_at_desc` + */ + get: operations["search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Error: { + /** + * @description Error message + * @example Not Found + */ + error: string; + /** + * @description HTTP status code + * @example 404 + */ + code: number; + /** @description Additional error details */ + details?: { + /** + * @description Detailed error message + * @example The resource you are looking for does not exist. + */ + message?: string; + }; + }; + /** @description Rate limit exceeded error response with upgrade information and suggestions */ + RateLimitError: { + error: { + /** + * @description Error type identifier + * @example rate_limit_exceeded + */ + type: string; + /** + * @description Human-readable error message + * @example Rate limit of 30 requests per minute exceeded for guest tier. Try again later. + */ + message: string; + /** + * @description User's current tier + * @example guest + * @enum {string} + */ + tier: "guest" | "free" | "premium" | "supporter"; + /** + * @description Request limit per minute for this tier + * @example 30 + */ + limit: number; + /** + * @description Time window for rate limits + * @example 1 minute + */ + limit_window?: string; + /** + * @description Suggested seconds to wait before retrying + * @example 65 + */ + retry_after: number; + current_status?: { + /** @example guest */ + tier?: string; + /** + * @example { + * "guest": 30, + * "free": 120, + * "premium": 300, + * "supporter": 600 + * } + */ + limits?: Record; + upgrade_path?: { + /** @example Guest (30 req/min) */ + current?: string; + /** @example Free Account (120 req/min) */ + recommended?: string; + /** + * @example [ + * "4x higher rate limit", + * "Persistent authentication", + * "API access" + * ] + */ + benefits?: string[]; + /** @example Sign up at https://are.na/signup */ + action?: string; + }; + }; + /** + * @description Tier-specific optimization suggestions + * @example [ + * "Sign up for a free account to get 120 requests per minute", + * "Implement exponential backoff with jitter", + * "Cache responses when possible to reduce API calls", + * "Consider batch requests if available" + * ] + */ + suggestions: string[]; + /** + * @description Information about header usage + * @example Check 'X-RateLimit-*' headers on successful requests for current usage + */ + headers_note?: string; + }; + }; + /** + * @description HATEOAS links for navigation and discovery. + * Follows HAL (Hypertext Application Language) format where link relationships + * are expressed as object keys (e.g., "self", "user", "channels"). + */ + Links: { + self: components["schemas"]["Link"] & unknown; + } & { + [key: string]: components["schemas"]["Link"]; + }; + /** + * @description A hypermedia link containing the URL of a linked resource. + * The relationship type is expressed by the key in the parent _links object. + */ + Link: { + /** + * Format: uri + * @description The URL of the linked resource + * @example https://api.are.na/v3/blocks/12345 + */ + href: string; + }; + /** @description Markdown content with multiple renderings */ + MarkdownContent: { + /** + * @description Original markdown value + * @example This is **only** a [test](https://example.com). + */ + markdown: string; + /** + * @description HTML rendering of the markdown + * @example

This is only a test.

+ */ + html: string; + /** + * @description Plain text rendering of the markdown + * @example This is only a test (https://example.com). + */ + plain: string; + }; + /** @description Embedded user representation (used when user is nested in other resources) */ + EmbeddedUser: { + /** + * @description Unique identifier for the user + * @example 12345 + */ + id: number; + /** + * @description User type (enum property replaced by openapi-typescript) + * @enum {string} + */ + type: "User"; + /** + * @description User's display name + * @example John Doe + */ + name: string; + /** + * @description URL-safe identifier (use this in API paths) + * @example john-doe + */ + slug: string; + /** + * Format: uri + * @description URL to user's avatar image + * @example https://d2w9rnfcy7mm78.cloudfront.net/12345/avatar.jpg + */ + avatar: string | null; + /** + * @description User's initials + * @example JD + */ + initials: string; + }; + /** + * @description Connection metadata that describes how an item is connected to a channel. + * This is only present when the item is returned as part of a channel's contents. + */ + ConnectionContext: { + /** + * @description Unique identifier for the connection + * @example 98765 + */ + id: number; + /** + * @description Position of the item within the channel (for manual ordering) + * @example 1 + */ + position: number; + /** + * @description Whether the item is pinned to the top of the channel + * @example false + */ + pinned: boolean; + /** + * Format: date-time + * @description When the item was connected to the channel + * @example 2023-01-15T10:30:00Z + */ + connected_at: string; + /** @description User who connected this item to the channel */ + connected_by: components["schemas"]["EmbeddedUser"] | null; + }; + /** @description Embedded group representation (used when group is nested in other resources) */ + EmbeddedGroup: { + /** + * @description Unique identifier for the group + * @example 67890 + */ + id: number; + /** + * @description Group type (enum property replaced by openapi-typescript) + * @enum {string} + */ + type: "Group"; + /** + * @description Group's name + * @example Design Team + */ + name: string; + /** + * @description Group's URL slug + * @example design-team-abc123 + */ + slug: string; + /** + * Format: uri + * @description URL to group's avatar image + * @example https://d2w9rnfcy7mm78.cloudfront.net/groups/67890/avatar.jpg + */ + avatar: string | null; + /** + * @description Group's initials + * @example DT + */ + initials: string; + }; + /** @description Full user representation */ + User: components["schemas"]["EmbeddedUser"] & { + /** + * Format: date-time + * @description When the user was created + * @example 2023-01-15T10:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the user was last updated + * @example 2023-06-20T14:45:00Z + */ + updated_at: string; + /** @description User biography with markdown, HTML, and plain text renderings */ + bio?: components["schemas"]["MarkdownContent"] | null; + counts: components["schemas"]["UserCounts"]; + _links: components["schemas"]["Links"] & unknown; + }; + /** @description Counts of various items for the user */ + UserCounts: { + /** + * @description Number of channels owned by the user + * @example 24 + */ + channels: number; + /** + * @description Number of followers + * @example 156 + */ + followers: number; + /** + * @description Number of users being followed + * @example 89 + */ + following: number; + }; + /** @description Full group representation */ + Group: components["schemas"]["EmbeddedGroup"] & { + /** @description Group biography with markdown, HTML, and plain text renderings */ + bio?: components["schemas"]["MarkdownContent"] | null; + /** + * Format: date-time + * @description When the group was created + * @example 2023-01-15T10:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the group was last updated + * @example 2023-06-20T14:45:00Z + */ + updated_at: string; + user: components["schemas"]["EmbeddedUser"] & unknown; + counts: components["schemas"]["GroupCounts"]; + _links: components["schemas"]["Links"] & unknown; + }; + /** @description Counts of various items for the group */ + GroupCounts: { + /** + * @description Number of channels owned by the group + * @example 12 + */ + channels: number; + /** + * @description Number of users in the group + * @example 5 + */ + users: number; + }; + /** + * @description Filter for content types (blocks and channels) + * @enum {string} + */ + ContentTypeFilter: "Text" | "Image" | "Link" | "Attachment" | "Embed" | "Channel" | "Block"; + /** + * @description Filter for searchable content types (includes all content types plus users and groups) + * @enum {string} + */ + SearchTypeFilter: "All" | "Text" | "Image" | "Link" | "Attachment" | "Embed" | "Channel" | "Block" | "User" | "Group"; + /** + * @description Supported file extensions for filtering + * @enum {string} + */ + FileExtension: "aac" | "ai" | "aiff" | "avi" | "avif" | "bmp" | "csv" | "doc" | "docx" | "eps" | "epub" | "fla" | "gif" | "h264" | "heic" | "heif" | "ind" | "indd" | "jpeg" | "jpg" | "key" | "kml" | "kmz" | "latex" | "m4a" | "ma" | "mb" | "mid" | "midi" | "mov" | "mp3" | "mp4" | "mp4v" | "mpeg" | "mpg" | "mpg4" | "numbers" | "oga" | "ogg" | "ogv" | "otf" | "pages" | "pdf" | "pgp" | "png" | "ppt" | "pptx" | "psd" | "svg" | "swa" | "swf" | "tex" | "texi" | "texinfo" | "tfm" | "tif" | "tiff" | "torrent" | "ttc" | "ttf" | "txt" | "wav" | "webm" | "webp" | "wma" | "xls" | "xlsx" | "xlt"; + /** + * @description Sort order for connection lists (channels containing a block/channel, followers) + * @enum {string} + */ + ConnectionSort: "created_at_desc" | "created_at_asc"; + /** + * @description Sort order for channel contents (includes position for manual ordering) + * @enum {string} + */ + ChannelContentSort: "position_asc" | "position_desc" | "created_at_asc" | "created_at_desc" | "updated_at_asc" | "updated_at_desc"; + /** + * @description Sort order for user/group content lists + * @enum {string} + */ + ContentSort: "created_at_asc" | "created_at_desc" | "updated_at_asc" | "updated_at_desc"; + /** + * @description A block is a piece of content on Are.na. Blocks come in different types, + * each with its own set of fields. Use the `type` field to determine which + * fields are available. + */ + Block: components["schemas"]["TextBlock"] | components["schemas"]["ImageBlock"] | components["schemas"]["LinkBlock"] | components["schemas"]["AttachmentBlock"] | components["schemas"]["EmbedBlock"]; + /** @description Common properties shared by all block types */ + BaseBlockProperties: { + /** + * @description Unique identifier for the block + * @example 12345 + */ + id: number; + /** + * @description Base type of the block (always "Block") + * @example Block + * @enum {string} + */ + base_type: "Block"; + /** + * @description Block title + * @example Interesting Article + */ + title?: string | null; + /** @description Block description with multiple renderings */ + description?: components["schemas"]["MarkdownContent"] | null; + /** + * @description Processing state of the block: + * - `available`: Block is ready and fully processed + * - `pending`: Block is queued for processing + * - `processing`: Block is currently being processed (e.g., image thumbnails, metadata extraction) + * - `failed`: Block processing failed (may still be viewable but incomplete) + * @example available + * @enum {string} + */ + state: "available" | "pending" | "failed" | "processing"; + /** + * @description Visibility level of the block: + * - `public`: Visible to everyone + * - `private`: Only visible to the owner + * - `orphan`: Block exists but is not connected to any channel + * @example public + * @enum {string} + */ + visibility: "public" | "private" | "orphan"; + /** + * @description Number of comments on the block + * @example 5 + */ + comment_count: number; + /** + * Format: date-time + * @description When the block was created + * @example 2023-01-15T10:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the block was last updated + * @example 2023-01-15T14:45:00Z + */ + updated_at: string; + user: components["schemas"]["EmbeddedUser"]; + /** @description Source URL and metadata (if block was created from a URL) */ + source?: components["schemas"]["BlockSource"] | null; + _links: components["schemas"]["Links"] & unknown; + /** + * @description Connection context (only present when block is returned as part of channel contents). + * Contains position, pinned status, and information about who connected the block. + */ + connection?: components["schemas"]["ConnectionContext"] | null; + }; + /** @description A text block containing markdown content */ + TextBlock: components["schemas"]["BaseBlockProperties"] & { + /** + * @description Block type (always "Text" for TextBlock) + * @enum {string} + */ + type: "Text"; + /** @description Text content with markdown, HTML, and plain text renderings */ + content: components["schemas"]["MarkdownContent"]; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "Text"; + }; + /** @description An image block containing an uploaded or scraped image */ + ImageBlock: components["schemas"]["BaseBlockProperties"] & { + /** + * @description Block type (always "Image" for ImageBlock) + * @enum {string} + */ + type: "Image"; + /** @description Image data with multiple resolutions */ + image: components["schemas"]["BlockImage"]; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "Image"; + }; + /** @description A link block representing a URL with optional preview */ + LinkBlock: components["schemas"]["BaseBlockProperties"] & { + /** + * @description Block type (always "Link" for LinkBlock) + * @enum {string} + */ + type: "Link"; + /** @description Preview image (if available) */ + image?: components["schemas"]["BlockImage"] | null; + /** @description Extracted text content from the link */ + content?: components["schemas"]["MarkdownContent"] | null; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "Link"; + }; + /** @description An attachment block containing an uploaded file */ + AttachmentBlock: components["schemas"]["BaseBlockProperties"] & { + /** + * @description Block type (always "Attachment" for AttachmentBlock) + * @enum {string} + */ + type: "Attachment"; + /** @description Attachment file data */ + attachment: components["schemas"]["BlockAttachment"]; + /** @description Preview image (for PDFs and other previewable files) */ + image?: components["schemas"]["BlockImage"] | null; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "Attachment"; + }; + /** @description An embed block containing embedded media (video, audio, etc.) */ + EmbedBlock: components["schemas"]["BaseBlockProperties"] & { + /** + * @description Block type (always "Embed" for EmbedBlock) + * @enum {string} + */ + type: "Embed"; + /** @description Embed data including HTML and dimensions */ + embed: components["schemas"]["BlockEmbed"]; + /** @description Thumbnail image (if available) */ + image?: components["schemas"]["BlockImage"] | null; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "Embed"; + }; + BlockSource: { + /** + * Format: uri + * @description Source URL + * @example https://example.com/article + */ + url: string; + /** + * @description Source title + * @example Original Article Title + */ + title?: string | null; + provider?: components["schemas"]["BlockProvider"] | null; + }; + BlockProvider: { + /** + * @description Provider name (from parsed URI host) + * @example Example.com + */ + name: string; + /** + * Format: uri + * @description Provider URL (from parsed URI scheme and host) + * @example https://example.com + */ + url: string; + }; + BlockImage: { + /** + * @description Alternative text associated with the image + * @example Scanned collage of magazine cutouts + */ + alt_text?: string | null; + /** + * @description BlurHash representation of the image for progressive loading + * @example LEHV6nWB2yk8pyo0adR*.7kCMdnj + */ + blurhash?: string | null; + /** + * @description Original image width in pixels + * @example 1920 + */ + width?: number | null; + /** + * @description Original image height in pixels + * @example 1080 + */ + height?: number | null; + /** + * Format: float + * @description Image aspect ratio (width / height) + * @example 1.7778 + */ + aspect_ratio?: number | null; + /** + * @description Image content type + * @example image/jpeg + */ + content_type?: string; + /** + * @description Image filename + * @example image.jpg + */ + filename?: string; + /** + * @description File size in bytes + * @example 1024000 + */ + file_size?: number | null; + /** + * Format: date-time + * @description When the image was last updated + * @example 2023-01-15T14:45:00Z + */ + updated_at?: string; + small: components["schemas"]["ImageVersion"] & unknown; + medium: components["schemas"]["ImageVersion"] & unknown; + large: components["schemas"]["ImageVersion"] & unknown; + square: components["schemas"]["ImageVersion"] & unknown; + }; + /** @description A resized/processed version of an image with multiple resolution URLs */ + ImageVersion: { + /** + * Format: uri + * @description Default image URL (1x resolution) + * @example https://d2w9rnfcy7mm78.cloudfront.net/12345/display_image.jpg + */ + src: string; + /** + * Format: uri + * @description 1x resolution image URL + * @example https://d2w9rnfcy7mm78.cloudfront.net/12345/display_image.jpg + */ + src_1x: string; + /** + * Format: uri + * @description 2x resolution image URL for high DPI displays + * @example https://d2w9rnfcy7mm78.cloudfront.net/12345/display_image@2x.jpg + */ + src_2x: string; + /** + * Format: uri + * @description 3x resolution image URL for very high DPI displays + * @example https://d2w9rnfcy7mm78.cloudfront.net/12345/display_image@3x.jpg + */ + src_3x: string; + /** + * @description Width of the resized image in pixels + * @example 640 + */ + width?: number | null; + /** + * @description Height of the resized image in pixels + * @example 480 + */ + height?: number | null; + }; + BlockEmbed: { + /** + * Format: uri + * @description Embed URL + * @example https://www.youtube.com/embed/abc123 + */ + url?: string | null; + /** + * @description Embed type + * @example youtube + */ + type?: string | null; + /** + * @description Embed title + * @example Video Title + */ + title?: string | null; + /** + * @description Author name + * @example Author Name + */ + author_name?: string | null; + /** + * Format: uri + * @description Author URL + * @example https://example.com/author + */ + author_url?: string | null; + /** + * Format: uri + * @description Embed source URL + * @example https://www.youtube.com/watch?v=abc123 + */ + source_url?: string | null; + /** + * @description Embed width + * @example 640 + */ + width?: number | null; + /** + * @description Embed height + * @example 480 + */ + height?: number | null; + /** + * @description Embed HTML + * @example + */ + html?: string | null; + /** + * Format: uri + * @description Thumbnail URL + * @example https://example.com/thumbnail.jpg + */ + thumbnail_url?: string | null; + }; + BlockAttachment: { + /** + * @description Attachment filename + * @example document.pdf + */ + filename?: string | null; + /** + * @description Attachment content type + * @example application/pdf + */ + content_type?: string | null; + /** + * @description File size in bytes + * @example 2048000 + */ + file_size?: number | null; + /** + * @description File extension + * @example pdf + */ + file_extension?: string | null; + /** + * Format: date-time + * @description When the attachment was last updated + */ + updated_at?: string | null; + /** + * Format: uri + * @description Attachment download URL + * @example https://attachments.are.na/12345/document.pdf + */ + url: string; + }; + /** @description A comment on a block */ + Comment: { + /** + * @description Unique identifier for the comment + * @example 12345 + */ + id: number; + /** + * @description Comment type + * @example Comment + * @enum {string} + */ + type: "Comment"; + /** @description Comment body with markdown, HTML, and plain text renderings */ + body?: components["schemas"]["MarkdownContent"] | null; + /** + * Format: date-time + * @description When the comment was created + * @example 2023-01-15T10:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the comment was last updated + * @example 2023-01-15T14:45:00Z + */ + updated_at: string; + user: components["schemas"]["EmbeddedUser"]; + _links: components["schemas"]["Links"] & unknown; + }; + Channel: { + /** + * @description Unique identifier for the channel + * @example 12345 + */ + id: number; + /** + * @description Channel type + * @example Channel + * @enum {string} + */ + type: "Channel"; + /** + * @description Channel URL slug + * @example my-collection-abc123 + */ + slug: string; + /** + * @description Channel title + * @example My Collection + */ + title: string; + /** @description Channel description with multiple renderings */ + description?: components["schemas"]["MarkdownContent"] | null; + /** + * @description Lifecycle state of the channel: + * - `available`: Channel is active and accessible + * - `deleted`: Channel has been soft-deleted + * @example available + * @enum {string} + */ + state: "available" | "deleted"; + /** + * @description Visibility level of the channel: + * - `public`: Anyone can view and connect to the channel + * - `private`: Only the owner and collaborators can view + * - `closed`: Anyone can view, but only collaborators can add content + * @example public + * @enum {string} + */ + visibility: "public" | "private" | "closed"; + /** + * Format: date-time + * @description When the channel was created + * @example 2023-01-15T10:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the channel was last updated + * @example 2023-01-15T14:45:00Z + */ + updated_at: string; + owner: components["schemas"]["ChannelOwner"]; + counts: components["schemas"]["ChannelCounts"]; + _links: components["schemas"]["Links"] & unknown; + /** + * @description Connection context (only present when channel is returned as part of another channel's contents). + * Contains position, pinned status, and information about who connected the channel. + */ + connection?: components["schemas"]["ConnectionContext"] | null; + }; + /** @description Channel owner (User or Group) */ + ChannelOwner: components["schemas"]["EmbeddedUser"] | components["schemas"]["EmbeddedGroup"]; + /** @description Counts of various items in the channel */ + ChannelCounts: { + /** + * @description Number of blocks in the channel + * @example 42 + */ + blocks: number; + /** + * @description Number of channels connected to this channel + * @example 8 + */ + channels: number; + /** + * @description Total number of contents (blocks + channels) + * @example 50 + */ + contents: number; + /** + * @description Number of collaborators on the channel + * @example 3 + */ + collaborators: number; + }; + /** @description Pagination metadata when total counts are available */ + PaginationMetaWithCount: { + /** + * @description Current page number + * @example 1 + */ + current_page: number; + /** + * @description Next page number (null if last page) + * @example 2 + */ + next_page?: number | null; + /** @description Previous page number (null if first page) */ + prev_page?: number | null; + /** + * @description Number of items per page + * @example 25 + */ + per_page: number; + /** + * @description Total number of pages available + * @example 5 + */ + total_pages: number; + /** + * @description Total number of items available + * @example 120 + */ + total_count: number; + /** + * @description Whether there are more pages available + * @example true + */ + has_more_pages: boolean; + }; + /** @description Pagination metadata when total counts are not available */ + PaginationMetaWithoutCount: { + /** + * @description Current page number + * @example 1 + */ + current_page: number; + /** + * @description Next page number (null if last page) + * @example 2 + */ + next_page?: number | null; + /** @description Previous page number (null if first page) */ + prev_page?: number | null; + /** + * @description Number of items per page + * @example 25 + */ + per_page: number; + /** + * @description Whether there are more pages available + * @example true + */ + has_more_pages: boolean; + }; + /** @description Pagination metadata union matching pagination behavior with and without total counts */ + PaginationMeta: components["schemas"]["PaginationMetaWithCount"] | components["schemas"]["PaginationMetaWithoutCount"]; + /** @description Base schema for all paginated responses (use allOf to extend with specific data type) */ + PaginatedResponseBase: { + meta: components["schemas"]["PaginationMeta"]; + }; + /** @description Base schema for paginated responses with total count (use allOf to extend with specific data type) */ + PaginatedResponseWithCountBase: { + meta: components["schemas"]["PaginationMetaWithCount"]; + }; + /** @description Data payload containing an array of users */ + UserList: { + /** @description Array of users */ + data: components["schemas"]["User"][]; + }; + /** @description Data payload containing an array of channels */ + ChannelList: { + /** @description Array of channels */ + data: components["schemas"]["Channel"][]; + }; + /** @description Data payload containing mixed content that can be connected to channels (blocks and channels) */ + ConnectableList: { + /** @description Array of blocks and channels */ + data: (components["schemas"]["TextBlock"] | components["schemas"]["ImageBlock"] | components["schemas"]["LinkBlock"] | components["schemas"]["AttachmentBlock"] | components["schemas"]["EmbedBlock"] | components["schemas"]["Channel"])[]; + }; + /** @description Data payload containing followable items (users, channels, and groups) */ + FollowableList: { + /** @description Array of users, channels, and/or groups */ + data: (components["schemas"]["User"] | components["schemas"]["Channel"] | components["schemas"]["Group"])[]; + }; + /** @description Data payload containing all content types */ + EverythingList: { + /** @description Array of results (blocks, channels, users, or groups) */ + data: (components["schemas"]["TextBlock"] | components["schemas"]["ImageBlock"] | components["schemas"]["LinkBlock"] | components["schemas"]["AttachmentBlock"] | components["schemas"]["EmbedBlock"] | components["schemas"]["Channel"] | components["schemas"]["User"] | components["schemas"]["Group"])[]; + }; + /** @description Paginated list of users with total count */ + UserListResponse: components["schemas"]["UserList"] & components["schemas"]["PaginatedResponseWithCountBase"]; + /** @description Paginated list of channels with total count */ + ChannelListResponse: components["schemas"]["ChannelList"] & components["schemas"]["PaginatedResponseWithCountBase"]; + /** @description Paginated list of connectable content (blocks and channels) */ + ConnectableListResponse: components["schemas"]["ConnectableList"] & components["schemas"]["PaginatedResponseBase"]; + /** @description Paginated list of followable items (users, channels, and groups) with total count */ + FollowableListResponse: components["schemas"]["FollowableList"] & components["schemas"]["PaginatedResponseWithCountBase"]; + /** @description Paginated list of all content types with total count */ + EverythingListResponse: components["schemas"]["EverythingList"] & components["schemas"]["PaginatedResponseWithCountBase"]; + /** @description Data payload containing an array of comments */ + CommentList: { + /** @description Array of comments */ + data: components["schemas"]["Comment"][]; + }; + /** @description Paginated list of comments with total count */ + CommentListResponse: components["schemas"]["CommentList"] & components["schemas"]["PaginatedResponseWithCountBase"]; + /** @description Health check response */ + PingResponse: { + /** + * @example ok + * @enum {string} + */ + status: "ok"; + }; + }; + responses: { + /** @description Unauthorized */ + UnauthorizedResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found */ + NotFoundResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Validation error */ + ValidationErrorResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden - insufficient permissions to access this resource */ + ForbiddenResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limit exceeded */ + RateLimitResponse: { + headers: { + /** + * @description Seconds to wait before retrying + * @example 65 + */ + "Retry-After"?: string; + /** + * @description Request limit per minute + * @example 30 + */ + "X-RateLimit-Limit"?: string; + /** + * @description User's current tier + * @example guest + */ + "X-RateLimit-Tier"?: string; + /** + * @description Time window in seconds + * @example 60 + */ + "X-RateLimit-Window"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RateLimitError"]; + }; + }; + }; + parameters: { + /** + * @description Page number for pagination + * @example 1 + */ + PageParam: number; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + PerParam: number; + /** @description Resource ID */ + IdParam: number; + /** @description Resource ID or slug */ + SlugOrIdParam: string; + /** + * @description Sort order (by created_at) + * @example created_at_desc + */ + ConnectionSortParam: components["schemas"]["ConnectionSort"]; + /** + * @description Sort order for content lists + * @example created_at_desc + */ + ContentSortParam: components["schemas"]["ContentSort"]; + /** + * @description Sort order for channel contents + * @example position_asc + */ + ChannelContentSortParam: components["schemas"]["ChannelContentSort"]; + /** + * @description Filter by content type + * @example Image + */ + ContentTypeFilterParam: components["schemas"]["ContentTypeFilter"]; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + createOAuthToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/x-www-form-urlencoded": { + /** + * @description The OAuth 2.0 grant type + * @enum {string} + */ + grant_type: "authorization_code" | "client_credentials"; + /** @description Your application's client ID (required for all grant types) */ + client_id?: string; + /** @description Your application's client secret (required for confidential clients, omit for PKCE) */ + client_secret?: string; + /** @description Authorization code (required for authorization_code grant) */ + code?: string; + /** + * Format: uri + * @description Redirect URI used in authorization request (required for authorization_code grant) + */ + redirect_uri?: string; + /** + * @description PKCE code verifier (required when authorization used code_challenge). + * Must be 43-128 characters from [A-Z], [a-z], [0-9], "-", ".", "_", "~". + */ + code_verifier?: string; + }; + }; + }; + responses: { + /** @description Access token granted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "access_token": "abc123def456...", + * "token_type": "Bearer", + * "scope": "write", + * "created_at": 1702900000 + * } + */ + "application/json": { + /** @description The access token to use for API requests */ + access_token: string; + /** + * @description Token type (always "Bearer") + * @enum {string} + */ + token_type: "Bearer"; + /** + * @description Granted scopes (space-separated) + * @example write + */ + scope: string; + /** @description Unix timestamp when the token was created */ + created_at: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + error?: "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type"; + error_description?: string; + }; + }; + }; + /** @description Invalid client credentials */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example invalid_client */ + error?: string; + /** @example Client authentication failed due to unknown client or invalid credentials. */ + error_description?: string; + }; + }; + }; + }; + }; + getOpenapiSpec: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OpenAPI specification in YAML format */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/yaml": string; + }; + }; + 404: components["responses"]["NotFoundResponse"]; + }; + }; + getOpenapiSpecJson: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OpenAPI specification in JSON format */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + 404: components["responses"]["NotFoundResponse"]; + }; + }; + getPing: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ping response */ + 200: { + headers: { + /** + * @description Request limit per minute for this user's tier + * @example 120 + */ + "X-RateLimit-Limit"?: string; + /** + * @description User's current tier + * @example free + */ + "X-RateLimit-Tier"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PingResponse"]; + }; + }; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getBlock: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource ID */ + id: components["parameters"]["IdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Block details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Block"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getBlockConnections: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order (by created_at) + * @example created_at_desc + */ + sort?: components["parameters"]["ConnectionSortParam"]; + /** + * @description Filter connections by ownership: + * - `ALL`: All accessible connections (default) + * - `OWN`: Only connections created by the current user + * - `EXCLUDE_OWN`: All connections except those created by the current user + * @example ALL + */ + filter?: "ALL" | "OWN" | "EXCLUDE_OWN"; + }; + header?: never; + path: { + /** @description Resource ID */ + id: components["parameters"]["IdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of channels where this block appears */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChannelListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getBlockComments: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order (by created_at) + * @example created_at_desc + */ + sort?: components["parameters"]["ConnectionSortParam"]; + }; + header?: never; + path: { + /** @description Resource ID */ + id: components["parameters"]["IdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of comments on this block */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommentListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getChannel: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Channel details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Channel"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getChannelContents: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order for channel contents + * @example position_asc + */ + sort?: components["parameters"]["ChannelContentSortParam"]; + /** + * @description Filter by user who added the content + * @example 12345 + */ + user_id?: number; + }; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Channel contents with pagination metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConnectableListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getChannelConnections: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order (by created_at) + * @example created_at_desc + */ + sort?: components["parameters"]["ConnectionSortParam"]; + }; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of channels where this channel appears */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChannelListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getChannelFollowers: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order (by created_at) + * @example created_at_desc + */ + sort?: components["parameters"]["ConnectionSortParam"]; + }; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of users who follow this channel */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getCurrentUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current user details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getUser: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getUserContents: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order for content lists + * @example created_at_desc + */ + sort?: components["parameters"]["ContentSortParam"]; + /** + * @description Filter by content type + * @example Image + */ + type?: components["parameters"]["ContentTypeFilterParam"]; + }; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User contents with pagination metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConnectableListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getUserFollowers: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order (by created_at) + * @example created_at_desc + */ + sort?: components["parameters"]["ConnectionSortParam"]; + }; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of users who follow this user */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getUserFollowing: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order (by created_at) + * @example created_at_desc + */ + sort?: components["parameters"]["ConnectionSortParam"]; + /** + * @description Filter by followable type + * @example Channel + */ + type?: "User" | "Channel" | "Group"; + }; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of users, channels, and groups that this user follows */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FollowableListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getGroup: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Group details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Group"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getGroupContents: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order for content lists + * @example created_at_desc + */ + sort?: components["parameters"]["ContentSortParam"]; + /** + * @description Filter by content type + * @example Image + */ + type?: components["parameters"]["ContentTypeFilterParam"]; + }; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Group contents with pagination metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConnectableListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + getGroupFollowers: { + parameters: { + query?: { + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + /** + * @description Sort order (by created_at) + * @example created_at_desc + */ + sort?: components["parameters"]["ConnectionSortParam"]; + }; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of users who follow this group */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListResponse"]; + }; + }; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; + search: { + parameters: { + query?: { + /** + * @description Search query. Use `*` to match everything (useful with filters). + * @example design + */ + q?: string; + /** + * @description Content types to search (comma-separated). + * Block subtypes: Text, Image, Link, Attachment, Embed. + * Other: Channel, User, Group, Block (all block types). + * @example [ + * "Image", + * "Link" + * ] + */ + type?: components["schemas"]["SearchTypeFilter"][]; + /** + * @description Where to search: + * - `all` - Everything (default) + * - `my` - Current user's content + * - `following` - Content from followed users/channels + * - `user:ID` - Specific user's content + * - `group:ID` - Specific group's content + * - `channel:ID` - Specific channel's content + * @example channel:12345 + */ + scope?: string; + /** + * @description Fields to search within (comma-separated). + * Options: name, description, content, domain, url. + * Defaults to all fields. + * @example [ + * "name", + * "description" + * ] + */ + in?: ("name" | "description" | "content" | "domain" | "url")[]; + /** + * @description Filter by file extensions (comma-separated) + * @example [ + * "pdf", + * "jpg" + * ] + */ + ext?: components["schemas"]["FileExtension"][]; + /** + * @description Sort order. Options: + * - `score_desc` (default) - Relevance + * - `created_at_desc`, `created_at_asc` + * - `updated_at_desc`, `updated_at_asc` + * - `name_asc`, `name_desc` + * - `connections_count_desc` + * - `random` (use with `seed` for reproducibility) + * @example created_at_desc + */ + sort?: "score_desc" | "created_at_desc" | "created_at_asc" | "updated_at_desc" | "updated_at_asc" | "name_asc" | "name_desc" | "connections_count_desc" | "random"; + /** + * @description Only return results updated after this date (ISO 8601) + * @example 2024-01-01T00:00:00Z + */ + after?: string; + /** + * @description Random seed for reproducible results (use with `sort=random`) + * @example 1234567890 + */ + seed?: number; + /** + * @description Page number for pagination + * @example 1 + */ + page?: components["parameters"]["PageParam"]; + /** + * @description Number of items per page (max 100) + * @example 24 + */ + per?: components["parameters"]["PerParam"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Search results with pagination metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EverythingListResponse"]; + }; + }; + 400: components["responses"]["ValidationErrorResponse"]; + 401: components["responses"]["UnauthorizedResponse"]; + /** @description Premium subscription required */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "Forbidden", + * "code": 403, + * "details": { + * "message": "This endpoint requires a Premium subscription" + * } + * } + */ + "application/json": components["schemas"]["Error"]; + }; + }; + 429: components["responses"]["RateLimitResponse"]; + }; + }; +} diff --git a/app/src/lib/services/arena/sync.ts b/app/src/lib/services/arena/sync.ts index 487a582..60945aa 100644 --- a/app/src/lib/services/arena/sync.ts +++ b/app/src/lib/services/arena/sync.ts @@ -1,49 +1,38 @@ // SPDX-License-Identifier: MPL-2.0 import type { DB } from '@vlcn.io/crsqlite-wasm' -import type { ArenaChannelWithDetails } from 'arena-ts' import { arena_entry_sync, arena_connection_import, ev_stmt_close } from '$lib/database/events' import type { TXAsync } from '@vlcn.io/xplat-api' +import type { ArenaChannel, ArenaEntry } from './types' import { entries, channels } from '$lib/data/maps.svelte' -export async function pullArena(db: DB | TXAsync, ...aChannels: ArenaChannelWithDetails[]) { - const dedupe = { - entries: new Map(entries), - conns: channels.entries().reduce((acc, [k, v]) => { - if (!acc.has(k)) acc.set(k, new Set()) - v.entries.forEach(b => { acc.get(k).add(b.key) }) - return acc - }, new Map>()) - } - if (dedupe.entries.size === 0) { console.warn(`recording events with 0 entries in dedupe`) } - - const promises: Promise[] = [] - const add = (p: Promise) => promises.push(p) - - for (const chan of aChannels) { - let currentChan = dedupe.entries.get(chan.slug) - if (!currentChan) dedupe.entries.set(chan.slug, chan) - add(arena_entry_sync(db, chan, currentChan)) - - if (!chan.contents) continue - for (const bl of chan.contents) { - const key = bl.base_class === 'Channel' ? bl.slug : bl.id.toString() - const currentBlock = dedupe.entries.get(key) - if (!currentBlock) dedupe.entries.set(key, bl) - add(arena_entry_sync(db, bl, currentBlock)) - - const conn = dedupe.conns.get(chan.slug) - if (conn) { - if (conn.has(key)) continue - add(arena_connection_import(db, chan, bl)) - conn.add(key) - } else { - add(arena_connection_import(db, chan, bl)) - dedupe.conns.set(key, new Set()) - } +export async function persistEntries(db: DB | TXAsync, channel: ArenaChannel, newEntries: ArenaEntry[]) { + console.debug(`recording events with ${entries.size} entries materialized`) + + const conns = channels.get(channel.slug)?.entries.map(e => e.id) + const promises: Promise[] = [] + + for (const entry of newEntries) { + const key = entry.type === 'Channel' ? entry.slug : `${entry.id}` + promises.push( + arena_entry_sync(db, entry, entries.get(key)) + ) + + if (entry.connection && !conns?.includes(key)) { + promises.push( + arena_connection_import(db, channel, entry) + ) } } - await Promise.all(promises).then(() => { - ev_stmt_close(db) - }) + + await Promise.all(promises).then(() => ev_stmt_close(db)) } + +export async function persistChannel(db: DB | TXAsync, newEntries: ArenaChannel[]) { + console.debug(`recording events with ${channels.size} channels materialized`) + + await Promise.all( + newEntries.map((entry) => arena_entry_sync(db, entry, channels.get(entry.slug))) + ).then(() => ev_stmt_close(db)) +} + diff --git a/app/src/lib/services/arena/types.ts b/app/src/lib/services/arena/types.ts new file mode 100644 index 0000000..7b10235 --- /dev/null +++ b/app/src/lib/services/arena/types.ts @@ -0,0 +1,13 @@ +import type { components } from './schema' + +export type ArenaChannel = components['schemas']['Channel'] +export type ArenaBlock = components['schemas']['Block'] + +/** content that can be connected to channels (blocks and channels) */ +export type ArenaEntry = components['schemas']['ConnectableList']['data'][number] +/** + * Connection context (only present when channel is returned as part of another channel's contents). + * Contains position, pinned status, and information about who connected the channel. + */ +export type ArenaConnection = components['schemas']['ConnectionContext'] +export type ArenaUser = components['schemas']['User'] diff --git a/app/src/lib/queries/listRecords.remote.ts b/app/src/lib/services/atpro/pullCosmik.ts similarity index 64% rename from app/src/lib/queries/listRecords.remote.ts rename to app/src/lib/services/atpro/pullCosmik.ts index f881c29..13922ef 100644 --- a/app/src/lib/queries/listRecords.remote.ts +++ b/app/src/lib/services/atpro/pullCosmik.ts @@ -1,16 +1,17 @@ -import { type } from 'arktype' -import { is, type ActorIdentifier } from "@atcute/lexicons"; +// SPDX-License-Identifier: MPL-2.0 + +import { is, type ActorIdentifier, type ResourceUri } from "@atcute/lexicons"; import { Client, simpleFetchHandler } from "@atcute/client"; import { NetworkCosmikCard, NetworkCosmikCollection, NetworkCosmikCollectionLink } from "$lib/services/atlex"; import { ComAtprotoRepoListRecords } from "@atcute/atproto"; -import { ConvexHttpClient } from "convex/browser"; -import { env } from "$env/dynamic/public"; -import { api } from "$lib/convex/_generated/api"; -import { command } from "$app/server"; import type { Id } from "$lib/convex/_generated/dataModel"; import type { ResolvedActor } from '@atcute/identity-resolver'; -type RecordParams = { repo: ActorIdentifier, limit?: number, reverse?: boolean, cursor?: string } +import { User } from '$lib/data/user.svelte' +import { Block } from '$lib/data/block.svelte' +import { Channel, Connection } from '$lib/data/channel.svelte'; +import { channels } from '$lib/data/maps.svelte'; +type RecordParams = { repo: ActorIdentifier, limit?: number, reverse?: boolean, cursor?: string } async function listRecords(xrpc: Client, params: { repo: ActorIdentifier, collection: `${string}.${string}.${string}`, limit?: number, reverse?: boolean, cursor?: string }) { const records = await xrpc.call(ComAtprotoRepoListRecords, { params, @@ -93,12 +94,23 @@ const STATUS = Object.freeze({ 'CLOSED': 'closed', 'OPEN': 'public' }) -const atpKey = (uri: string) => uri.split('/').at(-1)! +const CosmikTypes: Readonly< + Record<'NOTE' | 'LINK' | 'BLOB', Block['type']> +> = Object.freeze({ + NOTE: 'text', + BLOB: 'media', + LINK: 'link', +}) +const atpKey = (uri: ResourceUri) => { + let [did, collection, hash] = uri.slice(5).split('/') + if (!did || !collection || !hash) { + throw new Error(`invalid resourceHash ${uri}`) + } + return `${did}/${collection}/${hash}` +} const crawlCosmic = async (xrpc: Client, params: RecordParams) => { - const convex = new ConvexHttpClient(env.PUBLIC_CONVEX_URL) - console.log('start crawl') const promises: Promise<({ user: [string, Id<'users'>] @@ -106,7 +118,8 @@ const crawlCosmic = async (xrpc: Client, params: RecordParams) => { } | null)[]>[] = [] for await (const batch of listCards(xrpc, params)) { - const entries = batch.map(({ cid, uri, value: v }) => { + batch.map(({ cid, uri, value: v }) => { + if (v.type == 'NOTE') return const rest: { type: 'text' | 'media' | 'blob' | 'link' | 'channel' } & Record = { type: 'link' } @@ -118,91 +131,71 @@ const crawlCosmic = async (xrpc: Client, params: RecordParams) => { rest.title = v.content.metadata?.title ?? '' rest.url = v.content.url rest.source = JSON.stringify(v.provenance) + if (v.content.$type === 'network.cosmik.card#urlContent') { + v.content.metadata?.type + } break case 'BLOB': rest.type = 'blob' - rest.blob = v.content?.ref?.$link + // rest.blob = v.content?.ref?.$link + // TODO: resolve blob content.type to mime and split media (img, video, music, pdf ) from attachments break } - return { - user: { - displayName: params.repo, - id: params.repo - }, + + new Block({ + id: atpKey(uri), + author_slug: params.repo, title: '', description: '', ...rest, - backing_service: 'cosmik', - service_id: atpKey(uri), + type: CosmikTypes[v.type], + // attachment: v.type === 'BLOB' ? v.content.ref.$link : undefined, + // backing_service: 'cosmik', created_at: v.createdAt ? new Date(v.createdAt).valueOf() : Date.now(), updated_at: v.createdAt ? new Date(v.createdAt).valueOf() : Date.now(), - } + }) }) - promises.push(convex.mutation(api.add.addEntries, { service: "atproto", entries })) } console.log('pulling collections') for await (const batch of listCollections(xrpc, params)) { - const entries = batch.map(({ uri, value: v }) => ({ - user: { - displayName: params.repo, - id: params.repo - }, + const entries = batch.map(({ uri, value: v }) => new Channel({ + id: atpKey(uri), + slug: atpKey(uri), title: v.name ?? '', + status: STATUS[v.accessType] ?? 'closed', description: v.description ?? '', - type: 'channel' as const, - backing_service: 'cosmik', - service_id: atpKey(uri), + // backing_service: 'cosmik', created_at: v.createdAt ? new Date(v.createdAt).valueOf() : Date.now(), updated_at: v.updatedAt ? new Date(v.updatedAt).valueOf() : Date.now(), - status: STATUS[v.accessType] ?? 'closed', + author: params.repo, })) - promises.push(convex.mutation(api.add.addEntries, { service: "atproto", entries })) } console.log('linking maps') - const res = (await Promise.all(promises)).flat() - const entries: Map> = new Map() - const users: Map> = new Map() - res.filter(q => q !== null).forEach(({ entry: [ek, ev], user: [uk, uv] }) => { - users.set(uk, uv) - entries.set(ek as string, ev) - }) - - const promises2: Promise[] = [] for await (const batch of listConnections(xrpc, params)) { console.log(`connecting batch ${promises.length}`) - const connections = (await Promise.all(batch.map(async ({ value: v }) => { - const ckey = atpKey(v.card.uri) - const pkey = atpKey(v.collection.uri) - const cid = entries.get(ckey) - const pid = entries.get(pkey) - if (!cid) console.warn('missing id for', { cid, ckey }) - if (!pid) console.warn('missing id for', { pid, pkey }) - return { - cid: cid ?? ckey, - pid: pid ?? pkey, + batch.forEach(({ value: v }) => { + const parent_id = atpKey(v.collection.uri) + channels.get(parent_id)?.addEntry(new Connection({ + parent_id, + child_id: atpKey(v.card.uri), connected_at: new Date(v.addedAt).valueOf(), - connected_by: users.get(params.repo) ?? { id: params.repo, displayName: params.repo }, - } - }))) - promises2.push(convex.mutation(api.add.connectEntries, { service: 'atproto', connections })) + connected_by: v.addedBy, + pinned: false, + position: Infinity, + })) + }) } - await Promise.all(promises2) console.log('COMPLETE') } -export const spiderUser = command(type({ - did: "string", - pds: "string", -}), async ({ did, pds }: Exclude) => { +export const pullCosmik = async ({ did, pds, handle }: ResolvedActor) => { const xrpc = new Client({ handler: simpleFetchHandler({ service: pds }) }) - try { - await crawlCosmic(xrpc, { repo: did, limit: 100, reverse: false }) - } catch (err) { - console.error(err) - } -}) + User.upsert(did, handle, '') + crawlCosmic(xrpc, { repo: did, limit: 100, reverse: false }) + .catch(console.error) +} diff --git a/app/src/routes/(app)/+layout.svelte b/app/src/routes/(app)/+layout.svelte index b86579f..8309584 100644 --- a/app/src/routes/(app)/+layout.svelte +++ b/app/src/routes/(app)/+layout.svelte @@ -2,21 +2,22 @@
-{@render children()} +{#if ready} + {@render children()} +{/if} diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte index 3da2bf1..97244ce 100644 --- a/app/src/routes/+layout.svelte +++ b/app/src/routes/+layout.svelte @@ -6,8 +6,9 @@ import { setupConvex } from 'convex-svelte' let { children } = $props() - setupConvex(env.PUBLIC_CONVEX_URL) + // setupConvex(env.PUBLIC_CONVEX_URL) + {@render children()}