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 } from "../lib";
6
7type BookmarkRecord = Record & { value: Bookmark };
8
9export class EditBookmarkModal extends Modal {
10 plugin: ATmarkPlugin;
11 record: BookmarkRecord;
12 onSuccess?: () => void;
13 tagInputs: HTMLInputElement[] = [];
14
15 constructor(plugin: ATmarkPlugin, record: BookmarkRecord, onSuccess?: () => void) {
16 super(plugin.app);
17 this.plugin = plugin;
18 this.record = record;
19 this.onSuccess = onSuccess;
20 }
21
22 onOpen() {
23 const { contentEl } = this;
24 contentEl.empty();
25 contentEl.addClass("atmark-modal");
26
27 contentEl.createEl("h2", { text: "Edit bookmark tags" });
28
29 if (!this.plugin.client) {
30 contentEl.createEl("p", { text: "Not connected." });
31 return;
32 }
33
34 const existingTags = this.record.value.tags || [];
35
36 const form = contentEl.createEl("div", { cls: "atmark-form" });
37
38 // Tags section
39 const tagsGroup = form.createEl("div", { cls: "atmark-form-group" });
40 tagsGroup.createEl("label", { text: "Tags" });
41
42 const tagsContainer = tagsGroup.createEl("div", { cls: "atmark-tags-container" });
43
44 // Render existing tags
45 for (const tag of existingTags) {
46 this.addTagInput(tagsContainer, tag);
47 }
48
49 // Add empty input for new tag
50 this.addTagInput(tagsContainer, "");
51
52 // Add tag button
53 const addTagBtn = tagsGroup.createEl("button", {
54 text: "Add tag",
55 cls: "atmark-btn atmark-btn-secondary"
56 });
57 addTagBtn.addEventListener("click", (e) => {
58 e.preventDefault();
59 this.addTagInput(tagsContainer, "");
60 });
61
62 // Action buttons
63 const actions = contentEl.createEl("div", { cls: "atmark-modal-actions" });
64
65 const deleteBtn = actions.createEl("button", {
66 text: "Delete",
67 cls: "atmark-btn atmark-btn-danger"
68 });
69 deleteBtn.addEventListener("click", () => { this.confirmDelete(contentEl); });
70
71 actions.createEl("div", { cls: "atmark-spacer" });
72
73 const cancelBtn = actions.createEl("button", {
74 text: "Cancel",
75 cls: "atmark-btn atmark-btn-secondary"
76 });
77 cancelBtn.addEventListener("click", () => { this.close(); });
78
79 const saveBtn = actions.createEl("button", {
80 text: "Save",
81 cls: "atmark-btn atmark-btn-primary"
82 });
83 saveBtn.addEventListener("click", () => { void this.saveChanges(); });
84 }
85
86 private addTagInput(container: HTMLElement, value: string) {
87 const tagRow = container.createEl("div", { cls: "atmark-tag-row" });
88
89 const input = tagRow.createEl("input", {
90 type: "text",
91 cls: "atmark-input",
92 value,
93 attr: { placeholder: "Enter tag..." }
94 });
95 this.tagInputs.push(input);
96
97 const removeBtn = tagRow.createEl("button", {
98 text: "×",
99 cls: "atmark-btn atmark-btn-secondary atmark-tag-remove-btn"
100 });
101 removeBtn.addEventListener("click", (e) => {
102 e.preventDefault();
103 tagRow.remove();
104 this.tagInputs = this.tagInputs.filter(i => i !== input);
105 });
106 }
107
108 private confirmDelete(contentEl: HTMLElement) {
109 contentEl.empty();
110 contentEl.createEl("h2", { text: "Delete bookmark" });
111 contentEl.createEl("p", { text: "Delete this bookmark?", cls: "atmark-warning-text" });
112
113 const actions = contentEl.createEl("div", { cls: "atmark-modal-actions" });
114
115 const cancelBtn = actions.createEl("button", {
116 text: "Cancel",
117 cls: "atmark-btn atmark-btn-secondary"
118 });
119 cancelBtn.addEventListener("click", () => {
120 void this.onOpen();
121 });
122
123 const confirmBtn = actions.createEl("button", {
124 text: "Delete",
125 cls: "atmark-btn atmark-btn-danger"
126 });
127 confirmBtn.addEventListener("click", () => { void this.deleteBookmark(); });
128 }
129
130 private async deleteBookmark() {
131 if (!this.plugin.client) return;
132
133 const { contentEl } = this;
134 contentEl.empty();
135 contentEl.createEl("p", { text: "Deleting bookmark..." });
136
137 try {
138 const rkey = this.record.uri.split("/").pop();
139 if (!rkey) {
140 contentEl.empty();
141 contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "atmark-error" });
142 return;
143 }
144
145 await deleteRecord(
146 this.plugin.client,
147 this.plugin.settings.identifier,
148 "community.lexicon.bookmarks.bookmark",
149 rkey
150 );
151
152 new Notice("Bookmark deleted");
153 this.close();
154 this.onSuccess?.();
155 } catch (err) {
156 contentEl.empty();
157 const message = err instanceof Error ? err.message : String(err);
158 contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "atmark-error" });
159 }
160 }
161
162 private async saveChanges() {
163 if (!this.plugin.client) return;
164
165 const { contentEl } = this;
166 contentEl.empty();
167 contentEl.createEl("p", { text: "Saving changes..." });
168
169 try {
170 // Get non-empty unique tags
171 const tags = [...new Set(
172 this.tagInputs
173 .map(input => input.value.trim())
174 .filter(tag => tag.length > 0)
175 )];
176
177 const rkey = this.record.uri.split("/").pop();
178 if (!rkey) {
179 contentEl.empty();
180 contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "atmark-error" });
181 return;
182 }
183
184 // Update the record with new tags
185 const updatedRecord: Bookmark = {
186 ...this.record.value,
187 tags,
188 };
189
190 await putRecord(
191 this.plugin.client,
192 this.plugin.settings.identifier,
193 "community.lexicon.bookmarks.bookmark",
194 rkey,
195 updatedRecord
196 );
197
198 new Notice("Tags updated");
199 this.close();
200 this.onSuccess?.();
201 } catch (err) {
202 contentEl.empty();
203 const message = err instanceof Error ? err.message : String(err);
204 contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "atmark-error" });
205 }
206 }
207
208 onClose() {
209 this.contentEl.empty();
210 }
211}