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("semble-collection-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 // Fetch collections and existing links in parallel
56 const [collectionsResp, linksResp] = await Promise.all([
57 getCollections(this.plugin.client, this.plugin.settings.identifier),
58 getCollectionLinks(this.plugin.client, this.plugin.settings.identifier),
59 ]);
60
61 loading.remove();
62
63 if (!collectionsResp.ok) {
64 contentEl.createEl("p", { text: "Failed to load collections.", cls: "semble-error" });
65 return;
66 }
67
68 const collections = collectionsResp.data.records as unknown as CollectionRecord[];
69 const links = (linksResp.ok ? linksResp.data.records : []) as unknown as CollectionLinkRecord[];
70
71 if (collections.length === 0) {
72 contentEl.createEl("p", { text: "No collections found. Create a collection first." });
73 return;
74 }
75
76 // Find which collections this card is already in
77 const cardLinks = links.filter(link => link.value.card.uri === this.cardUri);
78 const linkedCollectionUris = new Map<string, string>();
79 for (const link of cardLinks) {
80 linkedCollectionUris.set(link.value.collection.uri, link.uri);
81 }
82
83 // Build collection states
84 this.collectionStates = collections.map(collection => ({
85 collection,
86 isSelected: linkedCollectionUris.has(collection.uri),
87 wasSelected: linkedCollectionUris.has(collection.uri),
88 linkUri: linkedCollectionUris.get(collection.uri),
89 }));
90
91 this.renderCollectionList(contentEl);
92 } catch (err) {
93 loading.remove();
94 const message = err instanceof Error ? err.message : String(err);
95 contentEl.createEl("p", { text: `Error: ${message}`, cls: "semble-error" });
96 }
97 }
98
99 private renderCollectionList(contentEl: HTMLElement) {
100 const list = contentEl.createEl("div", { cls: "semble-collection-list" });
101
102 for (const state of this.collectionStates) {
103 const item = list.createEl("label", { cls: "semble-collection-item" });
104
105 const checkbox = item.createEl("input", { type: "checkbox", cls: "semble-collection-checkbox" });
106 checkbox.checked = state.isSelected;
107 checkbox.addEventListener("change", () => {
108 state.isSelected = checkbox.checked;
109 this.updateSaveButton();
110 });
111
112 const info = item.createEl("div", { cls: "semble-collection-item-info" });
113 info.createEl("span", { text: state.collection.value.name, cls: "semble-collection-item-name" });
114 if (state.collection.value.description) {
115 info.createEl("span", { text: state.collection.value.description, cls: "semble-collection-item-desc" });
116 }
117 }
118
119 // Action buttons
120 const actions = contentEl.createEl("div", { cls: "semble-modal-actions" });
121
122 const deleteBtn = actions.createEl("button", { text: "Delete", cls: "semble-btn semble-btn-danger" });
123 deleteBtn.addEventListener("click", () => { this.confirmDelete(contentEl); });
124
125 actions.createEl("div", { cls: "semble-spacer" });
126
127 const cancelBtn = actions.createEl("button", { text: "Cancel", cls: "semble-btn semble-btn-secondary" });
128 cancelBtn.addEventListener("click", () => { this.close(); });
129
130 const saveBtn = actions.createEl("button", { text: "Save", cls: "semble-btn semble-btn-primary" });
131 saveBtn.id = "semble-save-btn";
132 saveBtn.disabled = true;
133 saveBtn.addEventListener("click", () => { void this.saveChanges(); });
134 }
135
136 private confirmDelete(contentEl: HTMLElement) {
137 contentEl.empty();
138 contentEl.createEl("h2", { text: "Delete card" });
139 contentEl.createEl("p", { text: "Delete this card?", cls: "semble-warning-text" });
140
141 const actions = contentEl.createEl("div", { cls: "semble-modal-actions" });
142
143 const cancelBtn = actions.createEl("button", { text: "Cancel", cls: "semble-btn semble-btn-secondary" });
144 cancelBtn.addEventListener("click", () => {
145 // Re-render the modal
146 void this.onOpen();
147 });
148
149 const confirmBtn = actions.createEl("button", { text: "Delete", cls: "semble-btn semble-btn-danger" });
150 confirmBtn.addEventListener("click", () => { void this.deleteCard(); });
151 }
152
153 private async deleteCard() {
154 if (!this.plugin.client) return;
155
156 const { contentEl } = this;
157 contentEl.empty();
158 contentEl.createEl("p", { text: "Deleting card..." });
159
160 try {
161 const rkey = this.cardUri.split("/").pop();
162 if (!rkey) {
163 contentEl.empty();
164 contentEl.createEl("p", { text: "Invalid card uri.", cls: "semble-error" });
165 return;
166 }
167
168 await deleteRecord(
169 this.plugin.client,
170 this.plugin.settings.identifier,
171 "network.cosmik.card",
172 rkey
173 );
174
175 new Notice("Card deleted");
176 this.close();
177 this.onSuccess?.();
178 } catch (err) {
179 contentEl.empty();
180 const message = err instanceof Error ? err.message : String(err);
181 contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "semble-error" });
182 }
183 }
184
185 private updateSaveButton() {
186 const saveBtn = document.getElementById("semble-save-btn") as HTMLButtonElement | null;
187 if (!saveBtn) return;
188
189 // Check if any changes were made
190 const hasChanges = this.collectionStates.some(s => s.isSelected !== s.wasSelected);
191 saveBtn.disabled = !hasChanges;
192 }
193
194 private async saveChanges() {
195 if (!this.plugin.client) return;
196
197 const { contentEl } = this;
198 contentEl.empty();
199 contentEl.createEl("p", { text: "Saving changes..." });
200
201 try {
202 const toAdd = this.collectionStates.filter(s => s.isSelected && !s.wasSelected);
203 const toRemove = this.collectionStates.filter(s => !s.isSelected && s.wasSelected);
204
205 // Process removals
206 for (const state of toRemove) {
207 if (state.linkUri) {
208 const rkey = state.linkUri.split("/").pop();
209 if (rkey) {
210 await deleteRecord(
211 this.plugin.client,
212 this.plugin.settings.identifier,
213 "network.cosmik.collectionLink",
214 rkey
215 );
216 }
217 }
218 }
219
220 // Process additions
221 for (const state of toAdd) {
222 const collectionRkey = state.collection.uri.split("/").pop();
223 if (!collectionRkey) continue;
224
225 const collectionResp = await getRecord(
226 this.plugin.client,
227 this.plugin.settings.identifier,
228 "network.cosmik.collection",
229 collectionRkey
230 );
231
232 if (!collectionResp.ok || !collectionResp.data.cid) continue;
233
234 await createCollectionLink(
235 this.plugin.client,
236 this.plugin.settings.identifier,
237 this.cardUri,
238 this.cardCid,
239 state.collection.uri,
240 String(collectionResp.data.cid)
241 );
242 }
243
244 const addedCount = toAdd.length;
245 const removedCount = toRemove.length;
246 const messages: string[] = [];
247 if (addedCount > 0) messages.push(`Added to ${addedCount} collection${addedCount > 1 ? "s" : ""}`);
248 if (removedCount > 0) messages.push(`Removed from ${removedCount} collection${removedCount > 1 ? "s" : ""}`);
249
250 if (messages.length > 0) {
251 new Notice(messages.join(". "));
252 }
253
254 this.close();
255 this.onSuccess?.();
256 } catch (err) {
257 contentEl.empty();
258 const message = err instanceof Error ? err.message : String(err);
259 contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "semble-error" });
260 }
261 }
262
263 onClose() {
264 this.contentEl.empty();
265 }
266}