AT protocol bookmarking platforms in obsidian

support editing record collection/tags

+475 -9
+2 -1
README.md
··· 5 5 ## Supported platforms 6 6 7 7 - **Semble** (`network.cosmik.*`) - Collections and cards 8 + - **Bookmarks** (`community.lexicon.bookmarks.*`) - Community bookmarks lexicon with tag filtering (supports kipclip tags) 8 9 9 10 ## Installation 10 11 ··· 13 14 1. Install the BRAT plugin from Community Plugins 14 15 2. Open BRAT settings 15 16 3. Click "Add Beta plugin" 16 - 4. Enter: `treethought/obsidian-atmark` 17 + 4. Enter the GitHub URL: `https://github.com/treethought/obsidian-atmark` 17 18 5. Enable the plugin in Community Plugins 18 19 19 20 ## Configuration
+97
src/components/createTagModal.ts
··· 1 + import { Modal, Notice } from "obsidian"; 2 + import type ATmarkPlugin from "../main"; 3 + import { createTag } from "../lib"; 4 + 5 + export class CreateTagModal extends Modal { 6 + plugin: ATmarkPlugin; 7 + onSuccess?: () => void; 8 + 9 + constructor(plugin: ATmarkPlugin, onSuccess?: () => void) { 10 + super(plugin.app); 11 + this.plugin = plugin; 12 + this.onSuccess = onSuccess; 13 + } 14 + 15 + onOpen() { 16 + const { contentEl } = this; 17 + contentEl.empty(); 18 + contentEl.addClass("semble-collection-modal"); 19 + 20 + contentEl.createEl("h2", { text: "New tag" }); 21 + 22 + if (!this.plugin.client) { 23 + contentEl.createEl("p", { text: "Not connected." }); 24 + return; 25 + } 26 + 27 + const form = contentEl.createEl("form", { cls: "semble-form" }); 28 + 29 + // Tag value field 30 + const tagGroup = form.createEl("div", { cls: "semble-form-group" }); 31 + tagGroup.createEl("label", { text: "Tag", attr: { for: "tag-value" } }); 32 + const tagInput = tagGroup.createEl("input", { 33 + type: "text", 34 + cls: "semble-input", 35 + attr: { id: "tag-value", placeholder: "Tag name", required: "true" }, 36 + }); 37 + 38 + // Action buttons 39 + const actions = form.createEl("div", { cls: "semble-modal-actions" }); 40 + 41 + const cancelBtn = actions.createEl("button", { 42 + text: "Cancel", 43 + cls: "semble-btn semble-btn-secondary", 44 + type: "button", 45 + }); 46 + cancelBtn.addEventListener("click", () => this.close()); 47 + 48 + const createBtn = actions.createEl("button", { 49 + text: "Create", 50 + cls: "semble-btn semble-btn-primary", 51 + type: "submit", 52 + }); 53 + 54 + form.addEventListener("submit", (e) => { 55 + e.preventDefault(); 56 + void this.handleSubmit(tagInput, createBtn); 57 + }); 58 + 59 + // Focus tag input 60 + tagInput.focus(); 61 + } 62 + 63 + private async handleSubmit( 64 + tagInput: HTMLInputElement, 65 + createBtn: HTMLButtonElement 66 + ) { 67 + const value = tagInput.value.trim(); 68 + if (!value) { 69 + new Notice("Please enter a tag name"); 70 + return; 71 + } 72 + 73 + createBtn.disabled = true; 74 + createBtn.textContent = "Creating..."; 75 + 76 + try { 77 + await createTag( 78 + this.plugin.client!, 79 + this.plugin.settings.identifier, 80 + value 81 + ); 82 + 83 + new Notice(`Created tag "${value}"`); 84 + this.close(); 85 + this.onSuccess?.(); 86 + } catch (err) { 87 + const message = err instanceof Error ? err.message : String(err); 88 + new Notice(`Failed to create tag: ${message}`); 89 + createBtn.disabled = false; 90 + createBtn.textContent = "Create"; 91 + } 92 + } 93 + 94 + onClose() { 95 + this.contentEl.empty(); 96 + } 97 + }
+207
src/components/editBookmarkModal.ts
··· 1 + import { Modal, Notice } from "obsidian"; 2 + import type ATmarkPlugin from "../main"; 3 + import { putRecord, deleteRecord } from "../lib"; 4 + 5 + export class EditBookmarkModal extends Modal { 6 + plugin: ATmarkPlugin; 7 + record: any; 8 + onSuccess?: () => void; 9 + tagInputs: HTMLInputElement[] = []; 10 + 11 + constructor(plugin: ATmarkPlugin, record: any, onSuccess?: () => void) { 12 + super(plugin.app); 13 + this.plugin = plugin; 14 + this.record = record; 15 + this.onSuccess = onSuccess; 16 + } 17 + 18 + onOpen() { 19 + const { contentEl } = this; 20 + contentEl.empty(); 21 + contentEl.addClass("semble-collection-modal"); 22 + 23 + contentEl.createEl("h2", { text: "Edit bookmark tags" }); 24 + 25 + if (!this.plugin.client) { 26 + contentEl.createEl("p", { text: "Not connected." }); 27 + return; 28 + } 29 + 30 + const existingTags = this.record.value.tags || []; 31 + 32 + const form = contentEl.createEl("div", { cls: "semble-form" }); 33 + 34 + // Tags section 35 + const tagsGroup = form.createEl("div", { cls: "semble-form-group" }); 36 + tagsGroup.createEl("label", { text: "Tags" }); 37 + 38 + const tagsContainer = tagsGroup.createEl("div", { cls: "semble-tags-container" }); 39 + 40 + // Render existing tags 41 + for (const tag of existingTags) { 42 + this.addTagInput(tagsContainer, tag); 43 + } 44 + 45 + // Add empty input for new tag 46 + this.addTagInput(tagsContainer, ""); 47 + 48 + // Add tag button 49 + const addTagBtn = tagsGroup.createEl("button", { 50 + text: "+ Add tag", 51 + cls: "semble-btn semble-btn-secondary" 52 + }); 53 + addTagBtn.addEventListener("click", (e) => { 54 + e.preventDefault(); 55 + this.addTagInput(tagsContainer, ""); 56 + }); 57 + 58 + // Action buttons 59 + const actions = contentEl.createEl("div", { cls: "semble-modal-actions" }); 60 + 61 + const deleteBtn = actions.createEl("button", { 62 + text: "Delete", 63 + cls: "semble-btn semble-btn-danger" 64 + }); 65 + deleteBtn.addEventListener("click", () => { this.confirmDelete(contentEl); }); 66 + 67 + actions.createEl("div", { cls: "semble-spacer" }); 68 + 69 + const cancelBtn = actions.createEl("button", { 70 + text: "Cancel", 71 + cls: "semble-btn semble-btn-secondary" 72 + }); 73 + cancelBtn.addEventListener("click", () => { this.close(); }); 74 + 75 + const saveBtn = actions.createEl("button", { 76 + text: "Save", 77 + cls: "semble-btn semble-btn-primary" 78 + }); 79 + saveBtn.addEventListener("click", () => { void this.saveChanges(); }); 80 + } 81 + 82 + private addTagInput(container: HTMLElement, value: string) { 83 + const tagRow = container.createEl("div", { cls: "semble-tag-row" }); 84 + 85 + const input = tagRow.createEl("input", { 86 + type: "text", 87 + cls: "semble-input", 88 + value, 89 + attr: { placeholder: "Enter tag..." } 90 + }); 91 + this.tagInputs.push(input); 92 + 93 + const removeBtn = tagRow.createEl("button", { 94 + text: "×", 95 + cls: "semble-btn semble-btn-secondary semble-tag-remove-btn" 96 + }); 97 + removeBtn.addEventListener("click", (e) => { 98 + e.preventDefault(); 99 + tagRow.remove(); 100 + this.tagInputs = this.tagInputs.filter(i => i !== input); 101 + }); 102 + } 103 + 104 + private confirmDelete(contentEl: HTMLElement) { 105 + contentEl.empty(); 106 + contentEl.createEl("h2", { text: "Delete bookmark" }); 107 + contentEl.createEl("p", { text: "Delete this bookmark?", cls: "semble-warning-text" }); 108 + 109 + const actions = contentEl.createEl("div", { cls: "semble-modal-actions" }); 110 + 111 + const cancelBtn = actions.createEl("button", { 112 + text: "Cancel", 113 + cls: "semble-btn semble-btn-secondary" 114 + }); 115 + cancelBtn.addEventListener("click", () => { 116 + void this.onOpen(); 117 + }); 118 + 119 + const confirmBtn = actions.createEl("button", { 120 + text: "Delete", 121 + cls: "semble-btn semble-btn-danger" 122 + }); 123 + confirmBtn.addEventListener("click", () => { void this.deleteBookmark(); }); 124 + } 125 + 126 + private async deleteBookmark() { 127 + if (!this.plugin.client) return; 128 + 129 + const { contentEl } = this; 130 + contentEl.empty(); 131 + contentEl.createEl("p", { text: "Deleting bookmark..." }); 132 + 133 + try { 134 + const rkey = this.record.uri.split("/").pop(); 135 + if (!rkey) { 136 + contentEl.empty(); 137 + contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "semble-error" }); 138 + return; 139 + } 140 + 141 + await deleteRecord( 142 + this.plugin.client, 143 + this.plugin.settings.identifier, 144 + "community.lexicon.bookmarks.bookmark", 145 + rkey 146 + ); 147 + 148 + new Notice("Bookmark deleted"); 149 + this.close(); 150 + this.onSuccess?.(); 151 + } catch (err) { 152 + contentEl.empty(); 153 + const message = err instanceof Error ? err.message : String(err); 154 + contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "semble-error" }); 155 + } 156 + } 157 + 158 + private async saveChanges() { 159 + if (!this.plugin.client) return; 160 + 161 + const { contentEl } = this; 162 + contentEl.empty(); 163 + contentEl.createEl("p", { text: "Saving changes..." }); 164 + 165 + try { 166 + // Get non-empty unique tags 167 + const tags = [...new Set( 168 + this.tagInputs 169 + .map(input => input.value.trim()) 170 + .filter(tag => tag.length > 0) 171 + )]; 172 + 173 + const rkey = this.record.uri.split("/").pop(); 174 + if (!rkey) { 175 + contentEl.empty(); 176 + contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "semble-error" }); 177 + return; 178 + } 179 + 180 + // Update the record with new tags 181 + const updatedRecord = { 182 + ...this.record.value, 183 + tags, 184 + }; 185 + 186 + await putRecord( 187 + this.plugin.client, 188 + this.plugin.settings.identifier, 189 + "community.lexicon.bookmarks.bookmark", 190 + rkey, 191 + updatedRecord 192 + ); 193 + 194 + new Notice("Tags updated"); 195 + this.close(); 196 + this.onSuccess?.(); 197 + } catch (err) { 198 + contentEl.empty(); 199 + const message = err instanceof Error ? err.message : String(err); 200 + contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "semble-error" }); 201 + } 202 + } 203 + 204 + onClose() { 205 + this.contentEl.empty(); 206 + } 207 + }
+1 -1
src/lib.ts
··· 1 - export { getRecord, deleteRecord, getProfile } from "./lib/atproto"; 1 + export { getRecord, deleteRecord, putRecord, getProfile } from "./lib/atproto"; 2 2 3 3 export { 4 4 getSembleCollections as getCollections,
+11
src/lib/atproto.ts
··· 21 21 }); 22 22 } 23 23 24 + export async function putRecord(client: Client, repo: string, collection: string, rkey: string, record: any) { 25 + return await client.post("com.atproto.repo.putRecord", { 26 + input: { 27 + repo: repo as ActorIdentifier, 28 + collection: collection as Nsid, 29 + rkey, 30 + record, 31 + }, 32 + }); 33 + } 34 + 24 35 export async function getProfile(client: Client, actor: string) { 25 36 return await client.get("app.bsky.actor.getProfile", { 26 37 params: { actor: actor as ActorIdentifier },
+21 -2
src/sources/bookmark.ts
··· 1 1 import type { Client } from "@atcute/client"; 2 + import { setIcon } from "obsidian"; 2 3 import type ATmarkPlugin from "../main"; 3 4 import { getBookmarks } from "../lib"; 4 5 import type { ATmarkItem, DataSource, SourceFilter } from "./types"; ··· 30 31 31 32 canAddNotes(): boolean { 32 33 return false; 34 + } 35 + 36 + canEdit(): boolean { 37 + return true; 38 + } 39 + 40 + openEditModal(onSuccess?: () => void): void { 41 + const { EditBookmarkModal } = require("../components/editBookmarkModal"); 42 + new EditBookmarkModal(this.plugin, this.record, onSuccess).open(); 33 43 } 34 44 35 45 render(container: HTMLElement): void { ··· 183 193 })); 184 194 } 185 195 186 - renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void): void { 196 + renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void, plugin: ATmarkPlugin): void { 187 197 const section = container.createEl("div", { cls: "atmark-filter-section" }); 188 - section.createEl("h3", { text: "Tags", cls: "atmark-filter-title" }); 198 + 199 + const titleRow = section.createEl("div", { cls: "atmark-filter-title-row" }); 200 + titleRow.createEl("h3", { text: "Tags", cls: "atmark-filter-title" }); 201 + 202 + const createBtn = titleRow.createEl("button", { cls: "atmark-filter-create-btn" }); 203 + setIcon(createBtn, "plus"); 204 + createBtn.addEventListener("click", () => { 205 + const { CreateTagModal } = require("../components/createTagModal"); 206 + new CreateTagModal(plugin, onChange).open(); 207 + }); 189 208 190 209 const chips = section.createEl("div", { cls: "atmark-filter-chips" }); 191 210
+20 -2
src/sources/semble.ts
··· 36 36 return true; 37 37 } 38 38 39 + canEdit(): boolean { 40 + return true; 41 + } 42 + 43 + openEditModal(onSuccess?: () => void): void { 44 + const { EditCardModal } = require("../components/editCardModal"); 45 + new EditCardModal(this.plugin, this.record.uri, this.record.cid, onSuccess).open(); 46 + } 47 + 39 48 render(container: HTMLElement): void { 40 49 const el = container.createEl("div", { cls: "semble-card-content" }); 41 50 ··· 223 232 })); 224 233 } 225 234 226 - renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void): void { 235 + renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void, plugin: ATmarkPlugin): void { 227 236 const section = container.createEl("div", { cls: "atmark-filter-section" }); 228 - section.createEl("h3", { text: "Semble Collections", cls: "atmark-filter-title" }); 237 + 238 + const titleRow = section.createEl("div", { cls: "atmark-filter-title-row" }); 239 + titleRow.createEl("h3", { text: "Semble Collections", cls: "atmark-filter-title" }); 240 + 241 + const createBtn = titleRow.createEl("button", { cls: "atmark-filter-create-btn" }); 242 + setIcon(createBtn, "plus"); 243 + createBtn.addEventListener("click", () => { 244 + const { CreateCollectionModal } = require("../components/createCollectionModal"); 245 + new CreateCollectionModal(plugin, onChange).open(); 246 + }); 229 247 230 248 const chips = section.createEl("div", { cls: "atmark-filter-chips" }); 231 249
+3 -1
src/sources/types.ts
··· 4 4 render(container: HTMLElement): void; 5 5 renderDetail(container: HTMLElement): void; 6 6 canAddNotes(): boolean; 7 + canEdit(): boolean; 8 + openEditModal(onSuccess?: () => void): void; 7 9 getUri(): string; 8 10 getCid(): string; 9 11 getCreatedAt(): string; ··· 19 21 readonly name: "semble" | "bookmark"; 20 22 fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]>; 21 23 getAvailableFilters(): Promise<SourceFilter[]>; 22 - renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void): void; 24 + renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void, plugin: ATmarkPlugin): void; 23 25 }
+21 -2
src/views/atmark.ts
··· 138 138 sourceData.source.renderFilterUI( 139 139 filtersContainer, 140 140 sourceData.filters, 141 - () => void this.render() 141 + () => void this.render(), 142 + this.plugin 142 143 ); 143 144 } 144 145 } ··· 146 147 private renderItem(container: HTMLElement, item: ATmarkItem) { 147 148 const el = container.createEl("div", { cls: "atmark-item" }); 148 149 149 - el.addEventListener("click", () => { 150 + el.addEventListener("click", (e) => { 151 + // Don't open detail if clicking the edit button 152 + if ((e.target as HTMLElement).closest(".atmark-item-edit-btn")) { 153 + return; 154 + } 150 155 new CardDetailModal(this.plugin, item, () => { 151 156 void this.render(); 152 157 }).open(); ··· 158 163 text: source, 159 164 cls: `atmark-badge atmark-badge-${source}`, 160 165 }); 166 + 167 + // Add edit button if item supports it 168 + if (item.canEdit()) { 169 + const editBtn = header.createEl("button", { 170 + cls: "atmark-item-edit-btn", 171 + }); 172 + setIcon(editBtn, "more-vertical"); 173 + editBtn.addEventListener("click", (e) => { 174 + e.stopPropagation(); 175 + item.openEditModal(() => { 176 + void this.render(); 177 + }); 178 + }); 179 + } 161 180 162 181 item.render(el); 163 182
+92
styles.css
··· 84 84 gap: 8px; 85 85 } 86 86 87 + .atmark-filter-title-row { 88 + display: flex; 89 + align-items: center; 90 + justify-content: space-between; 91 + gap: 8px; 92 + } 93 + 87 94 .atmark-filter-title { 88 95 margin: 0; 89 96 font-size: var(--font-small); ··· 91 98 color: var(--text-muted); 92 99 text-transform: uppercase; 93 100 letter-spacing: 0.5px; 101 + } 102 + 103 + .atmark-filter-create-btn { 104 + display: flex; 105 + align-items: center; 106 + justify-content: center; 107 + width: 24px; 108 + height: 24px; 109 + padding: 0; 110 + background: transparent; 111 + border: 1px solid var(--background-modifier-border); 112 + border-radius: var(--radius-s); 113 + cursor: pointer; 114 + color: var(--text-muted); 115 + transition: all 0.15s ease; 116 + } 117 + 118 + .atmark-filter-create-btn:hover { 119 + background: var(--background-modifier-hover); 120 + color: var(--text-normal); 121 + border-color: var(--background-modifier-border-hover); 122 + } 123 + 124 + .atmark-filter-create-btn svg { 125 + width: 14px; 126 + height: 14px; 94 127 } 95 128 96 129 .atmark-filter-chips { ··· 154 187 justify-content: space-between; 155 188 align-items: flex-start; 156 189 gap: 8px; 190 + } 191 + 192 + .atmark-item-edit-btn { 193 + display: flex; 194 + align-items: center; 195 + justify-content: center; 196 + width: 24px; 197 + height: 24px; 198 + padding: 0; 199 + margin-left: auto; 200 + background: transparent; 201 + border: none; 202 + border-radius: var(--radius-s); 203 + cursor: pointer; 204 + color: var(--text-faint); 205 + opacity: 0.6; 206 + transition: all 0.15s ease; 207 + } 208 + 209 + .atmark-item:hover .atmark-item-edit-btn { 210 + opacity: 1; 211 + } 212 + 213 + .atmark-item-edit-btn:hover { 214 + background: var(--background-modifier-hover); 215 + color: var(--text-normal); 216 + opacity: 1; 217 + } 218 + 219 + .atmark-item-edit-btn svg { 220 + width: 14px; 221 + height: 14px; 157 222 } 158 223 159 224 .atmark-badge { ··· 1035 1100 .semble-add-note-form .semble-btn { 1036 1101 align-self: flex-end; 1037 1102 } 1103 + 1104 + /* Tag editing */ 1105 + .semble-tags-container { 1106 + display: flex; 1107 + flex-direction: column; 1108 + gap: 8px; 1109 + margin-bottom: 8px; 1110 + } 1111 + 1112 + .semble-tag-row { 1113 + display: flex; 1114 + align-items: center; 1115 + gap: 8px; 1116 + } 1117 + 1118 + .semble-tag-row .semble-input { 1119 + flex: 1; 1120 + } 1121 + 1122 + .semble-tag-remove-btn { 1123 + width: 32px; 1124 + height: 32px; 1125 + padding: 0; 1126 + font-size: 20px; 1127 + line-height: 1; 1128 + flex-shrink: 0; 1129 + }