Various AT Protocol integrations with obsidian
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Client cache (#4)

* refactor client to cache responses

* wrap client

authored by

treethought and committed by
GitHub
628582dc ecb56f1b

+156 -94
+4 -5
src/commands/publishDocument.ts
··· 14 14 return; 15 15 } 16 16 17 - await plugin.initClient(); 18 - if (!plugin.client) { 19 - new Notice("Not logged in. Check your credentials in settings."); 17 + if (!plugin.client.loggedIn) { 18 + new Notice("Must login to publish document."); 20 19 return; 21 20 } 22 21 ··· 33 32 await updateFrontMatter(plugin, file, newUri, record, documentUrl); 34 33 return; 35 34 } 36 - const pub = await getPublication(record.site as ResourceUri); 35 + const pub = await getPublication(plugin.client, record.site as ResourceUri); 37 36 const documentUrl = buildDocumentUrl(pub.value.url, newUri, record); 38 37 39 38 await updateFrontMatter(plugin, file, newUri, record, documentUrl); ··· 127 126 pubUri = sel.uri; 128 127 pub = sel.publication; 129 128 } else { 130 - const pubData = await getPublication(pubUri); 129 + const pubData = await getPublication(plugin.client, pubUri); 131 130 pub = pubData.value; 132 131 } 133 132
+1 -1
src/components/createCollectionModal.ts
··· 80 80 81 81 try { 82 82 await createCollection( 83 - this.plugin.client!, 83 + this.plugin.client, 84 84 this.plugin.settings.identifier, 85 85 name, 86 86 descInput.value.trim()
+1 -1
src/components/createMarginCollectionModal.ts
··· 89 89 90 90 try { 91 91 await createMarginCollection( 92 - this.plugin.client!, 92 + this.plugin.client, 93 93 this.plugin.settings.identifier, 94 94 name, 95 95 descInput.value.trim() || undefined,
+1 -1
src/components/createTagModal.ts
··· 72 72 73 73 try { 74 74 await createTag( 75 - this.plugin.client!, 75 + this.plugin.client, 76 76 this.plugin.settings.identifier, 77 77 value 78 78 );
+1 -1
src/components/selectPublicationModal.ts
··· 34 34 const loading = contentEl.createEl("p", { text: "Loading publications..." }); 35 35 36 36 try { 37 - const response = await getPublications(this.plugin.settings.identifier); 37 + const response = await getPublications(this.plugin.client, this.plugin.settings.identifier); 38 38 loading.remove(); 39 39 40 40 let pubs = response.records
-3
src/lib.ts
··· 24 24 } from "./lib/bookmarks/margin"; 25 25 26 26 export { 27 - getDocuments, 28 27 createDocument, 29 28 putDocument, 30 29 getPublication, ··· 36 35 export { markdownToLeafletContent } from "./lib/standardsite/leaflet"; 37 36 export { markdownToPcktContent } from "./lib/standardsite/pckt"; 38 37 export { stripMarkdown } from "./lib/markdown"; 39 - 40 - export { getAuthClient, getPublicClient } from "./lib/client"; 41 38 42 39 export type ATRecord<T> = Record & { value: T };
+120 -14
src/lib/client.ts
··· 1 - import { Client, CredentialManager, simpleFetchHandler } from "@atcute/client"; 2 - import { type ActorIdentifier } from "@atcute/lexicons"; 1 + import { Client, CredentialManager, FetchHandlerObject, simpleFetchHandler } from "@atcute/client"; 3 2 import { resolveActor } from "./identity"; 4 3 import { isActorIdentifier } from "@atcute/lexicons/syntax"; 4 + import { ResolvedActor } from "@atcute/identity-resolver"; 5 5 6 6 const DEFAULT_SERVICE = "https://bsky.social"; 7 7 ··· 10 10 password: string; 11 11 } 12 12 13 - export async function getAuthClient(creds: Credentials): Promise<Client> { 14 - let actor = await resolveActor(creds.identifier as ActorIdentifier); 15 - const service = actor.pds || DEFAULT_SERVICE; 16 - const manager = new CredentialManager({ service }); 17 - await manager.login(creds); 18 - return new Client({ handler: manager }); 13 + export class ATClient extends Client { 14 + hh: Handler; 15 + 16 + constructor(creds?: Credentials) { 17 + const handler = new Handler(creds); 18 + super({ handler }); 19 + this.hh = handler; 20 + } 21 + 22 + get loggedIn(): boolean { 23 + return !!this.hh.cm.session?.did; 24 + } 25 + get session() { 26 + return this.hh.cm.session; 27 + } 28 + 19 29 } 20 30 21 - export async function getPublicClient(identifier: string): Promise<Client> { 22 - if (isActorIdentifier(identifier)) { 23 - let actor = await resolveActor(identifier); 24 - const service = actor.pds || DEFAULT_SERVICE; 25 - return new Client({ handler: simpleFetchHandler({ service }) }); 31 + export class Handler implements FetchHandlerObject { 32 + creds?: Credentials; 33 + cm: CredentialManager; 34 + cache: Cache 35 + 36 + constructor(creds?: Credentials) { 37 + this.creds = creds; 38 + this.cache = new Cache(5 * 60 * 1000); // 5 minutes TTL 39 + this.cm = new CredentialManager({ service: DEFAULT_SERVICE }); 40 + } 41 + 42 + async getActor(identifier: string): Promise<ResolvedActor> { 43 + const key = `actor:${identifier}`; 44 + const cached = this.cache.get<ResolvedActor>(key); 45 + if (cached) { 46 + return cached; 47 + } 48 + if (isActorIdentifier(identifier)) { 49 + const res = await resolveActor(identifier); 50 + this.cache.set(key, res); 51 + return res; 52 + } else { 53 + throw new Error("Invalid actor identifier: " + JSON.stringify(identifier)); 54 + } 26 55 } 27 - throw new Error("Invalid actor identifier: " + JSON.stringify(identifier)); 56 + 57 + async getPDS(pathname: string): Promise<string | null> { 58 + const url = new URL(pathname, "https://placeholder") 59 + const repo = url.searchParams.get("repo"); 60 + if (!repo) { 61 + return null 62 + } 63 + const own = (repo === this.cm.session?.handle || repo === this.cm.session?.did); 64 + if (!own) { 65 + const actor = await this.getActor(repo); 66 + return actor.pds 67 + } 68 + return null 69 + } 70 + 71 + async handle(pathname: string, init: RequestInit): Promise<Response> { 72 + if (this.creds && !this.cm.session?.did) { 73 + await this.cm.login(this.creds); 74 + if (this.cm.session?.did) { 75 + void this.getActor(this.cm.session?.did) 76 + } 77 + } 78 + 79 + const cacheKey = `${init?.method || "GET"}:${pathname}`; 80 + if (init?.method?.toLowerCase() === "get") { 81 + const cached = this.cache.get<Response>(cacheKey); 82 + if (cached) { 83 + return cached.clone(); 84 + } 85 + } 86 + 87 + let resp: Response; 88 + const pds = await this.getPDS(pathname); 89 + if (pds) { 90 + const sfh = simpleFetchHandler({ service: pds }); 91 + resp = await sfh(pathname, init); 92 + } else { 93 + resp = await this.cm.handle(pathname, init); 94 + } 95 + 96 + if (init?.method?.toLowerCase() === "get" && resp.ok) { 97 + this.cache.set(cacheKey, resp.clone()); 98 + } 99 + return resp; 100 + } 28 101 } 29 102 103 + class CacheEntry<T> { 104 + value: T; 105 + timestamp: number; 106 + constructor(value: T) { 107 + this.value = value; 108 + this.timestamp = Date.now(); 109 + } 110 + } 111 + 112 + class Cache { 113 + #store = new Map<string, CacheEntry<unknown>>(); 114 + #ttl: number; 115 + 116 + constructor(ttlMillis: number) { 117 + this.#ttl = ttlMillis; 118 + } 119 + 120 + get<T>(key: string): T | undefined { 121 + const entry = this.#store.get(key); 122 + if (entry) { 123 + if (Date.now() - entry.timestamp < this.#ttl) { 124 + return entry.value as T; 125 + } else { 126 + this.#store.delete(key); 127 + } 128 + } 129 + return undefined; 130 + } 131 + 132 + set<T>(key: string, value: T): void { 133 + this.#store.set(key, new CacheEntry(value)); 134 + } 135 + }
+16 -14
src/lib/standardsite/index.ts
··· 6 6 import { Main as Publication } from "@atcute/standard-site/types/publication"; 7 7 import { Main as Subscription } from "@atcute/standard-site/types/graph/subscription"; 8 8 9 - import { ATRecord, getPublicClient } from "lib"; 9 + import { ATRecord } from "lib"; 10 10 import { SiteStandardDocument, SiteStandardGraphSubscription, SiteStandardPublication } from "@atcute/standard-site"; 11 11 12 - export async function getDocuments(repo: string) { 13 - const client = await getPublicClient(repo); 12 + export async function getPublicationDocuments(client: Client, repo: string, pubUri: ResourceUri) { 14 13 const response = await ok(client.call(ComAtprotoRepoListRecords, { 15 14 params: { 16 15 repo: repo as ActorIdentifier, ··· 19 18 }, 20 19 })); 21 20 21 + // filter records by publication uri 22 + const pubDocs = response.records.filter(record => { 23 + const parsed = parse(SiteStandardDocument.mainSchema, record.value); 24 + return parsed.site === pubUri; 25 + }); 26 + 22 27 return { 23 28 ...response, 24 - records: response.records.map(record => ({ 29 + records: pubDocs.map(record => ({ 25 30 ...record, 26 31 value: parse(SiteStandardDocument.mainSchema, record.value), 27 32 })) as ATRecord<Document>[], 28 33 }; 29 - } 34 + }; 30 35 31 36 export async function createDocument( 32 37 client: Client, ··· 81 86 }); 82 87 } 83 88 84 - export async function getPublications(repo: string) { 85 - const client = await getPublicClient(repo); 89 + export async function getPublications(client: Client, repo: string) { 86 90 const response = await ok(client.call(ComAtprotoRepoListRecords, { 87 91 params: { 88 92 repo: repo as ActorIdentifier, ··· 102 106 } 103 107 104 108 105 - export async function getPublication(uri: ResourceUri): Promise<ATRecord<Publication>> { 109 + export async function getPublication(client: Client, uri: ResourceUri): Promise<ATRecord<Publication>> { 106 110 const parsed = parseResourceUri(uri); 107 111 if (!parsed.ok) { 108 112 throw new Error(`Invalid URI: ${uri}`); 109 113 } 110 - const client = await getPublicClient(parsed.value.repo); 111 114 const resp = await ok(client.call(ComAtprotoRepoGetRecord, { 112 115 params: { 113 116 repo: parsed.value.repo, ··· 159 162 }); 160 163 } 161 164 162 - export async function getSubscriptions(repo: string) { 163 - const client = await getPublicClient(repo as ActorIdentifier); 165 + export async function getSubscriptions(client: Client, repo: string) { 164 166 const response = await ok(client.call(ComAtprotoRepoListRecords, { 165 167 params: { 166 168 repo: repo as ActorIdentifier, ··· 179 181 } 180 182 } 181 183 182 - export async function getSubscribedPublications(repo: string): Promise<ATRecord<Publication>[]> { 183 - const subsResp = await getSubscriptions(repo); 184 + export async function getSubscribedPublications(client: Client, repo: string): Promise<ATRecord<Publication>[]> { 185 + const subsResp = await getSubscriptions(client, repo); 184 186 const pubUris = subsResp.records.map(sub => sub.value.publication); 185 187 186 188 let pubs: ATRecord<Publication>[] = []; 187 189 for (const uri of pubUris) { 188 190 try { 189 - const pubResp = await getPublication(uri); 191 + const pubResp = await getPublication(client, uri); 190 192 pubs.push(pubResp); 191 193 } catch (e) { 192 194 console.warn(`Failed to fetch publication at ${uri}:`, e);
+9 -32
src/main.ts
··· 1 - import { Notice, Plugin, WorkspaceLeaf } from "obsidian"; 2 - import type { Client } from "@atcute/client"; 1 + import { Plugin, WorkspaceLeaf } from "obsidian"; 3 2 import { DEFAULT_SETTINGS, AtProtoSettings, SettingTab } from "./settings"; 4 3 import { ATmarkView, VIEW_TYPE_ATMARK } from "./views/atmark"; 5 - // import { StandardSiteView, VIEW_TYPE_STANDARD_SITE } from "./views/standardsite"; 6 4 import { publishFileAsDocument } from "./commands/publishDocument"; 7 5 import { StandardFeedView, VIEW_STANDARD_FEED } from "views/standardfeed"; 8 - import { getAuthClient } from "lib"; 6 + import { ATClient } from "lib/client"; 9 7 10 8 export default class ATmarkPlugin extends Plugin { 11 9 settings: AtProtoSettings = DEFAULT_SETTINGS; 12 - client: Client | null = null; 10 + client: ATClient 13 11 14 12 async onload() { 15 13 await this.loadSettings(); 16 14 15 + const creds = { 16 + identifier: this.settings.identifier, 17 + password: this.settings.appPassword, 18 + }; 19 + this.client = new ATClient(creds); 20 + 17 21 this.registerView(VIEW_TYPE_ATMARK, (leaf) => { 18 22 return new ATmarkView(leaf, this); 19 23 }); 20 24 21 - // this.registerView(VIEW_TYPE_STANDARD_SITE, (leaf) => { 22 - // return new StandardSiteView(leaf, this); 23 - // }); 24 25 this.registerView(VIEW_STANDARD_FEED, (leaf) => { 25 26 return new StandardFeedView(leaf, this); 26 27 }); ··· 71 72 } 72 73 73 74 74 - async initClient() { 75 - const { identifier, appPassword } = this.settings; 76 - if (identifier && appPassword) { 77 - try { 78 - this.client = await getAuthClient({ identifier, password: appPassword }); 79 - new Notice("Connected"); 80 - } catch (err) { 81 - const message = err instanceof Error ? err.message : String(err); 82 - console.error("Failed to login:", message); 83 - } 84 - } 85 - } 86 - 87 - async refreshClient() { 88 - await this.initClient(); 89 - } 90 - 91 75 92 76 async activateView(v: string) { 93 77 const { workspace } = this.app; 94 - if (!this.client) { 95 - await this.initClient(); 96 - } 97 - if (!this.client) { 98 - new Notice("Failed to login. Check your credentials in plugin settings."); 99 - return; 100 - } 101 78 102 79 let leaf: WorkspaceLeaf | null = null; 103 80 const leaves = workspace.getLeavesOfType(v);
+2 -12
src/views/atmark.ts
··· 18 18 constructor(leaf: WorkspaceLeaf, plugin: ATmarkPlugin) { 19 19 super(leaf); 20 20 this.plugin = plugin; 21 - 22 - this.initSources(); 23 - 24 21 } 25 22 26 23 initSources() { 27 - if (this.plugin.client) { 24 + if (this.plugin.settings.identifier) { 28 25 const repo = this.plugin.settings.identifier; 29 26 this.sources.set("semble", { 30 27 source: new SembleSource(this.plugin.client, repo), ··· 57 54 } 58 55 59 56 async onOpen() { 57 + this.initSources(); 60 58 await this.render(); 61 59 } 62 60 ··· 75 73 container.empty(); 76 74 container.addClass("atmark-view"); 77 75 78 - if (!this.plugin.client) { 79 - await this.plugin.refreshClient(); 80 - if (!this.plugin.client) { 81 - container.createEl("p", { text: "Not logged in, check your credentials in settings." }); 82 - return; 83 - } 84 - this.initSources(); 85 - } 86 76 this.renderHeader(container); 87 77 88 78 const loading = container.createEl("p", { text: "Loading..." });
+1 -10
src/views/standardfeed.ts
··· 34 34 const container = this.contentEl; 35 35 container.empty(); 36 36 container.addClass("standard-site-view"); 37 - 38 - if (!this.plugin.client) { 39 - await this.plugin.refreshClient(); 40 - if (!this.plugin.client) { 41 - container.createEl("p", { text: "Not logged in, check your credentials in settings." }); 42 - return; 43 - } 44 - } 45 - 46 37 this.renderHeader(container); 47 38 48 39 const loading = container.createEl("p", { text: "Loading feed..." }); 49 40 50 41 try { 51 - const pubs = await getSubscribedPublications(this.plugin.settings.identifier); 42 + const pubs = await getSubscribedPublications(this.plugin.client, this.plugin.settings.identifier); 52 43 loading.remove(); 53 44 54 45 if (pubs.length === 0) {