Various AT Protocol integrations with obsidian
22
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}