AT protocol bookmarking platforms in obsidian
1import { Modal, Notice } from "obsidian";
2import type ATmarkPlugin from "../main";
3import { getCollections, getCollectionLinks, createCollectionLink, getRecord, deleteRecord } from "../lib";
4import type { Main as Collection } from "../lexicons/types/network/cosmik/collection";
5import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink";
6
7interface CollectionRecord {
8 uri: string;
9 cid: string;
10 value: Collection;
11}
12
13interface CollectionLinkRecord {
14 uri: string;
15 value: CollectionLink;
16}
17
18interface CollectionState {
19 collection: CollectionRecord;
20 isSelected: boolean;
21 wasSelected: boolean; // Original state to track changes
22 linkUri?: string; // URI of existing link (for deletion)
23}
24
25export class EditCardModal extends Modal {
26 plugin: ATmarkPlugin;
27 cardUri: string;
28 cardCid: string;
29 onSuccess?: () => void;
30 collectionStates: CollectionState[] = [];
31
32 constructor(plugin: ATmarkPlugin, cardUri: string, cardCid: string, onSuccess?: () => void) {
33 super(plugin.app);
34 this.plugin = plugin;
35 this.cardUri = cardUri;
36 this.cardCid = cardCid;
37 this.onSuccess = onSuccess;
38 }
39
40 async onOpen() {
41 const { contentEl } = this;
42 contentEl.empty();
43 contentEl.addClass("atmark-modal");
44
45 contentEl.createEl("h2", { text: "Edit collections" });
46
47 if (!this.plugin.client) {
48 contentEl.createEl("p", { text: "Not connected." });
49 return;
50 }
51
52 const loading = contentEl.createEl("p", { text: "Loading..." });
53
54 try {
55 const [collectionsResp, linksResp] = await Promise.all([
56 getCollections(this.plugin.client, this.plugin.settings.identifier),
57 getCollectionLinks(this.plugin.client, this.plugin.settings.identifier),
58 ]);
59
60 loading.remove();
61
62 if (!collectionsResp.ok) {
63 contentEl.createEl("p", { text: "Failed to load collections.", cls: "atmark-error" });
64 return;
65 }
66
67 const collections = collectionsResp.data.records as unknown as CollectionRecord[];
68 const links = (linksResp.ok ? linksResp.data.records : []) as unknown as CollectionLinkRecord[];
69
70 if (collections.length === 0) {
71 contentEl.createEl("p", { text: "No collections found. Create a collection first." });
72 return;
73 }
74
75 const cardLinks = links.filter(link => link.value.card.uri === this.cardUri);
76 const linkedCollectionUris = new Map<string, string>();
77 for (const link of cardLinks) {
78 linkedCollectionUris.set(link.value.collection.uri, link.uri);
79 }
80
81 this.collectionStates = collections.map(collection => ({
82 collection,
83 isSelected: linkedCollectionUris.has(collection.uri),
84 wasSelected: linkedCollectionUris.has(collection.uri),
85 linkUri: linkedCollectionUris.get(collection.uri),
86 }));
87
88 this.renderCollectionList(contentEl);
89 } catch (err) {
90 loading.remove();
91 const message = err instanceof Error ? err.message : String(err);
92 contentEl.createEl("p", { text: `Error: ${message}`, cls: "atmark-error" });
93 }
94 }
95
96 private renderCollectionList(contentEl: HTMLElement) {
97 const list = contentEl.createEl("div", { cls: "atmark-collection-list" });
98
99 for (const state of this.collectionStates) {
100 const item = list.createEl("label", { cls: "atmark-collection-item" });
101
102 const checkbox = item.createEl("input", { type: "checkbox", cls: "atmark-collection-checkbox" });
103 checkbox.checked = state.isSelected;
104 checkbox.addEventListener("change", () => {
105 state.isSelected = checkbox.checked;
106 this.updateSaveButton();
107 });
108
109 const info = item.createEl("div", { cls: "atmark-collection-item-info" });
110 info.createEl("span", { text: state.collection.value.name, cls: "atmark-collection-item-name" });
111 if (state.collection.value.description) {
112 info.createEl("span", { text: state.collection.value.description, cls: "atmark-collection-item-desc" });
113 }
114 }
115
116 const actions = contentEl.createEl("div", { cls: "atmark-modal-actions" });
117
118 const deleteBtn = actions.createEl("button", { text: "Delete", cls: "atmark-btn atmark-btn-danger" });
119 deleteBtn.addEventListener("click", () => { this.confirmDelete(contentEl); });
120
121 actions.createEl("div", { cls: "atmark-spacer" });
122
123 const cancelBtn = actions.createEl("button", { text: "Cancel", cls: "atmark-btn atmark-btn-secondary" });
124 cancelBtn.addEventListener("click", () => { this.close(); });
125
126 const saveBtn = actions.createEl("button", { text: "Save", cls: "atmark-btn atmark-btn-primary" });
127 saveBtn.id = "atmark-save-btn";
128 saveBtn.disabled = true;
129 saveBtn.addEventListener("click", () => { void this.saveChanges(); });
130 }
131
132 private confirmDelete(contentEl: HTMLElement) {
133 contentEl.empty();
134 contentEl.createEl("h2", { text: "Delete card" });
135 contentEl.createEl("p", { text: "Delete this card?", cls: "atmark-warning-text" });
136
137 const actions = contentEl.createEl("div", { cls: "atmark-modal-actions" });
138
139 const cancelBtn = actions.createEl("button", { text: "Cancel", cls: "atmark-btn atmark-btn-secondary" });
140 cancelBtn.addEventListener("click", () => {
141 void this.onOpen();
142 });
143
144 const confirmBtn = actions.createEl("button", { text: "Delete", cls: "atmark-btn atmark-btn-danger" });
145 confirmBtn.addEventListener("click", () => { void this.deleteCard(); });
146 }
147
148 private async deleteCard() {
149 if (!this.plugin.client) return;
150
151 const { contentEl } = this;
152 contentEl.empty();
153 contentEl.createEl("p", { text: "Deleting card..." });
154
155 try {
156 const rkey = this.cardUri.split("/").pop();
157 if (!rkey) {
158 contentEl.empty();
159 contentEl.createEl("p", { text: "Invalid card uri.", cls: "atmark-error" });
160 return;
161 }
162
163 await deleteRecord(
164 this.plugin.client,
165 this.plugin.settings.identifier,
166 "network.cosmik.card",
167 rkey
168 );
169
170 new Notice("Card deleted");
171 this.close();
172 this.onSuccess?.();
173 } catch (err) {
174 contentEl.empty();
175 const message = err instanceof Error ? err.message : String(err);
176 contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "atmark-error" });
177 }
178 }
179
180 private updateSaveButton() {
181 const saveBtn = document.getElementById("atmark-save-btn") as HTMLButtonElement | null;
182 if (!saveBtn) return;
183
184 const hasChanges = this.collectionStates.some(s => s.isSelected !== s.wasSelected);
185 saveBtn.disabled = !hasChanges;
186 }
187
188 private async saveChanges() {
189 if (!this.plugin.client) return;
190
191 const { contentEl } = this;
192 contentEl.empty();
193 contentEl.createEl("p", { text: "Saving changes..." });
194
195 try {
196 const toAdd = this.collectionStates.filter(s => s.isSelected && !s.wasSelected);
197 const toRemove = this.collectionStates.filter(s => !s.isSelected && s.wasSelected);
198
199 for (const state of toRemove) {
200 if (state.linkUri) {
201 const rkey = state.linkUri.split("/").pop();
202 if (rkey) {
203 await deleteRecord(
204 this.plugin.client,
205 this.plugin.settings.identifier,
206 "network.cosmik.collectionLink",
207 rkey
208 );
209 }
210 }
211 }
212
213 for (const state of toAdd) {
214 const collectionRkey = state.collection.uri.split("/").pop();
215 if (!collectionRkey) continue;
216
217 const collectionResp = await getRecord(
218 this.plugin.client,
219 this.plugin.settings.identifier,
220 "network.cosmik.collection",
221 collectionRkey
222 );
223
224 if (!collectionResp.ok || !collectionResp.data.cid) continue;
225
226 await createCollectionLink(
227 this.plugin.client,
228 this.plugin.settings.identifier,
229 this.cardUri,
230 this.cardCid,
231 state.collection.uri,
232 String(collectionResp.data.cid)
233 );
234 }
235
236 const addedCount = toAdd.length;
237 const removedCount = toRemove.length;
238 const messages: string[] = [];
239 if (addedCount > 0) messages.push(`Added to ${addedCount} collection${addedCount > 1 ? "s" : ""}`);
240 if (removedCount > 0) messages.push(`Removed from ${removedCount} collection${removedCount > 1 ? "s" : ""}`);
241
242 if (messages.length > 0) {
243 new Notice(messages.join(". "));
244 }
245
246 this.close();
247 this.onSuccess?.();
248 } catch (err) {
249 contentEl.empty();
250 const message = err instanceof Error ? err.message : String(err);
251 contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "atmark-error" });
252 }
253 }
254
255 onClose() {
256 this.contentEl.empty();
257 }
258}