AT protocol bookmarking platforms in obsidian
at client-cache 238 lines 6.9 kB view raw
1import { Modal, Notice } from "obsidian"; 2import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3import type { Main as Bookmark } from "../lexicons/types/community/lexicon/bookmarks/bookmark"; 4import type ATmarkPlugin from "../main"; 5import { putRecord, deleteRecord, getBookmarks } from "../lib"; 6 7type BookmarkRecord = Record & { value: Bookmark }; 8 9interface TagState { 10 tag: string; 11 isSelected: boolean; 12} 13 14export class EditBookmarkModal extends Modal { 15 plugin: ATmarkPlugin; 16 record: BookmarkRecord; 17 onSuccess?: () => void; 18 tagStates: TagState[] = []; 19 newTagInput: HTMLInputElement | null = null; 20 21 constructor(plugin: ATmarkPlugin, record: BookmarkRecord, onSuccess?: () => void) { 22 super(plugin.app); 23 this.plugin = plugin; 24 this.record = record; 25 this.onSuccess = onSuccess; 26 } 27 28 async onOpen() { 29 const { contentEl } = this; 30 contentEl.empty(); 31 contentEl.addClass("atmark-modal"); 32 33 contentEl.createEl("h2", { text: "Edit bookmark" }); 34 35 if (!this.plugin.client) { 36 contentEl.createEl("p", { text: "Not connected." }); 37 return; 38 } 39 40 const loading = contentEl.createEl("p", { text: "Loading..." }); 41 42 try { 43 const bookmarksResp = await getBookmarks(this.plugin.client, this.plugin.settings.identifier); 44 loading.remove(); 45 46 const bookmarks = (bookmarksResp.ok ? bookmarksResp.data.records : []) as unknown as BookmarkRecord[]; 47 48 const allTags = new Set<string>(); 49 for (const bookmark of bookmarks) { 50 if (bookmark.value.tags) { 51 for (const tag of bookmark.value.tags) { 52 allTags.add(tag); 53 } 54 } 55 } 56 57 const currentTags = new Set(this.record.value.tags || []); 58 this.tagStates = Array.from(allTags).sort().map(tag => ({ 59 tag, 60 isSelected: currentTags.has(tag), 61 })); 62 63 this.renderForm(contentEl); 64 } catch (err) { 65 loading.remove(); 66 const message = err instanceof Error ? err.message : String(err); 67 contentEl.createEl("p", { text: `Error: ${message}`, cls: "atmark-error" }); 68 } 69 } 70 71 private renderForm(contentEl: HTMLElement) { 72 const form = contentEl.createEl("div", { cls: "atmark-form" }); 73 74 const tagsGroup = form.createEl("div", { cls: "atmark-form-group" }); 75 tagsGroup.createEl("label", { text: "Tags" }); 76 77 const tagsList = tagsGroup.createEl("div", { cls: "atmark-tag-list" }); 78 for (const state of this.tagStates) { 79 this.addTagChip(tagsList, state); 80 } 81 82 const newTagRow = tagsGroup.createEl("div", { cls: "atmark-tag-row" }); 83 this.newTagInput = newTagRow.createEl("input", { 84 type: "text", 85 cls: "atmark-input", 86 attr: { placeholder: "Add new tag..." } 87 }); 88 const addBtn = newTagRow.createEl("button", { 89 text: "Add", 90 cls: "atmark-btn atmark-btn-secondary", 91 attr: { type: "button" } 92 }); 93 addBtn.addEventListener("click", () => { 94 const value = this.newTagInput?.value.trim(); 95 if (value && !this.tagStates.some(s => s.tag === value)) { 96 const newState = { tag: value, isSelected: true }; 97 this.tagStates.push(newState); 98 this.addTagChip(tagsList, newState); 99 if (this.newTagInput) this.newTagInput.value = ""; 100 } 101 }); 102 103 const actions = contentEl.createEl("div", { cls: "atmark-modal-actions" }); 104 105 const deleteBtn = actions.createEl("button", { 106 text: "Delete", 107 cls: "atmark-btn atmark-btn-danger" 108 }); 109 deleteBtn.addEventListener("click", () => { this.confirmDelete(contentEl); }); 110 111 actions.createEl("div", { cls: "atmark-spacer" }); 112 113 const cancelBtn = actions.createEl("button", { 114 text: "Cancel", 115 cls: "atmark-btn atmark-btn-secondary" 116 }); 117 cancelBtn.addEventListener("click", () => { this.close(); }); 118 119 const saveBtn = actions.createEl("button", { 120 text: "Save", 121 cls: "atmark-btn atmark-btn-primary" 122 }); 123 saveBtn.addEventListener("click", () => { void this.saveChanges(); }); 124 } 125 126 private addTagChip(container: HTMLElement, state: TagState) { 127 const item = container.createEl("label", { cls: "atmark-tag-item" }); 128 const checkbox = item.createEl("input", { type: "checkbox" }); 129 checkbox.checked = state.isSelected; 130 checkbox.addEventListener("change", () => { 131 state.isSelected = checkbox.checked; 132 }); 133 item.createEl("span", { text: state.tag }); 134 } 135 136 private confirmDelete(contentEl: HTMLElement) { 137 contentEl.empty(); 138 contentEl.createEl("h2", { text: "Delete bookmark" }); 139 contentEl.createEl("p", { text: "Delete this bookmark?", cls: "atmark-warning-text" }); 140 141 const actions = contentEl.createEl("div", { cls: "atmark-modal-actions" }); 142 143 const cancelBtn = actions.createEl("button", { 144 text: "Cancel", 145 cls: "atmark-btn atmark-btn-secondary" 146 }); 147 cancelBtn.addEventListener("click", () => { 148 void this.onOpen(); 149 }); 150 151 const confirmBtn = actions.createEl("button", { 152 text: "Delete", 153 cls: "atmark-btn atmark-btn-danger" 154 }); 155 confirmBtn.addEventListener("click", () => { void this.deleteBookmark(); }); 156 } 157 158 private async deleteBookmark() { 159 if (!this.plugin.client) return; 160 161 const { contentEl } = this; 162 contentEl.empty(); 163 contentEl.createEl("p", { text: "Deleting bookmark..." }); 164 165 try { 166 const rkey = this.record.uri.split("/").pop(); 167 if (!rkey) { 168 contentEl.empty(); 169 contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "atmark-error" }); 170 return; 171 } 172 173 await deleteRecord( 174 this.plugin.client, 175 this.plugin.settings.identifier, 176 "community.lexicon.bookmarks.bookmark", 177 rkey 178 ); 179 180 new Notice("Bookmark deleted"); 181 this.close(); 182 this.onSuccess?.(); 183 } catch (err) { 184 contentEl.empty(); 185 const message = err instanceof Error ? err.message : String(err); 186 contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "atmark-error" }); 187 } 188 } 189 190 private async saveChanges() { 191 if (!this.plugin.client) return; 192 193 const { contentEl } = this; 194 contentEl.empty(); 195 contentEl.createEl("p", { text: "Saving changes..." }); 196 197 try { 198 const selectedTags = this.tagStates.filter(s => s.isSelected).map(s => s.tag); 199 const newTag = this.newTagInput?.value.trim(); 200 if (newTag && !selectedTags.includes(newTag)) { 201 selectedTags.push(newTag); 202 } 203 const tags = [...new Set(selectedTags)]; 204 205 const rkey = this.record.uri.split("/").pop(); 206 if (!rkey) { 207 contentEl.empty(); 208 contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "atmark-error" }); 209 return; 210 } 211 212 const updatedRecord: Bookmark = { 213 ...this.record.value, 214 tags, 215 }; 216 217 await putRecord( 218 this.plugin.client, 219 this.plugin.settings.identifier, 220 "community.lexicon.bookmarks.bookmark", 221 rkey, 222 updatedRecord 223 ); 224 225 new Notice("Tags updated"); 226 this.close(); 227 this.onSuccess?.(); 228 } catch (err) { 229 contentEl.empty(); 230 const message = err instanceof Error ? err.message : String(err); 231 contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "atmark-error" }); 232 } 233 } 234 235 onClose() { 236 this.contentEl.empty(); 237 } 238}