AT protocol bookmarking platforms in obsidian
at margin 317 lines 11 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 { getMarginBookmarks, getMarginCollections, getMarginCollectionItems } from "../lib"; 6import type { ATmarkItem, DataSource, SourceFilter } from "./types"; 7import type { Main as MarginBookmark } from "../lexicons/types/at/margin/bookmark"; 8import type { Main as MarginCollection } from "../lexicons/types/at/margin/collection"; 9import type { Main as MarginCollectionItem } from "../lexicons/types/at/margin/collectionItem"; 10import { EditMarginBookmarkModal } from "../components/editMarginBookmarkModal"; 11import { CreateMarginCollectionModal } from "../components/createMarginCollectionModal"; 12 13type MarginBookmarkRecord = Record & { value: MarginBookmark }; 14type MarginCollectionRecord = Record & { value: MarginCollection }; 15type MarginCollectionItemRecord = Record & { value: MarginCollectionItem }; 16 17class MarginItem implements ATmarkItem { 18 private record: MarginBookmarkRecord; 19 private plugin: ATmarkPlugin; 20 private collections: Array<{ uri: string; name: string }>; 21 22 constructor(record: MarginBookmarkRecord, collections: Array<{ uri: string; name: string }>, plugin: ATmarkPlugin) { 23 this.record = record; 24 this.collections = collections; 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; 38 } 39 40 getSource(): "margin" { 41 return "margin"; 42 } 43 44 canAddNotes(): boolean { 45 return false; 46 } 47 48 canEdit(): boolean { 49 return true; 50 } 51 52 openEditModal(onSuccess?: () => void): void { 53 new EditMarginBookmarkModal(this.plugin, this.record, onSuccess).open(); 54 } 55 56 render(container: HTMLElement): void { 57 const el = container.createEl("div", { cls: "atmark-item-content" }); 58 const bookmark = this.record.value; 59 60 // Display collections 61 if (this.collections.length > 0) { 62 const collectionsContainer = el.createEl("div", { cls: "atmark-item-collections" }); 63 for (const collection of this.collections) { 64 collectionsContainer.createEl("span", { text: collection.name, cls: "atmark-collection" }); 65 } 66 } 67 68 // Display tags 69 if (bookmark.tags && bookmark.tags.length > 0) { 70 const tagsContainer = el.createEl("div", { cls: "atmark-item-tags" }); 71 for (const tag of bookmark.tags) { 72 tagsContainer.createEl("span", { text: tag, cls: "atmark-tag" }); 73 } 74 } 75 76 if (bookmark.title) { 77 el.createEl("div", { text: bookmark.title, cls: "atmark-item-title" }); 78 } 79 80 if (bookmark.description) { 81 const desc = bookmark.description.length > 200 82 ? bookmark.description.slice(0, 200) + "…" 83 : bookmark.description; 84 el.createEl("p", { text: desc, cls: "atmark-item-desc" }); 85 } 86 87 const link = el.createEl("a", { 88 text: bookmark.source, 89 href: bookmark.source, 90 cls: "atmark-item-url", 91 }); 92 link.setAttr("target", "_blank"); 93 } 94 95 renderDetail(container: HTMLElement): void { 96 const body = container.createEl("div", { cls: "atmark-detail-body" }); 97 const bookmark = this.record.value; 98 99 if (bookmark.title) { 100 body.createEl("h2", { text: bookmark.title, cls: "atmark-detail-title" }); 101 } 102 103 if (bookmark.description) { 104 body.createEl("p", { text: bookmark.description, cls: "atmark-detail-description" }); 105 } 106 107 const linkWrapper = body.createEl("div", { cls: "atmark-detail-link-wrapper" }); 108 const link = linkWrapper.createEl("a", { 109 text: bookmark.source, 110 href: bookmark.source, 111 cls: "atmark-detail-link", 112 }); 113 link.setAttr("target", "_blank"); 114 115 // Collections section 116 if (this.collections.length > 0) { 117 const collectionsSection = container.createEl("div", { cls: "atmark-item-collections-section" }); 118 collectionsSection.createEl("h3", { text: "Collections", cls: "atmark-detail-section-title" }); 119 const collectionsContainer = collectionsSection.createEl("div", { cls: "atmark-item-collections" }); 120 for (const collection of this.collections) { 121 collectionsContainer.createEl("span", { text: collection.name, cls: "atmark-collection" }); 122 } 123 } 124 125 // Tags section 126 if (bookmark.tags && bookmark.tags.length > 0) { 127 const tagsSection = container.createEl("div", { cls: "atmark-item-tags-section" }); 128 tagsSection.createEl("h3", { text: "Tags", cls: "atmark-detail-section-title" }); 129 const tagsContainer = tagsSection.createEl("div", { cls: "atmark-item-tags" }); 130 for (const tag of bookmark.tags) { 131 tagsContainer.createEl("span", { text: tag, cls: "atmark-tag" }); 132 } 133 } 134 } 135 136 getTags() { 137 return this.record.value.tags || []; 138 } 139 140 getRecord() { 141 return this.record; 142 } 143} 144 145export class MarginSource implements DataSource { 146 readonly name = "margin" as const; 147 private client: Client; 148 private repo: string; 149 150 constructor(client: Client, repo: string) { 151 this.client = client; 152 this.repo = repo; 153 } 154 155 async fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]> { 156 const bookmarksResp = await getMarginBookmarks(this.client, this.repo); 157 if (!bookmarksResp.ok) return []; 158 159 let bookmarks = bookmarksResp.data.records as MarginBookmarkRecord[]; 160 161 // Build collections map (bookmark URI -> collection info) 162 const collectionsMap = new Map<string, Array<{ uri: string; name: string }>>(); 163 const collectionsResp = await getMarginCollections(this.client, this.repo); 164 const itemsResp = await getMarginCollectionItems(this.client, this.repo); 165 166 if (collectionsResp.ok && itemsResp.ok) { 167 const collections = collectionsResp.data.records as MarginCollectionRecord[]; 168 const collectionNameMap = new Map<string, string>(); 169 for (const collection of collections) { 170 collectionNameMap.set(collection.uri, collection.value.name); 171 } 172 173 const items = itemsResp.data.records as MarginCollectionItemRecord[]; 174 for (const item of items) { 175 const bookmarkUri = item.value.annotation; 176 const collectionUri = item.value.collection; 177 const collectionName = collectionNameMap.get(collectionUri); 178 179 if (collectionName) { 180 const existing = collectionsMap.get(bookmarkUri) || []; 181 existing.push({ uri: collectionUri, name: collectionName }); 182 collectionsMap.set(bookmarkUri, existing); 183 } 184 } 185 } 186 187 // Apply collection filter if specified 188 const collectionFilter = filters.find(f => f.type === "marginCollection"); 189 if (collectionFilter && collectionFilter.value) { 190 if (itemsResp.ok) { 191 const items = itemsResp.data.records as MarginCollectionItemRecord[]; 192 const filteredItems = items.filter((item: MarginCollectionItemRecord) => 193 item.value.collection === collectionFilter.value 194 ); 195 const bookmarkUris = new Set(filteredItems.map((item: MarginCollectionItemRecord) => item.value.annotation)); 196 bookmarks = bookmarks.filter((bookmark: MarginBookmarkRecord) => bookmarkUris.has(bookmark.uri)); 197 } 198 } 199 200 // Apply tag filter if specified 201 const tagFilter = filters.find(f => f.type === "marginTag"); 202 if (tagFilter && tagFilter.value) { 203 bookmarks = bookmarks.filter((record: MarginBookmarkRecord) => 204 record.value.tags?.includes(tagFilter.value) 205 ); 206 } 207 208 return bookmarks.map((record: MarginBookmarkRecord) => 209 new MarginItem(record, collectionsMap.get(record.uri) || [], plugin) 210 ); 211 } 212 213 async getAvailableFilters(): Promise<SourceFilter[]> { 214 const filters: SourceFilter[] = []; 215 216 // Get collections 217 const collectionsResp = await getMarginCollections(this.client, this.repo); 218 if (collectionsResp.ok) { 219 const collections = collectionsResp.data.records as MarginCollectionRecord[]; 220 filters.push(...collections.map((c: MarginCollectionRecord) => ({ 221 type: "marginCollection", 222 value: c.uri, 223 label: c.value.name, 224 }))); 225 } 226 227 // Get tags 228 const bookmarksResp = await getMarginBookmarks(this.client, this.repo); 229 if (bookmarksResp.ok) { 230 const tagSet = new Set<string>(); 231 const records = bookmarksResp.data.records as MarginBookmarkRecord[]; 232 for (const record of records) { 233 if (record.value.tags) { 234 for (const tag of record.value.tags) { 235 tagSet.add(tag); 236 } 237 } 238 } 239 filters.push(...Array.from(tagSet).map(tag => ({ 240 type: "marginTag", 241 value: tag, 242 label: tag, 243 }))); 244 } 245 246 return filters; 247 } 248 249 renderFilterUI(container: HTMLElement, activeFilters: Map<string, SourceFilter>, onChange: () => void, plugin: ATmarkPlugin): void { 250 // Collections section 251 const collectionsSection = container.createEl("div", { cls: "atmark-filter-section" }); 252 253 const collectionsTitleRow = collectionsSection.createEl("div", { cls: "atmark-filter-title-row" }); 254 collectionsTitleRow.createEl("h3", { text: "Collections", cls: "atmark-filter-title" }); 255 256 const createCollectionBtn = collectionsTitleRow.createEl("button", { cls: "atmark-filter-create-btn" }); 257 setIcon(createCollectionBtn, "plus"); 258 createCollectionBtn.addEventListener("click", () => { 259 new CreateMarginCollectionModal(plugin, onChange).open(); 260 }); 261 262 const collectionsChips = collectionsSection.createEl("div", { cls: "atmark-filter-chips" }); 263 264 // All collections chip 265 const allCollectionsChip = collectionsChips.createEl("button", { 266 text: "All", 267 cls: `atmark-chip ${!activeFilters.has("marginCollection") ? "atmark-chip-active" : ""}`, 268 }); 269 allCollectionsChip.addEventListener("click", () => { 270 activeFilters.delete("marginCollection"); 271 onChange(); 272 }); 273 274 // Tags section 275 const tagsSection = container.createEl("div", { cls: "atmark-filter-section" }); 276 277 const tagsTitleRow = tagsSection.createEl("div", { cls: "atmark-filter-title-row" }); 278 tagsTitleRow.createEl("h3", { text: "Tags", cls: "atmark-filter-title" }); 279 280 const tagsChips = tagsSection.createEl("div", { cls: "atmark-filter-chips" }); 281 282 // All tags chip 283 const allTagsChip = tagsChips.createEl("button", { 284 text: "All", 285 cls: `atmark-chip ${!activeFilters.has("marginTag") ? "atmark-chip-active" : ""}`, 286 }); 287 allTagsChip.addEventListener("click", () => { 288 activeFilters.delete("marginTag"); 289 onChange(); 290 }); 291 292 // Get filters and render chips 293 void this.getAvailableFilters().then(filters => { 294 for (const filter of filters) { 295 if (filter.type === "marginCollection") { 296 const chip = collectionsChips.createEl("button", { 297 text: filter.label, 298 cls: `atmark-chip ${activeFilters.get("marginCollection")?.value === filter.value ? "atmark-chip-active" : ""}`, 299 }); 300 chip.addEventListener("click", () => { 301 activeFilters.set("marginCollection", filter); 302 onChange(); 303 }); 304 } else if (filter.type === "marginTag") { 305 const chip = tagsChips.createEl("button", { 306 text: filter.label, 307 cls: `atmark-chip ${activeFilters.get("marginTag")?.value === filter.value ? "atmark-chip-active" : ""}`, 308 }); 309 chip.addEventListener("click", () => { 310 activeFilters.set("marginTag", filter); 311 onChange(); 312 }); 313 } 314 } 315 }); 316 } 317}