AT protocol bookmarking platforms in obsidian
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}