AT protocol bookmarking platforms in obsidian
at client-cache 233 lines 7.0 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 { getBookmarks } from "../lib"; 6import type { ATmarkItem, DataSource, SourceFilter } from "./types"; 7import { EditBookmarkModal } from "../components/editBookmarkModal"; 8import { CreateTagModal } from "../components/createTagModal"; 9import type { Main as Bookmark } from "../lexicons/types/community/lexicon/bookmarks/bookmark"; 10 11type BookmarkRecord = Record & { value: Bookmark }; 12 13class BookmarkItem implements ATmarkItem { 14 private record: BookmarkRecord; 15 private plugin: ATmarkPlugin; 16 17 constructor(record: BookmarkRecord, plugin: ATmarkPlugin) { 18 this.record = record; 19 this.plugin = plugin; 20 } 21 22 getUri(): string { 23 return this.record.uri; 24 } 25 26 getCid(): string { 27 return this.record.cid; 28 } 29 30 getCreatedAt(): string { 31 return this.record.value.createdAt; 32 } 33 34 getSource(): "bookmark" { 35 return "bookmark"; 36 } 37 38 canAddNotes(): boolean { 39 return false; 40 } 41 42 canEdit(): boolean { 43 return true; 44 } 45 46 openEditModal(onSuccess?: () => void): void { 47 new EditBookmarkModal(this.plugin, this.record, onSuccess).open(); 48 } 49 50 render(container: HTMLElement): void { 51 const el = container.createEl("div", { cls: "atmark-item-content" }); 52 const bookmark = this.record.value; 53 const enriched = bookmark.enriched; 54 55 if (bookmark.tags && bookmark.tags.length > 0) { 56 const tagsContainer = el.createEl("div", { cls: "atmark-item-tags" }); 57 for (const tag of bookmark.tags) { 58 tagsContainer.createEl("span", { text: tag, cls: "atmark-tag" }); 59 } 60 } 61 62 const title = enriched?.title || bookmark.title; 63 if (title) { 64 el.createEl("div", { text: title, cls: "atmark-item-title" }); 65 } 66 67 const imageUrl = enriched?.image || enriched?.thumb; 68 if (imageUrl) { 69 const img = el.createEl("img", { cls: "atmark-item-image" }); 70 img.src = imageUrl; 71 img.alt = title || "Image"; 72 } 73 74 const description = enriched?.description || bookmark.description; 75 if (description) { 76 const desc = description.length > 200 77 ? description.slice(0, 200) + "…" 78 : description; 79 el.createEl("p", { text: desc, cls: "atmark-item-desc" }); 80 } 81 82 if (enriched?.siteName) { 83 el.createEl("span", { text: enriched.siteName, cls: "atmark-item-site" }); 84 } 85 86 const link = el.createEl("a", { 87 text: bookmark.subject, 88 href: bookmark.subject, 89 cls: "atmark-item-url", 90 }); 91 link.setAttr("target", "_blank"); 92 } 93 94 renderDetail(container: HTMLElement): void { 95 const body = container.createEl("div", { cls: "atmark-detail-body" }); 96 const bookmark = this.record.value; 97 const enriched = bookmark.enriched; 98 99 const title = enriched?.title || bookmark.title; 100 if (title) { 101 body.createEl("h2", { text: title, cls: "atmark-detail-title" }); 102 } 103 104 const imageUrl = enriched?.image || enriched?.thumb; 105 if (imageUrl) { 106 const img = body.createEl("img", { cls: "atmark-detail-image" }); 107 img.src = imageUrl; 108 img.alt = title || "Image"; 109 } 110 111 const description = enriched?.description || bookmark.description; 112 if (description) { 113 body.createEl("p", { text: description, cls: "atmark-detail-description" }); 114 } 115 116 if (enriched?.siteName) { 117 const metaGrid = body.createEl("div", { cls: "atmark-detail-meta" }); 118 const item = metaGrid.createEl("div", { cls: "atmark-detail-meta-item" }); 119 item.createEl("span", { text: "Site", cls: "atmark-detail-meta-label" }); 120 item.createEl("span", { text: enriched.siteName, cls: "atmark-detail-meta-value" }); 121 } 122 123 const linkWrapper = body.createEl("div", { cls: "atmark-detail-link-wrapper" }); 124 const link = linkWrapper.createEl("a", { 125 text: bookmark.subject, 126 href: bookmark.subject, 127 cls: "atmark-detail-link", 128 }); 129 link.setAttr("target", "_blank"); 130 131 if (bookmark.tags && bookmark.tags.length > 0) { 132 const tagsSection = container.createEl("div", { cls: "atmark-item-tags-section" }); 133 tagsSection.createEl("h3", { text: "Tags", cls: "atmark-detail-section-title" }); 134 const tagsContainer = tagsSection.createEl("div", { cls: "atmark-item-tags" }); 135 for (const tag of bookmark.tags) { 136 tagsContainer.createEl("span", { text: tag, cls: "atmark-tag" }); 137 } 138 } 139 } 140 141 getTags() { 142 return this.record.value.tags || []; 143 } 144 145 getRecord() { 146 return this.record; 147 } 148} 149 150export class BookmarkSource implements DataSource { 151 readonly name = "bookmark" 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 bookmarksResp = await getBookmarks(this.client, this.repo); 162 if (!bookmarksResp.ok) return []; 163 164 let bookmarks = bookmarksResp.data.records as BookmarkRecord[]; 165 166 const tagFilter = filters.find(f => f.type === "bookmarkTag"); 167 if (tagFilter && tagFilter.value) { 168 bookmarks = bookmarks.filter((record: BookmarkRecord) => 169 record.value.tags?.includes(tagFilter.value) 170 ); 171 } 172 173 return bookmarks.map((record: BookmarkRecord) => new BookmarkItem(record, plugin)); 174 } 175 176 async getAvailableFilters(): Promise<SourceFilter[]> { 177 const bookmarksResp = await getBookmarks(this.client, this.repo); 178 if (!bookmarksResp.ok) return []; 179 180 const tagSet = new Set<string>(); 181 const records = bookmarksResp.data.records as BookmarkRecord[]; 182 for (const record of records) { 183 if (record.value.tags) { 184 for (const tag of record.value.tags) { 185 tagSet.add(tag); 186 } 187 } 188 } 189 190 return Array.from(tagSet).map(tag => ({ 191 type: "bookmarkTag", 192 value: tag, 193 label: tag, 194 })); 195 } 196 197 renderFilterUI(container: HTMLElement, activeFilters: Map<string, SourceFilter>, onChange: () => void, plugin: ATmarkPlugin): void { 198 const section = container.createEl("div", { cls: "atmark-filter-section" }); 199 200 const titleRow = section.createEl("div", { cls: "atmark-filter-title-row" }); 201 titleRow.createEl("h3", { text: "Tags", cls: "atmark-filter-title" }); 202 203 const createBtn = titleRow.createEl("button", { cls: "atmark-filter-create-btn" }); 204 setIcon(createBtn, "plus"); 205 createBtn.addEventListener("click", () => { 206 new CreateTagModal(plugin, onChange).open(); 207 }); 208 209 const chips = section.createEl("div", { cls: "atmark-filter-chips" }); 210 211 const allChip = chips.createEl("button", { 212 text: "All", 213 cls: `atmark-chip ${!activeFilters.has("bookmarkTag") ? "atmark-chip-active" : ""}`, 214 }); 215 allChip.addEventListener("click", () => { 216 activeFilters.delete("bookmarkTag"); 217 onChange(); 218 }); 219 220 void this.getAvailableFilters().then(tags => { 221 for (const tag of tags) { 222 const chip = chips.createEl("button", { 223 text: tag.label, 224 cls: `atmark-chip ${activeFilters.get("bookmarkTag")?.value === tag.value ? "atmark-chip-active" : ""}`, 225 }); 226 chip.addEventListener("click", () => { 227 activeFilters.set("bookmarkTag", tag); 228 onChange(); 229 }); 230 } 231 }); 232 } 233}