AT protocol bookmarking platforms in obsidian
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}