Various AT Protocol integrations with obsidian
20
fork

Configure Feed

Select the types of activity you want to include in your feed.

unify edit modal across sources (#30)

* unify edit modal, support adding to other osurce collections

* open edit modal from view

* add community to scope

authored by

treethought and committed by
GitHub
2530df67 a250ea5a

+377 -847
+1 -1
client-metadata.json
··· 5 5 "redirect_uris": [ 6 6 "https://treethought.github.io/obsidian-atmosphere/oauth-callback.html" 7 7 ], 8 - "scope": "atproto include:at.margin.authFull repo:site.standard.document repo:network.cosmik.card repo:network.cosmik.collection repo:network.cosmik.collectionLink", 8 + "scope": "atproto include:at.margin.authFull repo:site.standard.document repo:network.cosmik.card repo:network.cosmik.collection repo:network.cosmik.collectionLink, repo:community.lexicon.bookmarks.bookmark", 9 9 "grant_types": ["authorization_code", "refresh_token"], 10 10 "response_types": ["code"], 11 11 "token_endpoint_auth_method": "none",
+2 -1
src/components/cardDetailModal.ts
··· 2 2 import type AtmospherePlugin from "../main"; 3 3 import { createSembleNote, deleteRecord } from "../lib"; 4 4 import type { ATBookmarkItem } from "../sources/types"; 5 + import { EditItemModal } from "./editItemModal"; 5 6 6 7 export class CardDetailModal extends Modal { 7 8 plugin: AtmospherePlugin; ··· 55 56 setIcon(editBtn, "pencil"); 56 57 editBtn.addEventListener("click", () => { 57 58 this.close(); 58 - this.item.openEditModal(this.onSuccess); 59 + new EditItemModal(this.plugin, this.item, this.onSuccess).open(); 59 60 }); 60 61 } 61 62 }
-238
src/components/editBookmarkModal.ts
··· 1 - import { Modal, Notice } from "obsidian"; 2 - import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 - import type { Main as Bookmark } from "../lexicons/types/community/lexicon/bookmarks/bookmark"; 4 - import type AtmospherePlugin from "../main"; 5 - import { putRecord, deleteRecord, getBookmarks } from "../lib"; 6 - 7 - type BookmarkRecord = Record & { value: Bookmark }; 8 - 9 - interface TagState { 10 - tag: string; 11 - isSelected: boolean; 12 - } 13 - 14 - export class EditBookmarkModal extends Modal { 15 - plugin: AtmospherePlugin; 16 - record: BookmarkRecord; 17 - onSuccess?: () => void; 18 - tagStates: TagState[] = []; 19 - newTagInput: HTMLInputElement | null = null; 20 - 21 - constructor(plugin: AtmospherePlugin, 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("atmosphere-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.did!); 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: "atmosphere-error" }); 68 - } 69 - } 70 - 71 - private renderForm(contentEl: HTMLElement) { 72 - const form = contentEl.createEl("div", { cls: "atmosphere-form" }); 73 - 74 - const tagsGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 75 - tagsGroup.createEl("label", { text: "Tags" }); 76 - 77 - const tagsList = tagsGroup.createEl("div", { cls: "atmosphere-tag-list" }); 78 - for (const state of this.tagStates) { 79 - this.addTagChip(tagsList, state); 80 - } 81 - 82 - const newTagRow = tagsGroup.createEl("div", { cls: "atmosphere-tag-row" }); 83 - this.newTagInput = newTagRow.createEl("input", { 84 - type: "text", 85 - cls: "atmosphere-input", 86 - attr: { placeholder: "Add new tag..." } 87 - }); 88 - const addBtn = newTagRow.createEl("button", { 89 - text: "Add", 90 - cls: "atmosphere-btn atmosphere-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: "atmosphere-modal-actions" }); 104 - 105 - const deleteBtn = actions.createEl("button", { 106 - text: "Delete", 107 - cls: "atmosphere-btn atmosphere-btn-danger" 108 - }); 109 - deleteBtn.addEventListener("click", () => { this.confirmDelete(contentEl); }); 110 - 111 - actions.createEl("div", { cls: "atmosphere-spacer" }); 112 - 113 - const cancelBtn = actions.createEl("button", { 114 - text: "Cancel", 115 - cls: "atmosphere-btn atmosphere-btn-secondary" 116 - }); 117 - cancelBtn.addEventListener("click", () => { this.close(); }); 118 - 119 - const saveBtn = actions.createEl("button", { 120 - text: "Save", 121 - cls: "atmosphere-btn atmosphere-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: "atmosphere-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: "atmosphere-warning-text" }); 140 - 141 - const actions = contentEl.createEl("div", { cls: "atmosphere-modal-actions" }); 142 - 143 - const cancelBtn = actions.createEl("button", { 144 - text: "Cancel", 145 - cls: "atmosphere-btn atmosphere-btn-secondary" 146 - }); 147 - cancelBtn.addEventListener("click", () => { 148 - void this.onOpen(); 149 - }); 150 - 151 - const confirmBtn = actions.createEl("button", { 152 - text: "Delete", 153 - cls: "atmosphere-btn atmosphere-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: "atmosphere-error" }); 170 - return; 171 - } 172 - 173 - await deleteRecord( 174 - this.plugin.client, 175 - this.plugin.settings.did!, 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: "atmosphere-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: "atmosphere-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.did!, 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: "atmosphere-error" }); 232 - } 233 - } 234 - 235 - onClose() { 236 - this.contentEl.empty(); 237 - } 238 - }
-258
src/components/editCardModal.ts
··· 1 - import { Modal, Notice } from "obsidian"; 2 - import type AtmospherePlugin from "../main"; 3 - import type { Main as Collection } from "../lexicons/types/network/cosmik/collection"; 4 - import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink"; 5 - import {getSembleCollections, getSembleCollectionLinks, deleteRecord, getRecord, createSembleCollectionLink} from "../lib"; 6 - 7 - interface CollectionRecord { 8 - uri: string; 9 - cid: string; 10 - value: Collection; 11 - } 12 - 13 - interface CollectionLinkRecord { 14 - uri: string; 15 - value: CollectionLink; 16 - } 17 - 18 - interface CollectionState { 19 - collection: CollectionRecord; 20 - isSelected: boolean; 21 - wasSelected: boolean; // Original state to track changes 22 - linkUri?: string; // URI of existing link (for deletion) 23 - } 24 - 25 - export class EditCardModal extends Modal { 26 - plugin: AtmospherePlugin; 27 - cardUri: string; 28 - cardCid: string; 29 - onSuccess?: () => void; 30 - collectionStates: CollectionState[] = []; 31 - 32 - constructor(plugin: AtmospherePlugin, cardUri: string, cardCid: string, onSuccess?: () => void) { 33 - super(plugin.app); 34 - this.plugin = plugin; 35 - this.cardUri = cardUri; 36 - this.cardCid = cardCid; 37 - this.onSuccess = onSuccess; 38 - } 39 - 40 - async onOpen() { 41 - const { contentEl } = this; 42 - contentEl.empty(); 43 - contentEl.addClass("atmosphere-modal"); 44 - 45 - contentEl.createEl("h2", { text: "Edit collections" }); 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 [collectionsResp, linksResp] = await Promise.all([ 56 - getSembleCollections(this.plugin.client, this.plugin.settings.did!), 57 - getSembleCollectionLinks(this.plugin.client, this.plugin.settings.did!), 58 - ]); 59 - 60 - loading.remove(); 61 - 62 - if (!collectionsResp.ok) { 63 - contentEl.createEl("p", { text: "Failed to load collections.", cls: "atmosphere-error" }); 64 - return; 65 - } 66 - 67 - const collections = collectionsResp.data.records as unknown as CollectionRecord[]; 68 - const links = (linksResp.ok ? linksResp.data.records : []) as unknown as CollectionLinkRecord[]; 69 - 70 - if (collections.length === 0) { 71 - contentEl.createEl("p", { text: "No collections found. Create a collection first." }); 72 - return; 73 - } 74 - 75 - const cardLinks = links.filter(link => link.value.card.uri === this.cardUri); 76 - const linkedCollectionUris = new Map<string, string>(); 77 - for (const link of cardLinks) { 78 - linkedCollectionUris.set(link.value.collection.uri, link.uri); 79 - } 80 - 81 - this.collectionStates = collections.map(collection => ({ 82 - collection, 83 - isSelected: linkedCollectionUris.has(collection.uri), 84 - wasSelected: linkedCollectionUris.has(collection.uri), 85 - linkUri: linkedCollectionUris.get(collection.uri), 86 - })); 87 - 88 - this.renderCollectionList(contentEl); 89 - } catch (err) { 90 - loading.remove(); 91 - const message = err instanceof Error ? err.message : String(err); 92 - contentEl.createEl("p", { text: `Error: ${message}`, cls: "atmosphere-error" }); 93 - } 94 - } 95 - 96 - private renderCollectionList(contentEl: HTMLElement) { 97 - const list = contentEl.createEl("div", { cls: "atmosphere-collection-list" }); 98 - 99 - for (const state of this.collectionStates) { 100 - const item = list.createEl("label", { cls: "atmosphere-collection-item" }); 101 - 102 - const checkbox = item.createEl("input", { type: "checkbox", cls: "atmosphere-collection-checkbox" }); 103 - checkbox.checked = state.isSelected; 104 - checkbox.addEventListener("change", () => { 105 - state.isSelected = checkbox.checked; 106 - this.updateSaveButton(); 107 - }); 108 - 109 - const info = item.createEl("div", { cls: "atmosphere-collection-item-info" }); 110 - info.createEl("span", { text: state.collection.value.name, cls: "atmosphere-collection-item-name" }); 111 - if (state.collection.value.description) { 112 - info.createEl("span", { text: state.collection.value.description, cls: "atmosphere-collection-item-desc" }); 113 - } 114 - } 115 - 116 - const actions = contentEl.createEl("div", { cls: "atmosphere-modal-actions" }); 117 - 118 - const deleteBtn = actions.createEl("button", { text: "Delete", cls: "atmosphere-btn atmosphere-btn-danger" }); 119 - deleteBtn.addEventListener("click", () => { this.confirmDelete(contentEl); }); 120 - 121 - actions.createEl("div", { cls: "atmosphere-spacer" }); 122 - 123 - const cancelBtn = actions.createEl("button", { text: "Cancel", cls: "atmosphere-btn atmosphere-btn-secondary" }); 124 - cancelBtn.addEventListener("click", () => { this.close(); }); 125 - 126 - const saveBtn = actions.createEl("button", { text: "Save", cls: "atmosphere-btn atmosphere-btn-primary" }); 127 - saveBtn.id = "atmosphere-save-btn"; 128 - saveBtn.disabled = true; 129 - saveBtn.addEventListener("click", () => { void this.saveChanges(); }); 130 - } 131 - 132 - private confirmDelete(contentEl: HTMLElement) { 133 - contentEl.empty(); 134 - contentEl.createEl("h2", { text: "Delete card" }); 135 - contentEl.createEl("p", { text: "Delete this card?", cls: "atmosphere-warning-text" }); 136 - 137 - const actions = contentEl.createEl("div", { cls: "atmosphere-modal-actions" }); 138 - 139 - const cancelBtn = actions.createEl("button", { text: "Cancel", cls: "atmosphere-btn atmosphere-btn-secondary" }); 140 - cancelBtn.addEventListener("click", () => { 141 - void this.onOpen(); 142 - }); 143 - 144 - const confirmBtn = actions.createEl("button", { text: "Delete", cls: "atmosphere-btn atmosphere-btn-danger" }); 145 - confirmBtn.addEventListener("click", () => { void this.deleteCard(); }); 146 - } 147 - 148 - private async deleteCard() { 149 - if (!this.plugin.client) return; 150 - 151 - const { contentEl } = this; 152 - contentEl.empty(); 153 - contentEl.createEl("p", { text: "Deleting card..." }); 154 - 155 - try { 156 - const rkey = this.cardUri.split("/").pop(); 157 - if (!rkey) { 158 - contentEl.empty(); 159 - contentEl.createEl("p", { text: "Invalid card uri.", cls: "atmosphere-error" }); 160 - return; 161 - } 162 - 163 - await deleteRecord( 164 - this.plugin.client, 165 - this.plugin.settings.did!, 166 - "network.cosmik.card", 167 - rkey 168 - ); 169 - 170 - new Notice("Card deleted"); 171 - this.close(); 172 - this.onSuccess?.(); 173 - } catch (err) { 174 - contentEl.empty(); 175 - const message = err instanceof Error ? err.message : String(err); 176 - contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "atmosphere-error" }); 177 - } 178 - } 179 - 180 - private updateSaveButton() { 181 - const saveBtn = document.getElementById("atmosphere-save-btn") as HTMLButtonElement | null; 182 - if (!saveBtn) return; 183 - 184 - const hasChanges = this.collectionStates.some(s => s.isSelected !== s.wasSelected); 185 - saveBtn.disabled = !hasChanges; 186 - } 187 - 188 - private async saveChanges() { 189 - if (!this.plugin.client) return; 190 - 191 - const { contentEl } = this; 192 - contentEl.empty(); 193 - contentEl.createEl("p", { text: "Saving changes..." }); 194 - 195 - try { 196 - const toAdd = this.collectionStates.filter(s => s.isSelected && !s.wasSelected); 197 - const toRemove = this.collectionStates.filter(s => !s.isSelected && s.wasSelected); 198 - 199 - for (const state of toRemove) { 200 - if (state.linkUri) { 201 - const rkey = state.linkUri.split("/").pop(); 202 - if (rkey) { 203 - await deleteRecord( 204 - this.plugin.client, 205 - this.plugin.settings.did!, 206 - "network.cosmik.collectionLink", 207 - rkey 208 - ); 209 - } 210 - } 211 - } 212 - 213 - for (const state of toAdd) { 214 - const collectionRkey = state.collection.uri.split("/").pop(); 215 - if (!collectionRkey) continue; 216 - 217 - const collectionResp = await getRecord( 218 - this.plugin.client, 219 - this.plugin.settings.did!, 220 - "network.cosmik.collection", 221 - collectionRkey 222 - ); 223 - 224 - if (!collectionResp.ok || !collectionResp.data.cid) continue; 225 - 226 - await createSembleCollectionLink( 227 - this.plugin.client, 228 - this.plugin.settings.did!, 229 - this.cardUri, 230 - this.cardCid, 231 - state.collection.uri, 232 - String(collectionResp.data.cid) 233 - ); 234 - } 235 - 236 - const addedCount = toAdd.length; 237 - const removedCount = toRemove.length; 238 - const messages: string[] = []; 239 - if (addedCount > 0) messages.push(`Added to ${addedCount} collection${addedCount > 1 ? "s" : ""}`); 240 - if (removedCount > 0) messages.push(`Removed from ${removedCount} collection${removedCount > 1 ? "s" : ""}`); 241 - 242 - if (messages.length > 0) { 243 - new Notice(messages.join(". ")); 244 - } 245 - 246 - this.close(); 247 - this.onSuccess?.(); 248 - } catch (err) { 249 - contentEl.empty(); 250 - const message = err instanceof Error ? err.message : String(err); 251 - contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "atmosphere-error" }); 252 - } 253 - } 254 - 255 - onClose() { 256 - this.contentEl.empty(); 257 - } 258 - }
+286
src/components/editItemModal.ts
··· 1 + import { Modal, Notice, setIcon } from "obsidian"; 2 + import type AtmospherePlugin from "../main"; 3 + import type { ATBookmarkItem, DataSource } from "../sources/types"; 4 + import { SembleSource } from "../sources/semble"; 5 + import { MarginSource } from "../sources/margin"; 6 + import { BookmarkSource } from "../sources/community"; 7 + 8 + interface 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 + 18 + interface TagState { 19 + tag: string; 20 + isSelected: boolean; 21 + } 22 + 23 + export 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 collectionsList = collectionsGroup.createEl("div", { cls: "atmosphere-collection-list" }); 164 + for (const state of this.collectionStates) { 165 + const item = collectionsList.createEl("label", { cls: "atmosphere-collection-item" }); 166 + 167 + const checkbox = item.createEl("input", { type: "checkbox", cls: "atmosphere-collection-checkbox" }); 168 + checkbox.checked = state.isSelected; 169 + checkbox.addEventListener("change", () => { state.isSelected = checkbox.checked; }); 170 + 171 + const info = item.createEl("div", { cls: "atmosphere-collection-item-info" }); 172 + info.createEl("span", { text: state.name, cls: "atmosphere-collection-item-name" }); 173 + if (state.description) { 174 + info.createEl("span", { text: state.description, cls: "atmosphere-collection-item-desc" }); 175 + } 176 + 177 + const sourceIcon = item.createEl("span", { cls: "atmosphere-collection-source-icon" }); 178 + setIcon(sourceIcon, state.source === "semble" ? "atmosphere-semble" : "atmosphere-margin"); 179 + } 180 + } 181 + 182 + const actions = contentEl.createEl("div", { cls: "atmosphere-modal-actions" }); 183 + 184 + actions.createEl("button", { text: "Delete", cls: "atmosphere-btn atmosphere-btn-danger" }) 185 + .addEventListener("click", () => { this.confirmDelete(contentEl); }); 186 + 187 + actions.createEl("div", { cls: "atmosphere-spacer" }); 188 + 189 + actions.createEl("button", { text: "Cancel", cls: "atmosphere-btn atmosphere-btn-secondary" }) 190 + .addEventListener("click", () => { this.close(); }); 191 + 192 + actions.createEl("button", { text: "Save", cls: "atmosphere-btn atmosphere-btn-primary" }) 193 + .addEventListener("click", () => { void this.saveChanges(); }); 194 + } 195 + 196 + private addTagChip(container: HTMLElement, state: TagState) { 197 + const item = container.createEl("label", { cls: "atmosphere-tag-item" }); 198 + const checkbox = item.createEl("input", { type: "checkbox" }); 199 + checkbox.checked = state.isSelected; 200 + checkbox.addEventListener("change", () => { state.isSelected = checkbox.checked; }); 201 + item.createEl("span", { text: state.tag }); 202 + } 203 + 204 + private confirmDelete(contentEl: HTMLElement) { 205 + contentEl.empty(); 206 + contentEl.createEl("h2", { text: "Delete item" }); 207 + contentEl.createEl("p", { text: "Are you sure you want to delete this item?", cls: "atmosphere-warning-text" }); 208 + 209 + const actions = contentEl.createEl("div", { cls: "atmosphere-modal-actions" }); 210 + actions.createEl("button", { text: "Cancel", cls: "atmosphere-btn atmosphere-btn-secondary" }) 211 + .addEventListener("click", () => { void this.onOpen(); }); 212 + actions.createEl("button", { text: "Delete", cls: "atmosphere-btn atmosphere-btn-danger" }) 213 + .addEventListener("click", () => { void this.handleDelete(); }); 214 + } 215 + 216 + private async handleDelete() { 217 + const { contentEl } = this; 218 + contentEl.empty(); 219 + contentEl.createEl("p", { text: "Deleting..." }); 220 + 221 + try { 222 + await this.itemSource.deleteItem!(this.item.getUri()); 223 + new Notice("Deleted"); 224 + this.close(); 225 + this.onSuccess?.(); 226 + } catch (err) { 227 + contentEl.empty(); 228 + const message = err instanceof Error ? err.message : String(err); 229 + contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "atmosphere-error" }); 230 + } 231 + } 232 + 233 + private async saveChanges() { 234 + if (!this.plugin.client) return; 235 + 236 + // Read pending tag input before clearing DOM 237 + const pendingNewTag = this.newTagInput?.value.trim(); 238 + 239 + const { contentEl } = this; 240 + contentEl.empty(); 241 + contentEl.createEl("p", { text: "Saving..." }); 242 + 243 + try { 244 + const messages: string[] = []; 245 + 246 + if (this.item.canAddTags() && this.itemSource.updateTags) { 247 + const selectedTags = this.tagStates.filter(s => s.isSelected).map(s => s.tag); 248 + if (pendingNewTag && !selectedTags.includes(pendingNewTag)) { 249 + selectedTags.push(pendingNewTag); 250 + } 251 + await this.itemSource.updateTags(this.item.getUri(), [...new Set(selectedTags)]); 252 + messages.push("Tags updated"); 253 + } 254 + 255 + const toAdd = this.collectionStates.filter(s => s.isSelected && !s.wasSelected); 256 + const toRemove = this.collectionStates.filter(s => !s.isSelected && s.wasSelected); 257 + 258 + for (const state of toRemove) { 259 + if (state.linkUri) { 260 + const source = state.source === "semble" ? this.sembleSource : this.marginSource; 261 + await source.removeFromCollection(state.linkUri); 262 + } 263 + } 264 + 265 + for (const state of toAdd) { 266 + const source = state.source === "semble" ? this.sembleSource : this.marginSource; 267 + await source.addToCollection(this.item.getUri(), this.item.getCid(), state.uri); 268 + } 269 + 270 + if (toAdd.length > 0) messages.push(`Added to ${toAdd.length} collection${toAdd.length > 1 ? "s" : ""}`); 271 + if (toRemove.length > 0) messages.push(`Removed from ${toRemove.length} collection${toRemove.length > 1 ? "s" : ""}`); 272 + 273 + new Notice(messages.length > 0 ? messages.join(". ") : "Saved"); 274 + this.close(); 275 + this.onSuccess?.(); 276 + } catch (err) { 277 + contentEl.empty(); 278 + const message = err instanceof Error ? err.message : String(err); 279 + contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "atmosphere-error" }); 280 + } 281 + } 282 + 283 + onClose() { 284 + this.contentEl.empty(); 285 + } 286 + }
-331
src/components/editMarginBookmarkModal.ts
··· 1 - import { Modal, Notice } from "obsidian"; 2 - import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 - import type { Main as MarginBookmark } from "../lexicons/types/at/margin/bookmark"; 4 - import type { Main as MarginCollection } from "../lexicons/types/at/margin/collection"; 5 - import type { Main as MarginCollectionItem } from "../lexicons/types/at/margin/collectionItem"; 6 - import type AtmospherePlugin from "../main"; 7 - import { putRecord, deleteRecord, getMarginCollections, getMarginCollectionItems, createMarginCollectionItem, getMarginBookmarks } from "../lib"; 8 - 9 - type MarginBookmarkRecord = Record & { value: MarginBookmark }; 10 - type MarginCollectionRecord = Record & { value: MarginCollection }; 11 - type MarginCollectionItemRecord = Record & { value: MarginCollectionItem }; 12 - 13 - interface CollectionState { 14 - collection: MarginCollectionRecord; 15 - isSelected: boolean; 16 - wasSelected: boolean; 17 - linkUri?: string; 18 - } 19 - 20 - interface TagState { 21 - tag: string; 22 - isSelected: boolean; 23 - } 24 - 25 - export class EditMarginBookmarkModal extends Modal { 26 - plugin: AtmospherePlugin; 27 - record: MarginBookmarkRecord; 28 - onSuccess?: () => void; 29 - tagStates: TagState[] = []; 30 - newTagInput: HTMLInputElement | null = null; 31 - collectionStates: CollectionState[] = []; 32 - 33 - constructor(plugin: AtmospherePlugin, record: MarginBookmarkRecord, onSuccess?: () => void) { 34 - super(plugin.app); 35 - this.plugin = plugin; 36 - this.record = record; 37 - this.onSuccess = onSuccess; 38 - } 39 - 40 - async onOpen() { 41 - const { contentEl } = this; 42 - contentEl.empty(); 43 - contentEl.addClass("atmosphere-modal"); 44 - 45 - contentEl.createEl("h2", { text: "Edit margin bookmark" }); 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 [collectionsResp, itemsResp, bookmarksResp] = await Promise.all([ 56 - getMarginCollections(this.plugin.client, this.plugin.settings.did!), 57 - getMarginCollectionItems(this.plugin.client, this.plugin.settings.did!), 58 - getMarginBookmarks(this.plugin.client, this.plugin.settings.did!), 59 - ]); 60 - 61 - loading.remove(); 62 - 63 - const collections = (collectionsResp.ok ? collectionsResp.data.records : []) as unknown as MarginCollectionRecord[]; 64 - const items = (itemsResp.ok ? itemsResp.data.records : []) as unknown as MarginCollectionItemRecord[]; 65 - const bookmarks = (bookmarksResp.ok ? bookmarksResp.data.records : []) as unknown as MarginBookmarkRecord[]; 66 - 67 - const bookmarkLinks = items.filter(item => item.value.annotation === this.record.uri); 68 - const linkedCollectionUris = new Map<string, string>(); 69 - for (const link of bookmarkLinks) { 70 - linkedCollectionUris.set(link.value.collection, link.uri); 71 - } 72 - 73 - this.collectionStates = collections.map(collection => ({ 74 - collection, 75 - isSelected: linkedCollectionUris.has(collection.uri), 76 - wasSelected: linkedCollectionUris.has(collection.uri), 77 - linkUri: linkedCollectionUris.get(collection.uri), 78 - })); 79 - 80 - const allTags = new Set<string>(); 81 - for (const bookmark of bookmarks) { 82 - if (bookmark.value.tags) { 83 - for (const tag of bookmark.value.tags) { 84 - allTags.add(tag); 85 - } 86 - } 87 - } 88 - 89 - const currentTags = new Set(this.record.value.tags || []); 90 - this.tagStates = Array.from(allTags).sort().map(tag => ({ 91 - tag, 92 - isSelected: currentTags.has(tag), 93 - })); 94 - 95 - this.renderForm(contentEl); 96 - } catch (err) { 97 - loading.remove(); 98 - const message = err instanceof Error ? err.message : String(err); 99 - contentEl.createEl("p", { text: `Error: ${message}`, cls: "atmosphere-error" }); 100 - } 101 - } 102 - 103 - private renderForm(contentEl: HTMLElement) { 104 - const form = contentEl.createEl("div", { cls: "atmosphere-form" }); 105 - 106 - const tagsGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 107 - tagsGroup.createEl("label", { text: "Tags" }); 108 - 109 - const tagsList = tagsGroup.createEl("div", { cls: "atmosphere-tag-list" }); 110 - for (const state of this.tagStates) { 111 - this.addTagChip(tagsList, state); 112 - } 113 - 114 - const newTagRow = tagsGroup.createEl("div", { cls: "atmosphere-tag-row" }); 115 - this.newTagInput = newTagRow.createEl("input", { 116 - type: "text", 117 - cls: "atmosphere-input", 118 - attr: { placeholder: "Add new tag..." } 119 - }); 120 - const addBtn = newTagRow.createEl("button", { 121 - text: "Add", 122 - cls: "atmosphere-btn atmosphere-btn-secondary", 123 - attr: { type: "button" } 124 - }); 125 - addBtn.addEventListener("click", () => { 126 - const value = this.newTagInput?.value.trim(); 127 - if (value && !this.tagStates.some(s => s.tag === value)) { 128 - const newState = { tag: value, isSelected: true }; 129 - this.tagStates.push(newState); 130 - this.addTagChip(tagsList, newState); 131 - if (this.newTagInput) this.newTagInput.value = ""; 132 - } 133 - }); 134 - 135 - if (this.collectionStates.length > 0) { 136 - const collectionsGroup = form.createEl("div", { cls: "atmosphere-form-group" }); 137 - collectionsGroup.createEl("label", { text: "Collections" }); 138 - 139 - const collectionsList = collectionsGroup.createEl("div", { cls: "atmosphere-collection-list" }); 140 - 141 - for (const state of this.collectionStates) { 142 - const item = collectionsList.createEl("label", { cls: "atmosphere-collection-item" }); 143 - 144 - const checkbox = item.createEl("input", { type: "checkbox", cls: "atmosphere-collection-checkbox" }); 145 - checkbox.checked = state.isSelected; 146 - checkbox.addEventListener("change", () => { 147 - state.isSelected = checkbox.checked; 148 - }); 149 - 150 - const info = item.createEl("div", { cls: "atmosphere-collection-item-info" }); 151 - info.createEl("span", { text: state.collection.value.name, cls: "atmosphere-collection-item-name" }); 152 - if (state.collection.value.description) { 153 - info.createEl("span", { text: state.collection.value.description, cls: "atmosphere-collection-item-desc" }); 154 - } 155 - } 156 - } 157 - 158 - const actions = contentEl.createEl("div", { cls: "atmosphere-modal-actions" }); 159 - 160 - const deleteBtn = actions.createEl("button", { 161 - text: "Delete", 162 - cls: "atmosphere-btn atmosphere-btn-danger" 163 - }); 164 - deleteBtn.addEventListener("click", () => { this.confirmDelete(contentEl); }); 165 - 166 - actions.createEl("div", { cls: "atmosphere-spacer" }); 167 - 168 - const cancelBtn = actions.createEl("button", { 169 - text: "Cancel", 170 - cls: "atmosphere-btn atmosphere-btn-secondary" 171 - }); 172 - cancelBtn.addEventListener("click", () => { this.close(); }); 173 - 174 - const saveBtn = actions.createEl("button", { 175 - text: "Save", 176 - cls: "atmosphere-btn atmosphere-btn-primary" 177 - }); 178 - saveBtn.addEventListener("click", () => { void this.saveChanges(); }); 179 - } 180 - 181 - private addTagChip(container: HTMLElement, state: TagState) { 182 - const item = container.createEl("label", { cls: "atmosphere-tag-item" }); 183 - const checkbox = item.createEl("input", { type: "checkbox" }); 184 - checkbox.checked = state.isSelected; 185 - checkbox.addEventListener("change", () => { 186 - state.isSelected = checkbox.checked; 187 - }); 188 - item.createEl("span", { text: state.tag }); 189 - } 190 - 191 - private confirmDelete(contentEl: HTMLElement) { 192 - contentEl.empty(); 193 - contentEl.createEl("h2", { text: "Delete bookmark" }); 194 - contentEl.createEl("p", { text: "Delete this bookmark?", cls: "atmosphere-warning-text" }); 195 - 196 - const actions = contentEl.createEl("div", { cls: "atmosphere-modal-actions" }); 197 - 198 - const cancelBtn = actions.createEl("button", { 199 - text: "Cancel", 200 - cls: "atmosphere-btn atmosphere-btn-secondary" 201 - }); 202 - cancelBtn.addEventListener("click", () => { 203 - void this.onOpen(); 204 - }); 205 - 206 - const confirmBtn = actions.createEl("button", { 207 - text: "Delete", 208 - cls: "atmosphere-btn atmosphere-btn-danger" 209 - }); 210 - confirmBtn.addEventListener("click", () => { void this.deleteBookmark(); }); 211 - } 212 - 213 - private async deleteBookmark() { 214 - if (!this.plugin.client) return; 215 - 216 - const { contentEl } = this; 217 - contentEl.empty(); 218 - contentEl.createEl("p", { text: "Deleting bookmark..." }); 219 - 220 - try { 221 - const rkey = this.record.uri.split("/").pop(); 222 - if (!rkey) { 223 - contentEl.empty(); 224 - contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "atmosphere-error" }); 225 - return; 226 - } 227 - 228 - await deleteRecord( 229 - this.plugin.client, 230 - this.plugin.settings.did!, 231 - "at.margin.bookmark", 232 - rkey 233 - ); 234 - 235 - new Notice("Bookmark deleted"); 236 - this.close(); 237 - this.onSuccess?.(); 238 - } catch (err) { 239 - contentEl.empty(); 240 - const message = err instanceof Error ? err.message : String(err); 241 - contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "atmosphere-error" }); 242 - } 243 - } 244 - 245 - private async saveChanges() { 246 - if (!this.plugin.client) return; 247 - 248 - const { contentEl } = this; 249 - contentEl.empty(); 250 - contentEl.createEl("p", { text: "Saving changes..." }); 251 - 252 - try { 253 - const selectedTags = this.tagStates.filter(s => s.isSelected).map(s => s.tag); 254 - const newTag = this.newTagInput?.value.trim(); 255 - if (newTag && !selectedTags.includes(newTag)) { 256 - selectedTags.push(newTag); 257 - } 258 - const tags = [...new Set(selectedTags)]; 259 - 260 - const rkey = this.record.uri.split("/").pop(); 261 - if (!rkey) { 262 - contentEl.empty(); 263 - contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "atmosphere-error" }); 264 - return; 265 - } 266 - 267 - const updatedRecord: MarginBookmark = { 268 - ...this.record.value, 269 - tags, 270 - }; 271 - 272 - await putRecord( 273 - this.plugin.client, 274 - this.plugin.settings.did!, 275 - "at.margin.bookmark", 276 - rkey, 277 - updatedRecord 278 - ); 279 - 280 - const collectionsToAdd = this.collectionStates.filter(s => s.isSelected && !s.wasSelected); 281 - const collectionsToRemove = this.collectionStates.filter(s => !s.isSelected && s.wasSelected); 282 - 283 - for (const state of collectionsToRemove) { 284 - if (state.linkUri) { 285 - const linkRkey = state.linkUri.split("/").pop(); 286 - if (linkRkey) { 287 - await deleteRecord( 288 - this.plugin.client, 289 - this.plugin.settings.did!, 290 - "at.margin.collectionItem", 291 - linkRkey 292 - ); 293 - } 294 - } 295 - } 296 - 297 - for (const state of collectionsToAdd) { 298 - await createMarginCollectionItem( 299 - this.plugin.client, 300 - this.plugin.settings.did!, 301 - this.record.uri, 302 - state.collection.uri 303 - ); 304 - } 305 - 306 - const messages: string[] = []; 307 - if (tags.length !== (this.record.value.tags?.length || 0) || 308 - !tags.every(t => this.record.value.tags?.includes(t))) { 309 - messages.push("Tags updated"); 310 - } 311 - if (collectionsToAdd.length > 0) { 312 - messages.push(`Added to ${collectionsToAdd.length} collection${collectionsToAdd.length > 1 ? "s" : ""}`); 313 - } 314 - if (collectionsToRemove.length > 0) { 315 - messages.push(`Removed from ${collectionsToRemove.length} collection${collectionsToRemove.length > 1 ? "s" : ""}`); 316 - } 317 - 318 - new Notice(messages.length > 0 ? messages.join(". ") : "Saved"); 319 - this.close(); 320 - this.onSuccess?.(); 321 - } catch (err) { 322 - contentEl.empty(); 323 - const message = err instanceof Error ? err.message : String(err); 324 - contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "atmosphere-error" }); 325 - } 326 - } 327 - 328 - onClose() { 329 - this.contentEl.empty(); 330 - } 331 - }
+19 -4
src/sources/community.ts
··· 1 1 import type { Client } from "@atcute/client"; 2 2 import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 3 import type AtmospherePlugin from "../main"; 4 - import { getBookmarks } from "../lib"; 4 + import { getBookmarks, deleteRecord, getRecord, putRecord } from "../lib"; 5 5 import type { ATBookmarkItem, DataSource, SourceFilter } from "./types"; 6 - import { EditBookmarkModal } from "../components/editBookmarkModal"; 7 6 import type { Main as Bookmark } from "../lexicons/types/community/lexicon/bookmarks/bookmark"; 8 7 import { fetchOgImage } from "../util" 9 8 ··· 55 54 return true; 56 55 } 57 56 58 - openEditModal(onSuccess?: () => void): void { 59 - new EditBookmarkModal(this.plugin, this.record, onSuccess).open(); 57 + canAddToCollections(): boolean { 58 + return false; 60 59 } 60 + 61 61 62 62 getTitle(): string | undefined { 63 63 const enriched = this.record.value.enriched; ··· 122 122 } 123 123 124 124 return bookmarks.map((record: BookmarkRecord) => new BookmarkItem(record, plugin)); 125 + } 126 + 127 + async deleteItem(itemUri: string): Promise<void> { 128 + const rkey = itemUri.split("/").pop(); 129 + if (!rkey) throw new Error("Invalid URI"); 130 + await deleteRecord(this.client, this.repo, "community.lexicon.bookmarks.bookmark", rkey); 131 + } 132 + 133 + async updateTags(itemUri: string, tags: string[]): Promise<void> { 134 + const rkey = itemUri.split("/").pop(); 135 + if (!rkey) throw new Error("Invalid URI"); 136 + const resp = await getRecord(this.client, this.repo, "community.lexicon.bookmarks.bookmark", rkey); 137 + if (!resp.ok) throw new Error("Failed to fetch record"); 138 + const existing = resp.data.value as unknown as Bookmark; 139 + await putRecord(this.client, this.repo, "community.lexicon.bookmarks.bookmark", rkey, { ...existing, tags }); 125 140 } 126 141 127 142 async getAvilableTags(): Promise<SourceFilter[]> {
+33 -5
src/sources/margin.ts
··· 1 1 import type { Client } from "@atcute/client"; 2 2 import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 3 import type AtmospherePlugin from "../main"; 4 - import { getMarginBookmarks, getMarginCollections, getMarginCollectionItems } from "../lib"; 4 + import { getMarginBookmarks, getMarginCollections, getMarginCollectionItems, deleteRecord, createMarginCollectionItem, getRecord, putRecord } from "../lib"; 5 5 import type { ATBookmarkItem, CollectionAssociation, DataSource, SourceFilter } from "./types"; 6 6 import type { Main as MarginBookmark } from "../lexicons/types/at/margin/bookmark"; 7 7 import type { Main as MarginCollection } from "../lexicons/types/at/margin/collection"; 8 8 import type { Main as MarginCollectionItem } from "../lexicons/types/at/margin/collectionItem"; 9 - import { EditMarginBookmarkModal } from "../components/editMarginBookmarkModal"; 10 9 import { fetchOgImage } from "../util" 11 10 12 11 type MarginBookmarkRecord = Record & { value: MarginBookmark }; ··· 48 47 return true; 49 48 } 50 49 50 + canAddToCollections(): boolean { 51 + return true; 52 + } 53 + 51 54 canEdit(): boolean { 52 55 return true; 53 56 } 54 57 55 - openEditModal(onSuccess?: () => void): void { 56 - new EditMarginBookmarkModal(this.plugin, this.record, onSuccess).open(); 57 - } 58 58 59 59 getTitle(): string | undefined { 60 60 return this.record.value.title || undefined; ··· 131 131 return collections.map((c: MarginCollectionRecord) => ({ 132 132 value: c.uri, 133 133 label: c.value.name, 134 + description: c.value.description, 134 135 })); 135 136 } 137 + 136 138 async getCollectionAssociations(): Promise<CollectionAssociation[]> { 137 139 const itemsResp = await getMarginCollectionItems(this.client, this.repo); 138 140 if (!itemsResp.ok) return []; ··· 140 142 return (itemsResp.data.records as MarginCollectionItemRecord[]).map(item => ({ 141 143 record: item.value.annotation, 142 144 collection: item.value.collection, 145 + linkUri: item.uri, 143 146 })); 147 + } 148 + 149 + async deleteItem(itemUri: string): Promise<void> { 150 + const rkey = itemUri.split("/").pop(); 151 + if (!rkey) throw new Error("Invalid URI"); 152 + await deleteRecord(this.client, this.repo, "at.margin.bookmark", rkey); 153 + } 154 + 155 + async addToCollection(itemUri: string, _itemCid: string, collectionUri: string): Promise<void> { 156 + await createMarginCollectionItem(this.client, this.repo, itemUri, collectionUri); 157 + } 158 + 159 + async removeFromCollection(linkUri: string): Promise<void> { 160 + const rkey = linkUri.split("/").pop(); 161 + if (!rkey) throw new Error("Invalid link URI"); 162 + await deleteRecord(this.client, this.repo, "at.margin.collectionItem", rkey); 163 + } 164 + 165 + async updateTags(itemUri: string, tags: string[]): Promise<void> { 166 + const rkey = itemUri.split("/").pop(); 167 + if (!rkey) throw new Error("Invalid URI"); 168 + const resp = await getRecord(this.client, this.repo, "at.margin.bookmark", rkey); 169 + if (!resp.ok) throw new Error("Failed to fetch record"); 170 + const existing = resp.data.value as MarginBookmark; 171 + await putRecord(this.client, this.repo, "at.margin.bookmark", rkey, { ...existing, tags }); 144 172 } 145 173 146 174 async getAvilableTags(): Promise<SourceFilter[]> {
+27 -5
src/sources/semble.ts
··· 1 1 import type { Client } from "@atcute/client"; 2 2 import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 3 import type AtmospherePlugin from "../main"; 4 - import { getSembleCollections, getSembleCards, getSembleCollectionLinks } from "../lib"; 4 + import { getSembleCollections, getSembleCards, getSembleCollectionLinks, deleteRecord, getRecord, createSembleCollectionLink } from "../lib"; 5 5 import type { Main as Card, NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card"; 6 6 import type { Main as Collection } from "../lexicons/types/network/cosmik/collection"; 7 7 import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink"; 8 8 import type { ATBookmarkItem, CollectionAssociation, DataSource, SourceFilter } from "./types"; 9 - import { EditCardModal } from "../components/editCardModal"; 10 9 import { fetchOgImage } from "../util" 11 10 12 11 type CardRecord = Record & { value: Card }; ··· 50 49 return false; 51 50 } 52 51 52 + canAddToCollections(): boolean { 53 + return true; 54 + } 55 + 53 56 canEdit(): boolean { 54 57 return true; 55 58 } 56 59 57 - openEditModal(onSuccess?: () => void): void { 58 - new EditCardModal(this.plugin, this.record.uri, this.record.cid, onSuccess).open(); 59 - } 60 60 61 61 getTitle(): string | undefined { 62 62 const card = this.record.value; ··· 178 178 return collections.map((c: CollectionRecord) => ({ 179 179 value: c.uri, 180 180 label: c.value.name, 181 + description: c.value.description, 181 182 })); 182 183 } 183 184 ··· 188 189 return (linksResp.data.records as CollectionLinkRecord[]).map(link => ({ 189 190 record: link.value.card.uri, 190 191 collection: link.value.collection.uri, 192 + linkUri: link.uri, 191 193 })); 194 + } 195 + 196 + async deleteItem(itemUri: string): Promise<void> { 197 + const rkey = itemUri.split("/").pop(); 198 + if (!rkey) throw new Error("Invalid URI"); 199 + await deleteRecord(this.client, this.repo, "network.cosmik.card", rkey); 200 + } 201 + 202 + async addToCollection(itemUri: string, itemCid: string, collectionUri: string): Promise<void> { 203 + const collectionRkey = collectionUri.split("/").pop(); 204 + if (!collectionRkey) throw new Error("Invalid collection URI"); 205 + const collectionResp = await getRecord(this.client, this.repo, "network.cosmik.collection", collectionRkey); 206 + if (!collectionResp.ok || !collectionResp.data.cid) throw new Error("Failed to fetch collection"); 207 + await createSembleCollectionLink(this.client, this.repo, itemUri, itemCid, collectionUri, String(collectionResp.data.cid)); 208 + } 209 + 210 + async removeFromCollection(linkUri: string): Promise<void> { 211 + const rkey = linkUri.split("/").pop(); 212 + if (!rkey) throw new Error("Invalid link URI"); 213 + await deleteRecord(this.client, this.repo, "network.cosmik.collectionLink", rkey); 192 214 } 193 215 194 216 }
+7 -1
src/sources/types.ts
··· 3 3 export interface ATBookmarkItem { 4 4 canAddNotes(): boolean; 5 5 canAddTags(): boolean; 6 + canAddToCollections(): boolean; 6 7 canEdit(): boolean; 7 - openEditModal(onSuccess?: () => void): void; 8 8 getUri(): string; 9 9 getCid(): string; 10 10 getCreatedAt(): string; ··· 23 23 export interface SourceFilter { 24 24 value: string; 25 25 label?: string; 26 + description?: string; 26 27 } 27 28 28 29 export interface CollectionAssociation { 29 30 record: string; 30 31 collection: string; 32 + linkUri: string; 31 33 } 32 34 33 35 export interface DataSource { ··· 36 38 getAvailableCollections?(): Promise<SourceFilter[]>; 37 39 getAvilableTags?(): Promise<SourceFilter[]>; 38 40 getCollectionAssociations?(): Promise<CollectionAssociation[]>; 41 + deleteItem?(itemUri: string): Promise<void>; 42 + addToCollection?(itemUri: string, itemCid: string, collectionUri: string): Promise<void>; 43 + removeFromCollection?(linkUri: string): Promise<void>; 44 + updateTags?(itemUri: string, tags: string[]): Promise<void>; 39 45 }
+2 -3
src/views/bookmarks.ts
··· 1 1 import { ItemView, WorkspaceLeaf, setIcon, Menu, SearchComponent } from "obsidian"; 2 2 import type AtmospherePlugin from "../main"; 3 3 import { CardDetailModal } from "../components/cardDetailModal"; 4 + import { EditItemModal } from "../components/editItemModal"; 4 5 import { CreateCollectionModal } from "../components/createCollectionModal"; 5 6 import { CreateTagModal } from "../components/createTagModal"; 6 7 import type { ATBookmarkItem, DataSource, SourceFilter } from "../sources/types"; ··· 427 428 setIcon(editBtn, "more-vertical"); 428 429 editBtn.addEventListener("click", (e) => { 429 430 e.stopPropagation(); 430 - item.openEditModal(() => { 431 - void this.refresh(); 432 - }); 431 + new EditItemModal(this.plugin, item, () => void this.refresh()).open(); 433 432 }); 434 433 } 435 434