AT protocol bookmarking platforms in obsidian
at responsive-sizing 266 lines 8.4 kB view raw
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}