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 { 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}