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