Various AT Protocol integrations with obsidian
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}