Various AT Protocol integrations with obsidian
at main 303 lines 11 kB view raw
1import { Modal, Notice, setIcon } from "obsidian"; 2import type AtmospherePlugin from "../main"; 3import type { ATBookmarkItem, DataSource } from "../sources/types"; 4import { SembleSource } from "../sources/semble"; 5import { MarginSource } from "../sources/margin"; 6import { BookmarkSource } from "../sources/community"; 7 8interface CollectionState { 9 uri: string; 10 name: string; 11 description?: string; 12 source: "semble" | "margin"; 13 isSelected: boolean; 14 wasSelected: boolean; 15 linkUri?: string; 16} 17 18interface TagState { 19 tag: string; 20 isSelected: boolean; 21} 22 23export class EditItemModal extends Modal { 24 plugin: AtmospherePlugin; 25 item: ATBookmarkItem; 26 onSuccess?: () => void; 27 collectionStates: CollectionState[] = []; 28 tagStates: TagState[] = []; 29 newTagInput: HTMLInputElement | null = null; 30 private sembleSource!: SembleSource; 31 private marginSource!: MarginSource; 32 private itemSource!: DataSource; 33 34 constructor(plugin: AtmospherePlugin, item: ATBookmarkItem, onSuccess?: () => void) { 35 super(plugin.app); 36 this.plugin = plugin; 37 this.item = item; 38 this.onSuccess = onSuccess; 39 } 40 41 async onOpen() { 42 const { contentEl } = this; 43 contentEl.empty(); 44 contentEl.addClass("atmosphere-modal"); 45 contentEl.createEl("h2", { text: "Edit item" }); 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 did = this.plugin.settings.did!; 56 this.sembleSource = new SembleSource(this.plugin.client, did); 57 this.marginSource = new MarginSource(this.plugin.client, did); 58 const itemSourceName = this.item.getSource(); 59 this.itemSource = itemSourceName === "semble" ? this.sembleSource 60 : itemSourceName === "margin" ? this.marginSource 61 : new BookmarkSource(this.plugin.client, did); 62 63 const itemUri = this.item.getUri(); 64 65 const canCollect = this.item.canAddToCollections(); 66 const [sembleColls, sembleAssocs, marginColls, marginAssocs, availableTags] = await Promise.all([ 67 canCollect ? this.sembleSource.getAvailableCollections() : Promise.resolve([]), 68 canCollect ? this.sembleSource.getCollectionAssociations() : Promise.resolve([]), 69 canCollect ? this.marginSource.getAvailableCollections() : Promise.resolve([]), 70 canCollect ? this.marginSource.getCollectionAssociations() : Promise.resolve([]), 71 this.itemSource.getAvilableTags?.() ?? Promise.resolve(undefined), 72 ]); 73 74 loading.remove(); 75 76 if (canCollect) { 77 const sembleLinkedUris = new Map<string, string>(); 78 for (const assoc of sembleAssocs) { 79 if (assoc.record === itemUri) sembleLinkedUris.set(assoc.collection, assoc.linkUri); 80 } 81 82 const marginLinkedUris = new Map<string, string>(); 83 for (const assoc of marginAssocs) { 84 if (assoc.record === itemUri) marginLinkedUris.set(assoc.collection, assoc.linkUri); 85 } 86 87 this.collectionStates = [ 88 ...sembleColls.map(c => ({ 89 uri: c.value, 90 name: c.label ?? c.value, 91 description: c.description, 92 source: "semble" as const, 93 isSelected: sembleLinkedUris.has(c.value), 94 wasSelected: sembleLinkedUris.has(c.value), 95 linkUri: sembleLinkedUris.get(c.value), 96 })), 97 ...marginColls.map(c => ({ 98 uri: c.value, 99 name: c.label ?? c.value, 100 description: c.description, 101 source: "margin" as const, 102 isSelected: marginLinkedUris.has(c.value), 103 wasSelected: marginLinkedUris.has(c.value), 104 linkUri: marginLinkedUris.get(c.value), 105 })), 106 ]; 107 } 108 109 if (this.item.canAddTags() && availableTags) { 110 const currentTags = new Set(this.item.getTags()); 111 this.tagStates = availableTags.map(f => f.value).sort().map(tag => ({ 112 tag, 113 isSelected: currentTags.has(tag), 114 })); 115 } 116 117 this.renderForm(contentEl); 118 } catch (err) { 119 loading.remove(); 120 const message = err instanceof Error ? err.message : String(err); 121 contentEl.createEl("p", { text: `Error: ${message}`, cls: "atmosphere-error" }); 122 } 123 } 124 125 private renderForm(contentEl: HTMLElement) { 126 const form = contentEl.createEl("div", { cls: "atmosphere-form" }); 127 128 if (this.item.canAddTags()) { 129 const tagsGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 130 tagsGroup.createEl("label", { text: "Tags" }); 131 132 const tagsList = tagsGroup.createEl("div", { cls: "atmosphere-tag-list" }); 133 for (const state of this.tagStates) { 134 this.addTagChip(tagsList, state); 135 } 136 137 const newTagRow = tagsGroup.createEl("div", { cls: "atmosphere-tag-row" }); 138 this.newTagInput = newTagRow.createEl("input", { 139 type: "text", 140 cls: "atmosphere-input", 141 attr: { placeholder: "Add new tag..." }, 142 }); 143 const addBtn = newTagRow.createEl("button", { 144 text: "Add", 145 cls: "atmosphere-btn atmosphere-btn-secondary", 146 attr: { type: "button" }, 147 }); 148 addBtn.addEventListener("click", () => { 149 const value = this.newTagInput?.value.trim(); 150 if (value && !this.tagStates.some(s => s.tag === value)) { 151 const newState = { tag: value, isSelected: true }; 152 this.tagStates.push(newState); 153 this.addTagChip(tagsList, newState); 154 if (this.newTagInput) this.newTagInput.value = ""; 155 } 156 }); 157 } 158 159 if (this.collectionStates.length > 0) { 160 const collectionsGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 161 collectionsGroup.createEl("label", { text: "Collections" }); 162 163 const searchInput = collectionsGroup.createEl("input", { 164 type: "text", 165 cls: "atmosphere-input atmosphere-collection-search", 166 attr: { placeholder: "Search collections..." }, 167 }); 168 169 const collectionsList = collectionsGroup.createEl("div", { cls: "atmosphere-collection-list" }); 170 171 const rows: { el: HTMLElement; name: string }[] = []; 172 for (const state of this.collectionStates) { 173 const item = collectionsList.createEl("label", { cls: "atmosphere-collection-item" }); 174 175 const checkbox = item.createEl("input", { type: "checkbox", cls: "atmosphere-collection-checkbox" }); 176 checkbox.checked = state.isSelected; 177 checkbox.addEventListener("change", () => { state.isSelected = checkbox.checked; }); 178 179 const info = item.createEl("div", { cls: "atmosphere-collection-item-info" }); 180 info.createEl("span", { text: state.name, cls: "atmosphere-collection-item-name" }); 181 if (state.description) { 182 info.createEl("span", { text: state.description, cls: "atmosphere-collection-item-desc" }); 183 } 184 185 const sourceIcon = item.createEl("span", { cls: "atmosphere-collection-source-icon" }); 186 setIcon(sourceIcon, state.source === "semble" ? "atmosphere-semble" : "atmosphere-margin"); 187 188 rows.push({ el: item, name: state.name.toLowerCase() }); 189 } 190 191 searchInput.addEventListener("input", () => { 192 const query = searchInput.value.toLowerCase(); 193 for (const row of rows) { 194 row.el.style.display = row.name.includes(query) ? "" : "none"; 195 } 196 }); 197 } 198 199 const actions = contentEl.createEl("div", { cls: "atmosphere-modal-actions" }); 200 201 actions.createEl("button", { text: "Delete", cls: "atmosphere-btn atmosphere-btn-danger" }) 202 .addEventListener("click", () => { this.confirmDelete(contentEl); }); 203 204 actions.createEl("div", { cls: "atmosphere-spacer" }); 205 206 actions.createEl("button", { text: "Cancel", cls: "atmosphere-btn atmosphere-btn-secondary" }) 207 .addEventListener("click", () => { this.close(); }); 208 209 actions.createEl("button", { text: "Save", cls: "atmosphere-btn atmosphere-btn-primary" }) 210 .addEventListener("click", () => { void this.saveChanges(); }); 211 } 212 213 private addTagChip(container: HTMLElement, state: TagState) { 214 const item = container.createEl("label", { cls: "atmosphere-tag-item" }); 215 const checkbox = item.createEl("input", { type: "checkbox" }); 216 checkbox.checked = state.isSelected; 217 checkbox.addEventListener("change", () => { state.isSelected = checkbox.checked; }); 218 item.createEl("span", { text: state.tag }); 219 } 220 221 private confirmDelete(contentEl: HTMLElement) { 222 contentEl.empty(); 223 contentEl.createEl("h2", { text: "Delete item" }); 224 contentEl.createEl("p", { text: "Are you sure you want to delete this item?", cls: "atmosphere-warning-text" }); 225 226 const actions = contentEl.createEl("div", { cls: "atmosphere-modal-actions" }); 227 actions.createEl("button", { text: "Cancel", cls: "atmosphere-btn atmosphere-btn-secondary" }) 228 .addEventListener("click", () => { void this.onOpen(); }); 229 actions.createEl("button", { text: "Delete", cls: "atmosphere-btn atmosphere-btn-danger" }) 230 .addEventListener("click", () => { void this.handleDelete(); }); 231 } 232 233 private async handleDelete() { 234 const { contentEl } = this; 235 contentEl.empty(); 236 contentEl.createEl("p", { text: "Deleting..." }); 237 238 try { 239 await this.itemSource.deleteItem!(this.item.getUri()); 240 new Notice("Deleted"); 241 this.close(); 242 this.onSuccess?.(); 243 } catch (err) { 244 contentEl.empty(); 245 const message = err instanceof Error ? err.message : String(err); 246 contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "atmosphere-error" }); 247 } 248 } 249 250 private async saveChanges() { 251 if (!this.plugin.client) return; 252 253 // Read pending tag input before clearing DOM 254 const pendingNewTag = this.newTagInput?.value.trim(); 255 256 const { contentEl } = this; 257 contentEl.empty(); 258 contentEl.createEl("p", { text: "Saving..." }); 259 260 try { 261 const messages: string[] = []; 262 263 if (this.item.canAddTags() && this.itemSource.updateTags) { 264 const selectedTags = this.tagStates.filter(s => s.isSelected).map(s => s.tag); 265 if (pendingNewTag && !selectedTags.includes(pendingNewTag)) { 266 selectedTags.push(pendingNewTag); 267 } 268 await this.itemSource.updateTags(this.item.getUri(), [...new Set(selectedTags)]); 269 messages.push("Tags updated"); 270 } 271 272 const toAdd = this.collectionStates.filter(s => s.isSelected && !s.wasSelected); 273 const toRemove = this.collectionStates.filter(s => !s.isSelected && s.wasSelected); 274 275 for (const state of toRemove) { 276 if (state.linkUri) { 277 const source = state.source === "semble" ? this.sembleSource : this.marginSource; 278 await source.removeFromCollection(state.linkUri); 279 } 280 } 281 282 for (const state of toAdd) { 283 const source = state.source === "semble" ? this.sembleSource : this.marginSource; 284 await source.addToCollection(this.item.getUri(), this.item.getCid(), state.uri); 285 } 286 287 if (toAdd.length > 0) messages.push(`Added to ${toAdd.length} collection${toAdd.length > 1 ? "s" : ""}`); 288 if (toRemove.length > 0) messages.push(`Removed from ${toRemove.length} collection${toRemove.length > 1 ? "s" : ""}`); 289 290 new Notice(messages.length > 0 ? messages.join(". ") : "Saved"); 291 this.close(); 292 this.onSuccess?.(); 293 } catch (err) { 294 contentEl.empty(); 295 const message = err instanceof Error ? err.message : String(err); 296 contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "atmosphere-error" }); 297 } 298 } 299 300 onClose() { 301 this.contentEl.empty(); 302 } 303}