AT protocol bookmarking platforms in obsidian
at client-cache 256 lines 8.3 kB view raw
1import type { Client } from "@atcute/client"; 2import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3import { setIcon } from "obsidian"; 4import type ATmarkPlugin from "../main"; 5import { getCards, getCollections, getCollectionLinks } from "../lib"; 6import type { Main as Card, NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card"; 7import type { Main as Collection } from "../lexicons/types/network/cosmik/collection"; 8import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink"; 9import type { ATmarkItem, DataSource, SourceFilter } from "./types"; 10import { EditCardModal } from "../components/editCardModal"; 11import { CreateCollectionModal } from "../components/createCollectionModal"; 12 13type CardRecord = Record & { value: Card }; 14type CollectionRecord = Record & { value: Collection }; 15type CollectionLinkRecord = Record & { value: CollectionLink }; 16 17class SembleItem implements ATmarkItem { 18 private record: CardRecord; 19 private attachedNotes: Array<{ uri: string; text: string }>; 20 private plugin: ATmarkPlugin; 21 22 constructor(record: CardRecord, attachedNotes: Array<{ uri: string; text: string }>, plugin: ATmarkPlugin) { 23 this.record = record; 24 this.attachedNotes = attachedNotes; 25 this.plugin = plugin; 26 } 27 28 getUri(): string { 29 return this.record.uri; 30 } 31 32 getCid(): string { 33 return this.record.cid; 34 } 35 36 getCreatedAt(): string { 37 return this.record.value.createdAt || new Date().toISOString(); 38 } 39 40 getSource(): "semble" { 41 return "semble"; 42 } 43 44 canAddNotes(): boolean { 45 return true; 46 } 47 48 canEdit(): boolean { 49 return true; 50 } 51 52 openEditModal(onSuccess?: () => void): void { 53 new EditCardModal(this.plugin, this.record.uri, this.record.cid, onSuccess).open(); 54 } 55 56 render(container: HTMLElement): void { 57 const el = container.createEl("div", { cls: "atmark-item-content" }); 58 59 const card = this.record.value; 60 61 if (card.type === "NOTE") { 62 const content = card.content as NoteContent; 63 el.createEl("p", { text: content.text, cls: "atmark-semble-card-text" }); 64 } else if (card.type === "URL") { 65 const content = card.content as UrlContent; 66 const meta = content.metadata; 67 68 if (meta?.title) { 69 el.createEl("div", { text: meta.title, cls: "atmark-item-title" }); 70 } 71 72 if (meta?.imageUrl) { 73 const img = el.createEl("img", { cls: "atmark-item-image" }); 74 img.src = meta.imageUrl; 75 img.alt = meta.title || "Image"; 76 } 77 78 if (meta?.description) { 79 const desc = meta.description.length > 200 80 ? meta.description.slice(0, 200) + "…" 81 : meta.description; 82 el.createEl("p", { text: desc, cls: "atmark-item-desc" }); 83 } 84 85 if (meta?.siteName) { 86 el.createEl("span", { text: meta.siteName, cls: "atmark-item-site" }); 87 } 88 89 const link = el.createEl("a", { 90 text: content.url, 91 href: content.url, 92 cls: "atmark-item-url", 93 }); 94 link.setAttr("target", "_blank"); 95 } 96 } 97 98 renderDetail(container: HTMLElement): void { 99 const body = container.createEl("div", { cls: "atmark-detail-body" }); 100 const card = this.record.value; 101 102 if (card.type === "NOTE") { 103 const content = card.content as NoteContent; 104 body.createEl("p", { text: content.text, cls: "atmark-semble-detail-text" }); 105 } else if (card.type === "URL") { 106 const content = card.content as UrlContent; 107 const meta = content.metadata; 108 109 if (meta?.title) { 110 body.createEl("h2", { text: meta.title, cls: "atmark-detail-title" }); 111 } 112 113 if (meta?.imageUrl) { 114 const img = body.createEl("img", { cls: "atmark-detail-image" }); 115 img.src = meta.imageUrl; 116 img.alt = meta.title || "Image"; 117 } 118 119 if (meta?.description) { 120 body.createEl("p", { text: meta.description, cls: "atmark-detail-description" }); 121 } 122 123 if (meta?.siteName) { 124 const metaGrid = body.createEl("div", { cls: "atmark-detail-meta" }); 125 const item = metaGrid.createEl("div", { cls: "atmark-detail-meta-item" }); 126 item.createEl("span", { text: "Site", cls: "atmark-detail-meta-label" }); 127 item.createEl("span", { text: meta.siteName, cls: "atmark-detail-meta-value" }); 128 } 129 130 const linkWrapper = body.createEl("div", { cls: "atmark-detail-link-wrapper" }); 131 const link = linkWrapper.createEl("a", { 132 text: content.url, 133 href: content.url, 134 cls: "atmark-detail-link", 135 }); 136 link.setAttr("target", "_blank"); 137 } 138 139 } 140 141 getAttachedNotes() { 142 return this.attachedNotes; 143 } 144 145 getRecord() { 146 return this.record; 147 } 148} 149 150export class SembleSource implements DataSource { 151 readonly name = "semble" as const; 152 private client: Client; 153 private repo: string; 154 155 constructor(client: Client, repo: string) { 156 this.client = client; 157 this.repo = repo; 158 } 159 160 async fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]> { 161 const cardsResp = await getCards(this.client, this.repo); 162 if (!cardsResp.ok) return []; 163 164 const allSembleCards = cardsResp.data.records as CardRecord[]; 165 166 const notesMap = new Map<string, Array<{ uri: string; text: string }>>(); 167 for (const record of allSembleCards) { 168 if (record.value.type === "NOTE") { 169 const parentUri = record.value.parentCard?.uri; 170 if (parentUri) { 171 const noteContent = record.value.content as NoteContent; 172 const existing = notesMap.get(parentUri) || []; 173 existing.push({ uri: record.uri, text: noteContent.text }); 174 notesMap.set(parentUri, existing); 175 } 176 } 177 } 178 179 // Filter out NOTE cards that are attached to other cards 180 let sembleCards = allSembleCards.filter((record: CardRecord) => { 181 if (record.value.type === "NOTE") { 182 const hasParent = record.value.parentCard?.uri; 183 return !hasParent; 184 } 185 return true; 186 }); 187 188 const collectionFilter = filters.find(f => f.type === "sembleCollection"); 189 if (collectionFilter && collectionFilter.value) { 190 const linksResp = await getCollectionLinks(this.client, this.repo); 191 if (linksResp.ok) { 192 const links = linksResp.data.records as CollectionLinkRecord[]; 193 const filteredLinks = links.filter((link: CollectionLinkRecord) => 194 link.value.collection.uri === collectionFilter.value 195 ); 196 const cardUris = new Set(filteredLinks.map((link: CollectionLinkRecord) => link.value.card.uri)); 197 sembleCards = sembleCards.filter((card: CardRecord) => cardUris.has(card.uri)); 198 } 199 } 200 201 return sembleCards.map((record: CardRecord) => 202 new SembleItem(record, notesMap.get(record.uri) || [], plugin) 203 ); 204 } 205 206 async getAvailableFilters(): Promise<SourceFilter[]> { 207 const collectionsResp = await getCollections(this.client, this.repo); 208 if (!collectionsResp.ok) return []; 209 210 const collections = collectionsResp.data.records as CollectionRecord[]; 211 return collections.map((c: CollectionRecord) => ({ 212 type: "sembleCollection", 213 value: c.uri, 214 label: c.value.name, 215 })); 216 } 217 218 renderFilterUI(container: HTMLElement, activeFilters: Map<string, SourceFilter>, onChange: () => void, plugin: ATmarkPlugin): void { 219 const section = container.createEl("div", { cls: "atmark-filter-section" }); 220 221 const titleRow = section.createEl("div", { cls: "atmark-filter-title-row" }); 222 titleRow.createEl("h3", { text: "Semble collections", cls: "atmark-filter-title" }); 223 224 const createBtn = titleRow.createEl("button", { cls: "atmark-filter-create-btn" }); 225 setIcon(createBtn, "plus"); 226 createBtn.addEventListener("click", () => { 227 new CreateCollectionModal(plugin, onChange).open(); 228 }); 229 230 const chips = section.createEl("div", { cls: "atmark-filter-chips" }); 231 232 const allChip = chips.createEl("button", { 233 text: "All", 234 cls: `atmark-chip ${!activeFilters.has("sembleCollection") ? "atmark-chip-active" : ""}`, 235 }); 236 allChip.addEventListener("click", () => { 237 activeFilters.delete("sembleCollection"); 238 onChange(); 239 }); 240 241 // Get collections synchronously - note: this is a limitation 242 // In a real app, we'd want to cache these or handle async properly 243 void this.getAvailableFilters().then(collections => { 244 for (const collection of collections) { 245 const chip = chips.createEl("button", { 246 text: collection.label, 247 cls: `atmark-chip ${activeFilters.get("sembleCollection")?.value === collection.value ? "atmark-chip-active" : ""}`, 248 }); 249 chip.addEventListener("click", () => { 250 activeFilters.set("sembleCollection", collection); 251 onChange(); 252 }); 253 } 254 }); 255 } 256}