AT protocol bookmarking platforms in obsidian

refactor and add support for community bookmakrs/kipclip

+1203 -792
+20 -130
src/components/cardDetailModal.ts
··· 1 - import { Modal, Notice, setIcon } from "obsidian"; 1 + import { Modal, Notice } from "obsidian"; 2 2 import type ATmarkPlugin from "../main"; 3 - import type { Main as Card, NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card"; 4 3 import { createNoteCard, deleteRecord } from "../lib"; 5 - 6 - interface AttachedNote { 7 - uri: string; 8 - text: string; 9 - } 10 - 11 - interface CardRecord { 12 - uri: string; 13 - cid: string; 14 - value: Card; 15 - } 16 - 17 - interface CardWithNotes extends CardRecord { 18 - attachedNotes: AttachedNote[]; 19 - } 4 + import type { ATmarkItem } from "../sources/types"; 20 5 21 6 export class CardDetailModal extends Modal { 22 7 plugin: ATmarkPlugin; 23 - card: CardWithNotes; 8 + item: ATmarkItem; 24 9 onSuccess?: () => void; 25 10 noteInput: HTMLTextAreaElement | null = null; 26 11 27 - constructor(plugin: ATmarkPlugin, card: CardWithNotes, onSuccess?: () => void) { 12 + constructor(plugin: ATmarkPlugin, item: ATmarkItem, onSuccess?: () => void) { 28 13 super(plugin.app); 29 14 this.plugin = plugin; 30 - this.card = card; 15 + this.item = item; 31 16 this.onSuccess = onSuccess; 32 17 } 33 18 ··· 36 21 contentEl.empty(); 37 22 contentEl.addClass("semble-detail-modal"); 38 23 39 - const card = this.card.value; 40 - 41 - // Header with type badge 24 + // Header with source badge 42 25 const header = contentEl.createEl("div", { cls: "semble-detail-header" }); 26 + const source = this.item.getSource(); 43 27 header.createEl("span", { 44 - text: card.type, 45 - cls: `semble-badge semble-badge-${card.type?.toLowerCase() || "unknown"}`, 28 + text: source, 29 + cls: `semble-badge semble-badge-source semble-badge-${source}`, 46 30 }); 47 31 48 - if (card.type === "NOTE") { 49 - this.renderNoteCard(contentEl, card); 50 - } else if (card.type === "URL") { 51 - this.renderUrlCard(contentEl, card); 52 - } 32 + // Render item detail content 33 + this.item.renderDetail(contentEl); 53 34 54 - // Attached notes section 55 - if (this.card.attachedNotes.length > 0) { 56 - const notesSection = contentEl.createEl("div", { cls: "semble-detail-notes-section" }); 57 - notesSection.createEl("h3", { text: "Notes", cls: "semble-detail-section-title" }); 58 - 59 - for (const note of this.card.attachedNotes) { 60 - const noteEl = notesSection.createEl("div", { cls: "semble-detail-note" }); 61 - 62 - const noteContent = noteEl.createEl("div", { cls: "semble-detail-note-content" }); 63 - const noteIcon = noteContent.createEl("span", { cls: "semble-detail-note-icon" }); 64 - setIcon(noteIcon, "message-square"); 65 - noteContent.createEl("p", { text: note.text, cls: "semble-detail-note-text" }); 66 - 67 - const deleteBtn = noteEl.createEl("button", { cls: "semble-note-delete-btn" }); 68 - setIcon(deleteBtn, "trash-2"); 69 - deleteBtn.setAttribute("aria-label", "Delete note"); 70 - deleteBtn.addEventListener("click", () => { void this.handleDeleteNote(note.uri); }); 71 - } 35 + // Add note form (only for items that support it) 36 + if (this.item.canAddNotes()) { 37 + this.renderAddNoteForm(contentEl); 72 38 } 73 39 74 - // Add note form 75 - this.renderAddNoteForm(contentEl); 76 - 77 40 // Footer with date 78 - if (card.createdAt) { 79 - const footer = contentEl.createEl("div", { cls: "semble-detail-footer" }); 80 - footer.createEl("span", { 81 - text: `Created ${new Date(card.createdAt).toLocaleDateString()}`, 82 - cls: "semble-detail-date", 83 - }); 84 - } 85 - } 86 - 87 - private renderNoteCard(contentEl: HTMLElement, card: Card) { 88 - const content = card.content as NoteContent; 89 - const body = contentEl.createEl("div", { cls: "semble-detail-body" }); 90 - body.createEl("p", { text: content.text, cls: "semble-detail-text" }); 91 - } 92 - 93 - private renderUrlCard(contentEl: HTMLElement, card: Card) { 94 - const content = card.content as UrlContent; 95 - const meta = content.metadata; 96 - const body = contentEl.createEl("div", { cls: "semble-detail-body" }); 97 - 98 - // Title 99 - if (meta?.title) { 100 - body.createEl("h2", { text: meta.title, cls: "semble-detail-title" }); 101 - } 102 - 103 - // Image 104 - if (meta?.imageUrl) { 105 - const img = body.createEl("img", { cls: "semble-detail-image" }); 106 - img.src = meta.imageUrl; 107 - img.alt = meta.title || "Image"; 108 - } 109 - 110 - // Full description 111 - if (meta?.description) { 112 - body.createEl("p", { text: meta.description, cls: "semble-detail-description" }); 113 - } 114 - 115 - // Metadata grid 116 - const metaGrid = body.createEl("div", { cls: "semble-detail-meta" }); 117 - 118 - if (meta?.siteName) { 119 - this.addMetaItem(metaGrid, "Site", meta.siteName); 120 - } 121 - 122 - if (meta?.author) { 123 - this.addMetaItem(metaGrid, "Author", meta.author); 124 - } 125 - 126 - if (meta?.publishedDate) { 127 - this.addMetaItem(metaGrid, "Published", new Date(meta.publishedDate).toLocaleDateString()); 128 - } 129 - 130 - if (meta?.type) { 131 - this.addMetaItem(metaGrid, "Type", meta.type); 132 - } 133 - 134 - if (meta?.doi) { 135 - this.addMetaItem(metaGrid, "DOI", meta.doi); 136 - } 137 - 138 - if (meta?.isbn) { 139 - this.addMetaItem(metaGrid, "ISBN", meta.isbn); 140 - } 141 - 142 - // URL link 143 - const linkWrapper = body.createEl("div", { cls: "semble-detail-link-wrapper" }); 144 - const link = linkWrapper.createEl("a", { 145 - text: content.url, 146 - href: content.url, 147 - cls: "semble-detail-link", 41 + const footer = contentEl.createEl("div", { cls: "semble-detail-footer" }); 42 + footer.createEl("span", { 43 + text: `Created ${new Date(this.item.getCreatedAt()).toLocaleDateString()}`, 44 + cls: "semble-detail-date", 148 45 }); 149 - link.setAttr("target", "_blank"); 150 46 } 151 47 152 48 private renderAddNoteForm(contentEl: HTMLElement) { ··· 157 53 158 54 this.noteInput = form.createEl("textarea", { 159 55 cls: "semble-textarea semble-note-input", 160 - attr: { placeholder: "Write a note about this card..." }, 56 + attr: { placeholder: "Write a note about this item..." }, 161 57 }); 162 58 163 59 const addBtn = form.createEl("button", { text: "Add note", cls: "semble-btn semble-btn-primary" }); ··· 178 74 this.plugin.client, 179 75 this.plugin.settings.identifier, 180 76 text, 181 - { uri: this.card.uri, cid: this.card.cid } 77 + { uri: this.item.getUri(), cid: this.item.getCid() } 182 78 ); 183 79 184 80 new Notice("Note added"); ··· 214 110 const message = err instanceof Error ? err.message : String(err); 215 111 new Notice(`Failed to delete note: ${message}`); 216 112 } 217 - } 218 - 219 - private addMetaItem(container: HTMLElement, label: string, value: string) { 220 - const item = container.createEl("div", { cls: "semble-detail-meta-item" }); 221 - item.createEl("span", { text: label, cls: "semble-detail-meta-label" }); 222 - item.createEl("span", { text: value, cls: "semble-detail-meta-value" }); 223 113 } 224 114 225 115 onClose() {
+20
src/components/profileIcon.ts
··· 1 + import type { Client } from "@atcute/client"; 2 + import { getProfile } from "../lib"; 3 + 1 4 export interface ProfileData { 2 5 did: string; 3 6 handle: string; 4 7 displayName?: string; 5 8 avatar?: string; 9 + } 10 + 11 + export async function fetchProfileData(client: Client, actor: string): Promise<ProfileData | null> { 12 + try { 13 + const resp = await getProfile(client, actor); 14 + if (!resp.ok) return null; 15 + 16 + return { 17 + did: resp.data.did, 18 + handle: resp.data.handle, 19 + displayName: resp.data.displayName, 20 + avatar: resp.data.avatar, 21 + }; 22 + } catch (e) { 23 + console.error("Failed to fetch profile:", e); 24 + return null; 25 + } 6 26 } 7 27 8 28 export function renderProfileIcon(
+11 -149
src/lib.ts
··· 1 - import type { Client } from "@atcute/client"; 2 - import type { ActorIdentifier, Nsid } from "@atcute/lexicons"; 3 - 4 - export async function getProfile(client: Client, actor: string) { 5 - return await client.get("app.bsky.actor.getProfile", { 6 - params: { actor: actor as ActorIdentifier }, 7 - }); 8 - } 9 - 10 - export async function getCollections(client: Client, repo: string) { 11 - return await client.get("com.atproto.repo.listRecords", { 12 - params: { 13 - repo: repo as ActorIdentifier, 14 - collection: "network.cosmik.collection" as Nsid, 15 - limit: 100, 16 - }, 17 - }); 18 - } 19 - 20 - 21 - export async function createCollection(client: Client, repo: string, name: string, description: string) { 22 - return await client.post("com.atproto.repo.createRecord", { 23 - input: { 24 - repo: repo as ActorIdentifier, 25 - collection: "network.cosmik.collection" as Nsid, 26 - validate: false, 27 - record: { 28 - $type: "network.cosmik.collection", 29 - name, 30 - description, 31 - accessType: "CLOSED", 32 - createdAt: new Date().toISOString(), 33 - }, 34 - }, 35 - }); 36 - } 37 - 38 - 39 - export async function createNoteCard(client: Client, repo: string, text: string, originalCard?: { uri: string; cid: string }) { 40 - return await client.post("com.atproto.repo.createRecord", { 41 - input: { 42 - repo: repo as ActorIdentifier, 43 - collection: "network.cosmik.card" as Nsid, 44 - record: { 45 - $type: "network.cosmik.card", 46 - type: "NOTE", 47 - content: { 48 - $type: "network.cosmik.card#noteContent", 49 - text, 50 - }, 51 - originalCard: originalCard ? { uri: originalCard.uri, cid: originalCard.cid } : undefined, 52 - createdAt: new Date().toISOString(), 53 - }, 54 - }, 55 - }); 56 - } 57 - 58 - export async function createUrlCard(client: Client, repo: string, url: string, metadata?: { 59 - title?: string; 60 - description?: string; 61 - imageUrl?: string; 62 - siteName?: string; 63 - }) { 64 - return await client.post("com.atproto.repo.createRecord", { 65 - input: { 66 - repo: repo as ActorIdentifier, 67 - collection: "network.cosmik.card" as Nsid, 68 - record: { 69 - $type: "network.cosmik.card", 70 - type: "URL", 71 - url, 72 - content: { 73 - $type: "network.cosmik.card#urlContent", 74 - url, 75 - metadata: metadata ? { $type: "network.cosmik.card#urlMetadata", ...metadata } : undefined, 76 - }, 77 - createdAt: new Date().toISOString(), 78 - }, 79 - }, 80 - }); 81 - } 82 - 83 - export async function getCards(client: Client, repo: string) { 84 - return await client.get("com.atproto.repo.listRecords", { 85 - params: { 86 - repo: repo as ActorIdentifier, 87 - collection: "network.cosmik.card" as Nsid, 88 - limit: 100, 89 - }, 90 - }); 91 - } 92 - 93 - export async function getCollectionLinks(client: Client, repo: string) { 94 - return await client.get("com.atproto.repo.listRecords", { 95 - params: { 96 - repo: repo as ActorIdentifier, 97 - collection: "network.cosmik.collectionLink" as Nsid, 98 - limit: 100, 99 - }, 100 - }); 101 - } 102 - 103 - export async function createCollectionLink( 104 - client: Client, 105 - repo: string, 106 - cardUri: string, 107 - cardCid: string, 108 - collectionUri: string, 109 - collectionCid: string 110 - ) { 111 - return await client.post("com.atproto.repo.createRecord", { 112 - input: { 113 - repo: repo as ActorIdentifier, 114 - collection: "network.cosmik.collectionLink" as Nsid, 115 - record: { 116 - $type: "network.cosmik.collectionLink", 117 - card: { 118 - uri: cardUri, 119 - cid: cardCid, 120 - }, 121 - collection: { 122 - uri: collectionUri, 123 - cid: collectionCid, 124 - }, 125 - addedAt: new Date().toISOString(), 126 - addedBy: repo, 127 - createdAt: new Date().toISOString(), 128 - }, 129 - }, 130 - }); 131 - } 1 + export { getRecord, deleteRecord, getProfile } from "./lib/atproto"; 132 2 133 - export async function getRecord(client: Client, repo: string, collection: string, rkey: string) { 134 - return await client.get("com.atproto.repo.getRecord", { 135 - params: { 136 - repo: repo as ActorIdentifier, 137 - collection: collection as Nsid, 138 - rkey, 139 - }, 140 - }); 141 - } 3 + export { 4 + getSembleCollections as getCollections, 5 + createSembleCollection as createCollection, 6 + getSembleCards as getCards, 7 + createSembleNote as createNoteCard, 8 + createSembleUrlCard as createUrlCard, 9 + getSembleCollectionLinks as getCollectionLinks, 10 + createSembleCollectionLink as createCollectionLink, 11 + } from "./lib/cosmik"; 142 12 143 - export async function deleteRecord(client: Client, repo: string, collection: string, rkey: string) { 144 - return await client.post("com.atproto.repo.deleteRecord", { 145 - input: { 146 - repo: repo as ActorIdentifier, 147 - collection: collection as Nsid, 148 - rkey, 149 - }, 150 - }); 151 - } 13 + export { getBookmarks, createBookmark, getTags, createTag } from "./lib/bookmarks";
+28
src/lib/atproto.ts
··· 1 + import type { Client } from "@atcute/client"; 2 + import type { ActorIdentifier, Nsid } from "@atcute/lexicons"; 3 + 4 + export async function getRecord(client: Client, repo: string, collection: string, rkey: string) { 5 + return await client.get("com.atproto.repo.getRecord", { 6 + params: { 7 + repo: repo as ActorIdentifier, 8 + collection: collection as Nsid, 9 + rkey, 10 + }, 11 + }); 12 + } 13 + 14 + export async function deleteRecord(client: Client, repo: string, collection: string, rkey: string) { 15 + return await client.post("com.atproto.repo.deleteRecord", { 16 + input: { 17 + repo: repo as ActorIdentifier, 18 + collection: collection as Nsid, 19 + rkey, 20 + }, 21 + }); 22 + } 23 + 24 + export async function getProfile(client: Client, actor: string) { 25 + return await client.get("app.bsky.actor.getProfile", { 26 + params: { actor: actor as ActorIdentifier }, 27 + }); 28 + }
+60
src/lib/bookmarks.ts
··· 1 + import type { Client } from "@atcute/client"; 2 + import type { ActorIdentifier, Nsid } from "@atcute/lexicons"; 3 + 4 + export async function getBookmarks(client: Client, repo: string) { 5 + return await client.get("com.atproto.repo.listRecords", { 6 + params: { 7 + repo: repo as ActorIdentifier, 8 + collection: "community.lexicon.bookmarks.bookmark" as Nsid, 9 + limit: 100, 10 + }, 11 + }); 12 + } 13 + 14 + export async function createBookmark( 15 + client: Client, 16 + repo: string, 17 + subject: string, 18 + title?: string, 19 + description?: string, 20 + tags?: string[] 21 + ) { 22 + return await client.post("com.atproto.repo.createRecord", { 23 + input: { 24 + repo: repo as ActorIdentifier, 25 + collection: "community.lexicon.bookmarks.bookmark" as Nsid, 26 + record: { 27 + $type: "community.lexicon.bookmarks.bookmark", 28 + subject, 29 + title, 30 + description, 31 + tags, 32 + createdAt: new Date().toISOString(), 33 + }, 34 + }, 35 + }); 36 + } 37 + 38 + export async function getTags(client: Client, repo: string) { 39 + return await client.get("com.atproto.repo.listRecords", { 40 + params: { 41 + repo: repo as ActorIdentifier, 42 + collection: "com.kipclip.tag" as Nsid, 43 + limit: 100, 44 + }, 45 + }); 46 + } 47 + 48 + export async function createTag(client: Client, repo: string, value: string) { 49 + return await client.post("com.atproto.repo.createRecord", { 50 + input: { 51 + repo: repo as ActorIdentifier, 52 + collection: "com.kipclip.tag" as Nsid, 53 + record: { 54 + $type: "com.kipclip.tag", 55 + value, 56 + createdAt: new Date().toISOString(), 57 + }, 58 + }, 59 + }); 60 + }
+123
src/lib/cosmik.ts
··· 1 + import type { Client } from "@atcute/client"; 2 + import type { ActorIdentifier, Nsid } from "@atcute/lexicons"; 3 + 4 + export async function getSembleCollections(client: Client, repo: string) { 5 + return await client.get("com.atproto.repo.listRecords", { 6 + params: { 7 + repo: repo as ActorIdentifier, 8 + collection: "network.cosmik.collection" as Nsid, 9 + limit: 100, 10 + }, 11 + }); 12 + } 13 + 14 + export async function createSembleCollection(client: Client, repo: string, name: string, description: string) { 15 + return await client.post("com.atproto.repo.createRecord", { 16 + input: { 17 + repo: repo as ActorIdentifier, 18 + collection: "network.cosmik.collection" as Nsid, 19 + validate: false, 20 + record: { 21 + $type: "network.cosmik.collection", 22 + name, 23 + description, 24 + accessType: "CLOSED", 25 + createdAt: new Date().toISOString(), 26 + }, 27 + }, 28 + }); 29 + } 30 + 31 + export async function getSembleCards(client: Client, repo: string) { 32 + return await client.get("com.atproto.repo.listRecords", { 33 + params: { 34 + repo: repo as ActorIdentifier, 35 + collection: "network.cosmik.card" as Nsid, 36 + limit: 100, 37 + }, 38 + }); 39 + } 40 + 41 + export async function createSembleNote(client: Client, repo: string, text: string, originalCard?: { uri: string; cid: string }) { 42 + return await client.post("com.atproto.repo.createRecord", { 43 + input: { 44 + repo: repo as ActorIdentifier, 45 + collection: "network.cosmik.card" as Nsid, 46 + record: { 47 + $type: "network.cosmik.card", 48 + type: "NOTE", 49 + content: { 50 + $type: "network.cosmik.card#noteContent", 51 + text, 52 + }, 53 + originalCard: originalCard ? { uri: originalCard.uri, cid: originalCard.cid } : undefined, 54 + createdAt: new Date().toISOString(), 55 + }, 56 + }, 57 + }); 58 + } 59 + 60 + export async function createSembleUrlCard(client: Client, repo: string, url: string, metadata?: { 61 + title?: string; 62 + description?: string; 63 + imageUrl?: string; 64 + siteName?: string; 65 + }) { 66 + return await client.post("com.atproto.repo.createRecord", { 67 + input: { 68 + repo: repo as ActorIdentifier, 69 + collection: "network.cosmik.card" as Nsid, 70 + record: { 71 + $type: "network.cosmik.card", 72 + type: "URL", 73 + url, 74 + content: { 75 + $type: "network.cosmik.card#urlContent", 76 + url, 77 + metadata: metadata ? { $type: "network.cosmik.card#urlMetadata", ...metadata } : undefined, 78 + }, 79 + createdAt: new Date().toISOString(), 80 + }, 81 + }, 82 + }); 83 + } 84 + 85 + export async function getSembleCollectionLinks(client: Client, repo: string) { 86 + return await client.get("com.atproto.repo.listRecords", { 87 + params: { 88 + repo: repo as ActorIdentifier, 89 + collection: "network.cosmik.collectionLink" as Nsid, 90 + limit: 100, 91 + }, 92 + }); 93 + } 94 + 95 + export async function createSembleCollectionLink( 96 + client: Client, 97 + repo: string, 98 + cardUri: string, 99 + cardCid: string, 100 + collectionUri: string, 101 + collectionCid: string 102 + ) { 103 + return await client.post("com.atproto.repo.createRecord", { 104 + input: { 105 + repo: repo as ActorIdentifier, 106 + collection: "network.cosmik.collectionLink" as Nsid, 107 + record: { 108 + $type: "network.cosmik.collectionLink", 109 + card: { 110 + uri: cardUri, 111 + cid: cardCid, 112 + }, 113 + collection: { 114 + uri: collectionUri, 115 + cid: collectionCid, 116 + }, 117 + addedAt: new Date().toISOString(), 118 + addedBy: repo, 119 + createdAt: new Date().toISOString(), 120 + }, 121 + }, 122 + }); 123 + }
+7 -32
src/main.ts
··· 3 3 import { DEFAULT_SETTINGS, AtProtoSettings, SettingTab } from "./settings"; 4 4 import { createAuthenticatedClient, createPublicClient } from "./auth"; 5 5 import { getProfile } from "./lib"; 6 - import { SembleCollectionsView, VIEW_TYPE_SEMBLE_COLLECTIONS } from "views/collections"; 7 - import { SembleCardsView, VIEW_TYPE_SEMBLE_CARDS } from "views/cards"; 6 + import { ATmarkView, VIEW_TYPE_ATMARK } from "views/atmark"; 8 7 import type { ProfileData } from "components/profileIcon"; 9 8 10 9 export default class ATmarkPlugin extends Plugin { ··· 16 15 await this.loadSettings(); 17 16 await this.initClient(); 18 17 19 - this.registerView(VIEW_TYPE_SEMBLE_COLLECTIONS, (leaf) => { 20 - return new SembleCollectionsView(leaf, this); 21 - }); 22 - 23 - this.registerView(VIEW_TYPE_SEMBLE_CARDS, (leaf) => { 24 - return new SembleCardsView(leaf, this); 25 - }); 26 - 27 - 28 - this.addCommand({ 29 - id: "view-semble-collections", 30 - name: "View semble collections", 31 - callback: () => { void this.activateView(VIEW_TYPE_SEMBLE_COLLECTIONS); }, 18 + this.registerView(VIEW_TYPE_ATMARK, (leaf) => { 19 + return new ATmarkView(leaf, this); 32 20 }); 33 21 34 22 this.addCommand({ 35 - id: "view-semble-cards", 36 - name: "View semble cards", 37 - callback: () => { void this.activateView(VIEW_TYPE_SEMBLE_CARDS); }, 23 + id: "view-atmark", 24 + name: "View ATmark", 25 + callback: () => { void this.activateView(VIEW_TYPE_ATMARK); }, 38 26 }); 39 27 40 28 this.addSettingTab(new SettingTab(this.app, this)); ··· 102 90 } 103 91 104 92 // Our view could not be found in the workspace, create a new leaf 105 - // in the right sidebar for it 106 - leaf = workspace.getRightLeaf(false); 107 - // leaf = workspace.getMostRecentLeaf() 93 + leaf = workspace.getMostRecentLeaf() 108 94 await leaf?.setViewState({ type: v, active: true }); 109 95 110 96 // "Reveal" the leaf in case it is in a collapsed sidebar 111 97 if (leaf) { 112 98 void workspace.revealLeaf(leaf); 113 99 } 114 - } 115 - 116 - async openCollection(uri: string, name: string) { 117 - const { workspace } = this.app; 118 - const leaf = workspace.getLeaf("tab"); 119 - await leaf.setViewState({ type: VIEW_TYPE_SEMBLE_CARDS, active: true }); 120 - 121 - const view = leaf.view as SembleCardsView; 122 - view.setCollection(uri, name); 123 - 124 - void workspace.revealLeaf(leaf); 125 100 } 126 101 127 102 async loadSettings() {
+216
src/sources/bookmark.ts
··· 1 + import type { Client } from "@atcute/client"; 2 + import type ATmarkPlugin from "../main"; 3 + import { getBookmarks } from "../lib"; 4 + import type { ATmarkItem, DataSource, SourceFilter } from "./types"; 5 + 6 + class BookmarkItem implements ATmarkItem { 7 + private record: any; 8 + private plugin: ATmarkPlugin; 9 + 10 + constructor(record: any, plugin: ATmarkPlugin) { 11 + this.record = record; 12 + this.plugin = plugin; 13 + } 14 + 15 + getUri(): string { 16 + return this.record.uri; 17 + } 18 + 19 + getCid(): string { 20 + return this.record.cid; 21 + } 22 + 23 + getCreatedAt(): string { 24 + return this.record.value.createdAt; 25 + } 26 + 27 + getSource(): "bookmark" { 28 + return "bookmark"; 29 + } 30 + 31 + canAddNotes(): boolean { 32 + return false; 33 + } 34 + 35 + render(container: HTMLElement): void { 36 + const el = container.createEl("div", { cls: "semble-card-content" }); 37 + const bookmark = this.record.value; 38 + const enriched = bookmark.enriched; 39 + 40 + // Display tags 41 + if (bookmark.tags && bookmark.tags.length > 0) { 42 + const tagsContainer = el.createEl("div", { cls: "semble-card-tags" }); 43 + for (const tag of bookmark.tags) { 44 + tagsContainer.createEl("span", { text: tag, cls: "semble-tag" }); 45 + } 46 + } 47 + 48 + const title = enriched?.title || bookmark.title; 49 + if (title) { 50 + el.createEl("div", { text: title, cls: "semble-card-title" }); 51 + } 52 + 53 + const imageUrl = enriched?.image || enriched?.thumb; 54 + if (imageUrl) { 55 + const img = el.createEl("img", { cls: "semble-card-image" }); 56 + img.src = imageUrl; 57 + img.alt = title || "Image"; 58 + } 59 + 60 + const description = enriched?.description || bookmark.description; 61 + if (description) { 62 + const desc = description.length > 200 63 + ? description.slice(0, 200) + "…" 64 + : description; 65 + el.createEl("p", { text: desc, cls: "semble-card-desc" }); 66 + } 67 + 68 + if (enriched?.siteName) { 69 + el.createEl("span", { text: enriched.siteName, cls: "semble-card-site" }); 70 + } 71 + 72 + const link = el.createEl("a", { 73 + text: bookmark.subject, 74 + href: bookmark.subject, 75 + cls: "semble-card-url", 76 + }); 77 + link.setAttr("target", "_blank"); 78 + } 79 + 80 + renderDetail(container: HTMLElement): void { 81 + const body = container.createEl("div", { cls: "semble-detail-body" }); 82 + const bookmark = this.record.value; 83 + const enriched = bookmark.enriched; 84 + 85 + const title = enriched?.title || bookmark.title; 86 + if (title) { 87 + body.createEl("h2", { text: title, cls: "semble-detail-title" }); 88 + } 89 + 90 + const imageUrl = enriched?.image || enriched?.thumb; 91 + if (imageUrl) { 92 + const img = body.createEl("img", { cls: "semble-detail-image" }); 93 + img.src = imageUrl; 94 + img.alt = title || "Image"; 95 + } 96 + 97 + const description = enriched?.description || bookmark.description; 98 + if (description) { 99 + body.createEl("p", { text: description, cls: "semble-detail-description" }); 100 + } 101 + 102 + if (enriched?.siteName) { 103 + const metaGrid = body.createEl("div", { cls: "semble-detail-meta" }); 104 + const item = metaGrid.createEl("div", { cls: "semble-detail-meta-item" }); 105 + item.createEl("span", { text: "Site", cls: "semble-detail-meta-label" }); 106 + item.createEl("span", { text: enriched.siteName, cls: "semble-detail-meta-value" }); 107 + } 108 + 109 + const linkWrapper = body.createEl("div", { cls: "semble-detail-link-wrapper" }); 110 + const link = linkWrapper.createEl("a", { 111 + text: bookmark.subject, 112 + href: bookmark.subject, 113 + cls: "semble-detail-link", 114 + }); 115 + link.setAttr("target", "_blank"); 116 + 117 + // Tags section 118 + if (bookmark.tags && bookmark.tags.length > 0) { 119 + const tagsSection = container.createEl("div", { cls: "semble-detail-tags-section" }); 120 + tagsSection.createEl("h3", { text: "Tags", cls: "semble-detail-section-title" }); 121 + const tagsContainer = tagsSection.createEl("div", { cls: "semble-card-tags" }); 122 + for (const tag of bookmark.tags) { 123 + tagsContainer.createEl("span", { text: tag, cls: "semble-tag" }); 124 + } 125 + } 126 + } 127 + 128 + getTags() { 129 + return this.record.value.tags || []; 130 + } 131 + 132 + getRecord() { 133 + return this.record; 134 + } 135 + } 136 + 137 + export class BookmarkSource implements DataSource { 138 + readonly name = "bookmark" as const; 139 + private client: Client; 140 + private repo: string; 141 + 142 + constructor(client: Client, repo: string) { 143 + this.client = client; 144 + this.repo = repo; 145 + } 146 + 147 + async fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]> { 148 + const bookmarksResp = await getBookmarks(this.client, this.repo); 149 + if (!bookmarksResp.ok) return []; 150 + 151 + let bookmarks = bookmarksResp.data.records; 152 + 153 + // Apply tag filter if specified 154 + const tagFilter = filters.find(f => f.type === "bookmarkTag"); 155 + if (tagFilter && tagFilter.value) { 156 + bookmarks = bookmarks.filter((record: any) => 157 + record.value.tags?.includes(tagFilter.value) 158 + ); 159 + } 160 + 161 + return bookmarks.map((record: any) => new BookmarkItem(record, plugin)); 162 + } 163 + 164 + async getAvailableFilters(): Promise<SourceFilter[]> { 165 + const bookmarksResp = await getBookmarks(this.client, this.repo); 166 + if (!bookmarksResp.ok) return []; 167 + 168 + // Extract unique tags 169 + const tagSet = new Set<string>(); 170 + const records = bookmarksResp.data.records as any[]; 171 + for (const record of records) { 172 + if (record.value?.tags) { 173 + for (const tag of record.value.tags) { 174 + tagSet.add(tag); 175 + } 176 + } 177 + } 178 + 179 + return Array.from(tagSet).map(tag => ({ 180 + type: "bookmarkTag", 181 + value: tag, 182 + label: tag, 183 + })); 184 + } 185 + 186 + renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void): void { 187 + const section = container.createEl("div", { cls: "atmark-filter-section" }); 188 + section.createEl("h3", { text: "Tags", cls: "atmark-filter-title" }); 189 + 190 + const chips = section.createEl("div", { cls: "atmark-filter-chips" }); 191 + 192 + // All chip 193 + const allChip = chips.createEl("button", { 194 + text: "All", 195 + cls: `atmark-chip ${!activeFilters.has("bookmarkTag") ? "atmark-chip-active" : ""}`, 196 + }); 197 + allChip.addEventListener("click", () => { 198 + activeFilters.delete("bookmarkTag"); 199 + onChange(); 200 + }); 201 + 202 + // Get tags and render chips 203 + void this.getAvailableFilters().then(tags => { 204 + for (const tag of tags) { 205 + const chip = chips.createEl("button", { 206 + text: (tag as any).label, 207 + cls: `atmark-chip ${activeFilters.get("bookmarkTag")?.value === tag.value ? "atmark-chip-active" : ""}`, 208 + }); 209 + chip.addEventListener("click", () => { 210 + activeFilters.set("bookmarkTag", tag); 211 + onChange(); 212 + }); 213 + } 214 + }); 215 + } 216 + }
+257
src/sources/semble.ts
··· 1 + import type { Client } from "@atcute/client"; 2 + import { setIcon } from "obsidian"; 3 + import type ATmarkPlugin from "../main"; 4 + import { getCards, getCollections, getCollectionLinks } from "../lib"; 5 + import type { NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card"; 6 + import type { ATmarkItem, DataSource, SourceFilter } from "./types"; 7 + 8 + class SembleItem implements ATmarkItem { 9 + private record: any; 10 + private attachedNotes: Array<{ uri: string; text: string }>; 11 + private plugin: ATmarkPlugin; 12 + 13 + constructor(record: any, attachedNotes: Array<{ uri: string; text: string }>, plugin: ATmarkPlugin) { 14 + this.record = record; 15 + this.attachedNotes = attachedNotes; 16 + this.plugin = plugin; 17 + } 18 + 19 + getUri(): string { 20 + return this.record.uri; 21 + } 22 + 23 + getCid(): string { 24 + return this.record.cid; 25 + } 26 + 27 + getCreatedAt(): string { 28 + return this.record.value.createdAt; 29 + } 30 + 31 + getSource(): "semble" { 32 + return "semble"; 33 + } 34 + 35 + canAddNotes(): boolean { 36 + return true; 37 + } 38 + 39 + render(container: HTMLElement): void { 40 + const el = container.createEl("div", { cls: "semble-card-content" }); 41 + 42 + // Display attached notes 43 + if (this.attachedNotes.length > 0) { 44 + for (const note of this.attachedNotes) { 45 + el.createEl("p", { text: note.text, cls: "semble-card-note" }); 46 + } 47 + } 48 + 49 + const card = this.record.value; 50 + 51 + if (card.type === "NOTE") { 52 + const content = card.content as NoteContent; 53 + el.createEl("p", { text: content.text, cls: "semble-card-text" }); 54 + } else if (card.type === "URL") { 55 + const content = card.content as UrlContent; 56 + const meta = content.metadata; 57 + 58 + if (meta?.title) { 59 + el.createEl("div", { text: meta.title, cls: "semble-card-title" }); 60 + } 61 + 62 + if (meta?.imageUrl) { 63 + const img = el.createEl("img", { cls: "semble-card-image" }); 64 + img.src = meta.imageUrl; 65 + img.alt = meta.title || "Image"; 66 + } 67 + 68 + if (meta?.description) { 69 + const desc = meta.description.length > 200 70 + ? meta.description.slice(0, 200) + "…" 71 + : meta.description; 72 + el.createEl("p", { text: desc, cls: "semble-card-desc" }); 73 + } 74 + 75 + if (meta?.siteName) { 76 + el.createEl("span", { text: meta.siteName, cls: "semble-card-site" }); 77 + } 78 + 79 + const link = el.createEl("a", { 80 + text: content.url, 81 + href: content.url, 82 + cls: "semble-card-url", 83 + }); 84 + link.setAttr("target", "_blank"); 85 + } 86 + } 87 + 88 + renderDetail(container: HTMLElement): void { 89 + const body = container.createEl("div", { cls: "semble-detail-body" }); 90 + const card = this.record.value; 91 + 92 + if (card.type === "NOTE") { 93 + const content = card.content as NoteContent; 94 + body.createEl("p", { text: content.text, cls: "semble-detail-text" }); 95 + } else if (card.type === "URL") { 96 + const content = card.content as UrlContent; 97 + const meta = content.metadata; 98 + 99 + if (meta?.title) { 100 + body.createEl("h2", { text: meta.title, cls: "semble-detail-title" }); 101 + } 102 + 103 + if (meta?.imageUrl) { 104 + const img = body.createEl("img", { cls: "semble-detail-image" }); 105 + img.src = meta.imageUrl; 106 + img.alt = meta.title || "Image"; 107 + } 108 + 109 + if (meta?.description) { 110 + body.createEl("p", { text: meta.description, cls: "semble-detail-description" }); 111 + } 112 + 113 + if (meta?.siteName) { 114 + const metaGrid = body.createEl("div", { cls: "semble-detail-meta" }); 115 + const item = metaGrid.createEl("div", { cls: "semble-detail-meta-item" }); 116 + item.createEl("span", { text: "Site", cls: "semble-detail-meta-label" }); 117 + item.createEl("span", { text: meta.siteName, cls: "semble-detail-meta-value" }); 118 + } 119 + 120 + const linkWrapper = body.createEl("div", { cls: "semble-detail-link-wrapper" }); 121 + const link = linkWrapper.createEl("a", { 122 + text: content.url, 123 + href: content.url, 124 + cls: "semble-detail-link", 125 + }); 126 + link.setAttr("target", "_blank"); 127 + } 128 + 129 + // Attached notes section 130 + if (this.attachedNotes.length > 0) { 131 + const notesSection = container.createEl("div", { cls: "semble-detail-notes-section" }); 132 + notesSection.createEl("h3", { text: "Notes", cls: "semble-detail-section-title" }); 133 + 134 + for (const note of this.attachedNotes) { 135 + const noteEl = notesSection.createEl("div", { cls: "semble-detail-note" }); 136 + 137 + const noteContent = noteEl.createEl("div", { cls: "semble-detail-note-content" }); 138 + const noteIcon = noteContent.createEl("span", { cls: "semble-detail-note-icon" }); 139 + setIcon(noteIcon, "message-square"); 140 + noteContent.createEl("p", { text: note.text, cls: "semble-detail-note-text" }); 141 + 142 + // Note: delete functionality would need to be handled by the modal 143 + } 144 + } 145 + } 146 + 147 + getAttachedNotes() { 148 + return this.attachedNotes; 149 + } 150 + 151 + getRecord() { 152 + return this.record; 153 + } 154 + } 155 + 156 + export class SembleSource implements DataSource { 157 + readonly name = "semble" as const; 158 + private client: Client; 159 + private repo: string; 160 + 161 + constructor(client: Client, repo: string) { 162 + this.client = client; 163 + this.repo = repo; 164 + } 165 + 166 + async fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]> { 167 + const cardsResp = await getCards(this.client, this.repo); 168 + if (!cardsResp.ok) return []; 169 + 170 + const allSembleCards = cardsResp.data.records; 171 + 172 + // Build notes map 173 + const notesMap = new Map<string, Array<{ uri: string; text: string }>>(); 174 + for (const record of allSembleCards as any[]) { 175 + if (record.value.type === "NOTE") { 176 + const parentUri = record.value.originalCard?.uri || record.value.parentCard?.uri; 177 + if (parentUri) { 178 + const noteContent = record.value.content as NoteContent; 179 + const existing = notesMap.get(parentUri) || []; 180 + existing.push({ uri: record.uri, text: noteContent.text }); 181 + notesMap.set(parentUri, existing); 182 + } 183 + } 184 + } 185 + 186 + // Filter out NOTE cards that are attached to other cards 187 + let sembleCards = allSembleCards.filter((record: any) => { 188 + if (record.value.type === "NOTE") { 189 + const hasParent = record.value.originalCard?.uri || record.value.parentCard?.uri; 190 + return !hasParent; 191 + } 192 + return true; 193 + }); 194 + 195 + // Apply collection filter if specified 196 + const collectionFilter = filters.find(f => f.type === "sembleCollection"); 197 + if (collectionFilter && collectionFilter.value) { 198 + const linksResp = await getCollectionLinks(this.client, this.repo); 199 + if (linksResp.ok) { 200 + const links = linksResp.data.records.filter((link: any) => 201 + link.value.collection.uri === collectionFilter.value 202 + ); 203 + const cardUris = new Set(links.map((link: any) => link.value.card.uri)); 204 + sembleCards = sembleCards.filter((card: any) => cardUris.has(card.uri)); 205 + } 206 + } 207 + 208 + // Create SembleItem objects 209 + return sembleCards.map((record: any) => 210 + new SembleItem(record, notesMap.get(record.uri) || [], plugin) 211 + ); 212 + } 213 + 214 + async getAvailableFilters(): Promise<SourceFilter[]> { 215 + const collectionsResp = await getCollections(this.client, this.repo); 216 + if (!collectionsResp.ok) return []; 217 + 218 + const collections = collectionsResp.data.records; 219 + return collections.map((c: any) => ({ 220 + type: "sembleCollection", 221 + value: c.uri, 222 + label: c.value.name, 223 + })); 224 + } 225 + 226 + renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void): void { 227 + const section = container.createEl("div", { cls: "atmark-filter-section" }); 228 + section.createEl("h3", { text: "Semble Collections", cls: "atmark-filter-title" }); 229 + 230 + const chips = section.createEl("div", { cls: "atmark-filter-chips" }); 231 + 232 + // All chip 233 + const allChip = chips.createEl("button", { 234 + text: "All", 235 + cls: `atmark-chip ${!activeFilters.has("sembleCollection") ? "atmark-chip-active" : ""}`, 236 + }); 237 + allChip.addEventListener("click", () => { 238 + activeFilters.delete("sembleCollection"); 239 + onChange(); 240 + }); 241 + 242 + // Get collections synchronously - note: this is a limitation 243 + // In a real app, we'd want to cache these or handle async properly 244 + void this.getAvailableFilters().then(collections => { 245 + for (const collection of collections) { 246 + const chip = chips.createEl("button", { 247 + text: (collection as any).label, 248 + cls: `atmark-chip ${activeFilters.get("sembleCollection") === collection.value ? "atmark-chip-active" : ""}`, 249 + }); 250 + chip.addEventListener("click", () => { 251 + activeFilters.set("sembleCollection", collection); 252 + onChange(); 253 + }); 254 + } 255 + }); 256 + } 257 + }
+23
src/sources/types.ts
··· 1 + import type ATmarkPlugin from "../main"; 2 + 3 + export interface ATmarkItem { 4 + render(container: HTMLElement): void; 5 + renderDetail(container: HTMLElement): void; 6 + canAddNotes(): boolean; 7 + getUri(): string; 8 + getCid(): string; 9 + getCreatedAt(): string; 10 + getSource(): "semble" | "bookmark"; 11 + } 12 + 13 + export interface SourceFilter { 14 + type: string; 15 + value: any; 16 + } 17 + 18 + export interface DataSource { 19 + readonly name: "semble" | "bookmark"; 20 + fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]>; 21 + getAvailableFilters(): Promise<SourceFilter[]>; 22 + renderFilterUI(container: HTMLElement, activeFilters: Map<string, any>, onChange: () => void): void; 23 + }
+172
src/views/atmark.ts
··· 1 + import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; 2 + import type ATmarkPlugin from "../main"; 3 + import { renderProfileIcon } from "../components/profileIcon"; 4 + import { CardDetailModal } from "../components/cardDetailModal"; 5 + import type { ATmarkItem } from "../sources/types"; 6 + import { SembleSource } from "../sources/semble"; 7 + import { BookmarkSource } from "../sources/bookmark"; 8 + 9 + export const VIEW_TYPE_ATMARK = "atmark-view"; 10 + 11 + type SourceType = "semble" | "bookmark"; 12 + 13 + export class ATmarkView extends ItemView { 14 + plugin: ATmarkPlugin; 15 + activeSource: SourceType = "semble"; 16 + sources: Map<SourceType, { source: any; filters: Map<string, any> }> = new Map(); 17 + 18 + constructor(leaf: WorkspaceLeaf, plugin: ATmarkPlugin) { 19 + super(leaf); 20 + this.plugin = plugin; 21 + 22 + // Initialize sources 23 + if (this.plugin.client) { 24 + const repo = this.plugin.settings.identifier; 25 + this.sources.set("semble", { 26 + source: new SembleSource(this.plugin.client, repo), 27 + filters: new Map() 28 + }); 29 + this.sources.set("bookmark", { 30 + source: new BookmarkSource(this.plugin.client, repo), 31 + filters: new Map() 32 + }); 33 + } 34 + } 35 + 36 + getViewType() { 37 + return VIEW_TYPE_ATMARK; 38 + } 39 + 40 + getDisplayText() { 41 + return "ATmark"; 42 + } 43 + 44 + getIcon() { 45 + return "bookmark"; 46 + } 47 + 48 + async onOpen() { 49 + await this.render(); 50 + } 51 + 52 + async fetchItems(): Promise<ATmarkItem[]> { 53 + if (!this.plugin.client) return []; 54 + 55 + const sourceData = this.sources.get(this.activeSource); 56 + if (!sourceData) return []; 57 + 58 + const filters = Array.from(sourceData.filters.values()); 59 + return await sourceData.source.fetchItems(filters, this.plugin); 60 + } 61 + 62 + async render() { 63 + const container = this.contentEl; 64 + container.empty(); 65 + container.addClass("atmark-view"); 66 + 67 + if (!this.plugin.client) { 68 + container.createEl("p", { text: "Not connected." }); 69 + return; 70 + } 71 + 72 + const loading = container.createEl("p", { text: "Loading..." }); 73 + 74 + try { 75 + const items = await this.fetchItems(); 76 + loading.remove(); 77 + 78 + this.renderHeader(container); 79 + 80 + if (items.length === 0) { 81 + container.createEl("p", { text: "No items found." }); 82 + return; 83 + } 84 + 85 + const grid = container.createEl("div", { cls: "atmark-grid" }); 86 + for (const item of items) { 87 + try { 88 + this.renderItem(grid, item); 89 + } catch (err) { 90 + const message = err instanceof Error ? err.message : String(err); 91 + console.error(`Failed to render item ${item.getUri()}: ${message}`); 92 + } 93 + } 94 + } catch (err) { 95 + loading.remove(); 96 + const message = err instanceof Error ? err.message : String(err); 97 + container.createEl("p", { text: `Failed to load: ${message}`, cls: "atmark-error" }); 98 + } 99 + } 100 + 101 + private renderHeader(container: HTMLElement) { 102 + const header = container.createEl("div", { cls: "atmark-header" }); 103 + const nav = header.createEl("div", { cls: "atmark-nav" }); 104 + 105 + // eslint-disable-next-line obsidianmd/ui/sentence-case 106 + nav.createEl("h1", { text: "ATmark", cls: "atmark-title" }); 107 + 108 + // Source selector in the center 109 + const sourceSelector = nav.createEl("div", { cls: "atmark-source-selector" }); 110 + const sources: SourceType[] = ["semble", "bookmark"]; 111 + 112 + for (const source of sources) { 113 + const label = sourceSelector.createEl("label", { cls: "atmark-source-option" }); 114 + 115 + const radio = label.createEl("input", { 116 + type: "radio", 117 + cls: "atmark-source-radio", 118 + }); 119 + radio.name = "atmark-source"; 120 + radio.checked = this.activeSource === source; 121 + radio.addEventListener("change", () => { 122 + this.activeSource = source; 123 + void this.render(); 124 + }); 125 + 126 + label.createEl("span", { 127 + text: source.charAt(0).toUpperCase() + source.slice(1), 128 + cls: "atmark-source-text", 129 + }); 130 + } 131 + 132 + renderProfileIcon(nav, this.plugin.profile); 133 + 134 + // Let the active source render its filters 135 + const filtersContainer = container.createEl("div", { cls: "atmark-filters" }); 136 + const sourceData = this.sources.get(this.activeSource); 137 + if (sourceData) { 138 + sourceData.source.renderFilterUI( 139 + filtersContainer, 140 + sourceData.filters, 141 + () => void this.render() 142 + ); 143 + } 144 + } 145 + 146 + private renderItem(container: HTMLElement, item: ATmarkItem) { 147 + const el = container.createEl("div", { cls: "atmark-item" }); 148 + 149 + el.addEventListener("click", () => { 150 + new CardDetailModal(this.plugin, item, () => { 151 + void this.render(); 152 + }).open(); 153 + }); 154 + 155 + const header = el.createEl("div", { cls: "atmark-item-header" }); 156 + const source = item.getSource(); 157 + header.createEl("span", { 158 + text: source, 159 + cls: `atmark-badge atmark-badge-${source}`, 160 + }); 161 + 162 + item.render(el); 163 + 164 + const footer = el.createEl("div", { cls: "atmark-item-footer" }); 165 + footer.createEl("span", { 166 + text: new Date(item.getCreatedAt()).toLocaleDateString(), 167 + cls: "atmark-date", 168 + }); 169 + } 170 + 171 + async onClose() { } 172 + }
-326
src/views/cards.ts
··· 1 - import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; 2 - import type ATmarkPlugin from "../main"; 3 - import { getCollections, getCollectionLinks, getCards } from "../lib"; 4 - import type { Main as Card, NoteContent, UrlContent } from "../lexicons/types/network/cosmik/card"; 5 - import type { Main as CollectionLink } from "../lexicons/types/network/cosmik/collectionLink"; 6 - import type { Main as Collection } from "../lexicons/types/network/cosmik/collection"; 7 - import { VIEW_TYPE_SEMBLE_COLLECTIONS } from "./collections"; 8 - import { renderProfileIcon } from "../components/profileIcon"; 9 - import { EditCardModal } from "../components/editCardModal"; 10 - import { CardDetailModal } from "../components/cardDetailModal"; 11 - 12 - export const VIEW_TYPE_SEMBLE_CARDS = "semble-cards-view"; 13 - 14 - interface CardRecord { 15 - uri: string; 16 - cid: string; 17 - value: Card; 18 - } 19 - 20 - export interface AttachedNote { 21 - uri: string; 22 - text: string; 23 - } 24 - 25 - export interface CardWithNotes extends CardRecord { 26 - attachedNotes: AttachedNote[]; 27 - } 28 - 29 - interface CollectionLinkRecord { 30 - uri: string; 31 - value: CollectionLink; 32 - } 33 - 34 - interface CollectionRecord { 35 - uri: string; 36 - value: Collection; 37 - } 38 - 39 - export class SembleCardsView extends ItemView { 40 - plugin: ATmarkPlugin; 41 - collectionUri: string | null = null; 42 - collectionName: string = "All Cards"; 43 - 44 - constructor(leaf: WorkspaceLeaf, plugin: ATmarkPlugin) { 45 - super(leaf); 46 - this.plugin = plugin; 47 - } 48 - 49 - getViewType() { 50 - return VIEW_TYPE_SEMBLE_CARDS; 51 - } 52 - 53 - getDisplayText() { 54 - return this.collectionName; 55 - } 56 - 57 - getIcon() { 58 - return "layers"; 59 - } 60 - 61 - setCollection(uri: string | null, name: string) { 62 - this.collectionUri = uri; 63 - this.collectionName = name; 64 - void this.render(); 65 - } 66 - 67 - async onOpen() { 68 - await this.render(); 69 - } 70 - 71 - /** 72 - * Process cards to attach notes to their parent cards and filter out attached notes. 73 - * Notes with originalCard or parentCard references are attached to those cards 74 - * instead of being shown as separate cards. 75 - */ 76 - private processCardsWithNotes(cards: CardRecord[]): CardWithNotes[] { 77 - // Build a map of card URI -> attached notes with their URIs 78 - const notesMap = new Map<string, AttachedNote[]>(); 79 - 80 - // Find all NOTE cards that reference other cards 81 - for (const record of cards) { 82 - if (record.value.type === "NOTE") { 83 - const parentUri = record.value.originalCard?.uri || record.value.parentCard?.uri; 84 - if (parentUri) { 85 - const noteContent = record.value.content as NoteContent; 86 - const existing = notesMap.get(parentUri) || []; 87 - existing.push({ uri: record.uri, text: noteContent.text }); 88 - notesMap.set(parentUri, existing); 89 - } 90 - } 91 - } 92 - 93 - // Filter out NOTE cards that are attached to other cards 94 - const filteredCards = cards.filter((record) => { 95 - if (record.value.type === "NOTE") { 96 - const hasParent = record.value.originalCard?.uri || record.value.parentCard?.uri; 97 - return !hasParent; // Only keep standalone notes 98 - } 99 - return true; 100 - }); 101 - 102 - // Add attached notes to each card 103 - return filteredCards.map((record) => ({ 104 - ...record, 105 - attachedNotes: notesMap.get(record.uri) || [], 106 - })); 107 - } 108 - 109 - async getAllCards(): Promise<CardWithNotes[]> { 110 - if (!this.plugin.client) return []; 111 - 112 - const repo = this.plugin.settings.identifier; 113 - const cardsResp = await getCards(this.plugin.client, repo); 114 - if (!cardsResp.ok) return []; 115 - const cards = cardsResp.data.records as unknown as CardRecord[]; 116 - return this.processCardsWithNotes(cards); 117 - } 118 - 119 - async getCardsInCollection(collectionUri: string): Promise<CardWithNotes[]> { 120 - if (!this.plugin.client) return []; 121 - 122 - const repo = this.plugin.settings.identifier; 123 - const [linksResp, cardsResp] = await Promise.all([ 124 - getCollectionLinks(this.plugin.client, repo), 125 - getCards(this.plugin.client, repo), 126 - ]); 127 - 128 - if (!linksResp.ok || !cardsResp.ok) return []; 129 - const allLinks = linksResp.data.records as unknown as CollectionLinkRecord[]; 130 - const allCards = cardsResp.data.records as unknown as CardRecord[]; 131 - 132 - // Filter links by collection 133 - const links = allLinks.filter((link) => link.value.collection.uri === collectionUri); 134 - 135 - // Get cards in collection 136 - const cardUris = new Set(links.map((link) => String(link.value.card.uri))); 137 - const cards = allCards.filter((card) => cardUris.has(String(card.uri))); 138 - 139 - return this.processCardsWithNotes(cards); 140 - } 141 - 142 - async render() { 143 - const container = this.contentEl; 144 - container.empty(); 145 - container.addClass("semble-cards-view"); 146 - 147 - if (!this.plugin.client) { 148 - container.createEl("p", { text: "Not connected." }); 149 - return; 150 - } 151 - 152 - const loading = container.createEl("p", { text: "Loading..." }); 153 - 154 - try { 155 - 156 - let cards: CardWithNotes[] = []; 157 - try { 158 - if (this.collectionUri) { 159 - cards = await this.getCardsInCollection(this.collectionUri); 160 - } else { 161 - cards = await this.getAllCards(); 162 - } 163 - } catch (err) { 164 - loading.remove(); 165 - const message = err instanceof Error ? err.message : String(err); 166 - container.createEl("p", { text: `Failed to load cards: ${message}`, cls: "semble-error" }); 167 - return; 168 - } 169 - 170 - const collectionsResp = await getCollections(this.plugin.client, this.plugin.settings.identifier); 171 - if (!collectionsResp.ok) { 172 - loading.remove(); 173 - const errorMsg = collectionsResp.data?.error ? String(collectionsResp.data.error) : "Unknown error"; 174 - container.createEl("p", { text: `Failed to load collections: ${errorMsg}`, cls: "semble-error" }); 175 - return; 176 - } 177 - const collections = collectionsResp.data?.records as unknown as CollectionRecord[]; 178 - 179 - loading.remove(); 180 - 181 - // Render header with back button and filters 182 - this.renderHeader(container, collections); 183 - 184 - if (cards.length === 0) { 185 - container.createEl("p", { text: "No cards found." }); 186 - return; 187 - } 188 - 189 - const grid = container.createEl("div", { cls: "semble-card-grid" }); 190 - for (const record of cards) { 191 - try { 192 - this.renderCard(grid, record); 193 - } catch (err) { 194 - const message = err instanceof Error ? err.message : String(err); 195 - console.error(`Failed to render card ${record.uri}: ${message}`); 196 - } 197 - } 198 - } catch (err) { 199 - loading.remove(); 200 - const message = err instanceof Error ? err.message : String(err); 201 - container.createEl("p", { text: `Failed to load: ${message}`, cls: "semble-error" }); 202 - } 203 - } 204 - 205 - private renderHeader(container: HTMLElement, collections: CollectionRecord[]) { 206 - const header = container.createEl("div", { cls: "semble-page-header" }); 207 - 208 - const nav = header.createEl("div", { cls: "semble-nav-row" }); 209 - 210 - // Back button 211 - const backBtn = nav.createEl("button", { cls: "semble-back-btn" }); 212 - setIcon(backBtn, "arrow-left"); 213 - backBtn.addEventListener("click", () => { 214 - void this.plugin.activateView(VIEW_TYPE_SEMBLE_COLLECTIONS); 215 - }); 216 - 217 - nav.createEl("span", { text: "Semble", cls: "semble-brand" }); 218 - 219 - renderProfileIcon(nav, this.plugin.profile); 220 - 221 - header.createEl("h2", { text: this.collectionName, cls: "semble-page-title" }); 222 - 223 - // Filter chips 224 - const filters = container.createEl("div", { cls: "semble-filter-chips" }); 225 - 226 - // All chip 227 - const allChip = filters.createEl("button", { 228 - text: "All", 229 - cls: `semble-chip ${!this.collectionUri ? "semble-chip-active" : ""}`, 230 - }); 231 - allChip.addEventListener("click", () => { 232 - this.setCollection(null, "All Cards"); 233 - }); 234 - 235 - // Collection chips 236 - for (const record of collections) { 237 - const chip = filters.createEl("button", { 238 - text: record.value.name, 239 - cls: `semble-chip ${this.collectionUri === record.uri ? "semble-chip-active" : ""}`, 240 - }); 241 - chip.addEventListener("click", () => { 242 - this.setCollection(record.uri, record.value.name); 243 - }); 244 - } 245 - } 246 - 247 - private renderCard(container: HTMLElement, record: CardWithNotes) { 248 - const card = record.value; 249 - const el = container.createEl("div", { cls: "semble-card" }); 250 - 251 - // Open detail modal on click 252 - el.addEventListener("click", () => { 253 - new CardDetailModal(this.plugin, record, () => { 254 - void this.render(); 255 - }).open(); 256 - }); 257 - 258 - const header = el.createEl("div", { cls: "semble-card-header" }); 259 - header.createEl("span", { 260 - text: card.type, 261 - cls: `semble-badge semble-badge-${card.type?.toLowerCase() || "unknown"}`, 262 - }); 263 - 264 - const addBtn = header.createEl("button", { cls: "semble-card-menu-btn" }); 265 - setIcon(addBtn, "more-vertical"); 266 - addBtn.setAttribute("aria-label", "Manage collections"); 267 - addBtn.addEventListener("click", (e) => { 268 - e.stopPropagation(); 269 - new EditCardModal(this.plugin, record.uri, record.cid, () => { 270 - void this.render(); 271 - }).open(); 272 - }); 273 - 274 - // Display attached notes at the top of the card 275 - if (record.attachedNotes.length > 0) { 276 - for (const note of record.attachedNotes) { 277 - el.createEl("p", { text: note.text, cls: "semble-card-note" }); 278 - } 279 - } 280 - 281 - if (card.type === "NOTE") { 282 - const content = card.content as NoteContent; 283 - el.createEl("p", { text: content.text, cls: "semble-card-text" }); 284 - } else if (card.type === "URL") { 285 - const content = card.content as UrlContent; 286 - const meta = content.metadata; 287 - 288 - if (meta?.title) { 289 - el.createEl("div", { text: meta.title, cls: "semble-card-title" }); 290 - } 291 - 292 - if (meta?.imageUrl) { 293 - const img = el.createEl("img", { cls: "semble-card-image" }); 294 - img.src = meta.imageUrl; 295 - img.alt = meta.title || "Image for " + content.url; 296 - } 297 - 298 - if (meta?.description) { 299 - const desc = meta.description.length > 200 300 - ? meta.description.slice(0, 200) + "…" 301 - : meta.description; 302 - el.createEl("p", { text: desc, cls: "semble-card-desc" }); 303 - } 304 - if (meta?.siteName) { 305 - el.createEl("span", { text: meta.siteName, cls: "semble-card-site" }); 306 - } 307 - 308 - const link = el.createEl("a", { 309 - text: content.url, 310 - href: content.url, 311 - cls: "semble-card-url", 312 - }); 313 - link.setAttr("target", "_blank"); 314 - } 315 - 316 - const footer = el.createEl("div", { cls: "semble-card-footer" }); 317 - if (card.createdAt) { 318 - footer.createEl("span", { 319 - text: new Date(card.createdAt).toLocaleDateString(), 320 - cls: "semble-card-date", 321 - }); 322 - } 323 - } 324 - 325 - async onClose() { } 326 - }
-155
src/views/collections.ts
··· 1 - import { ItemView, WorkspaceLeaf, setIcon } from "obsidian"; 2 - import type ATmarkPlugin from "../main"; 3 - import { getCollections } from "../lib"; 4 - import type { Main as Collection } from "../lexicons/types/network/cosmik/collection"; 5 - import { SembleCardsView, VIEW_TYPE_SEMBLE_CARDS } from "./cards"; 6 - import { renderProfileIcon } from "../components/profileIcon"; 7 - import { CreateCollectionModal } from "../components/createCollectionModal"; 8 - 9 - export const VIEW_TYPE_SEMBLE_COLLECTIONS = "semble-collections-view"; 10 - 11 - interface CollectionRecord { 12 - uri: string; 13 - value: Collection; 14 - } 15 - 16 - export class SembleCollectionsView extends ItemView { 17 - plugin: ATmarkPlugin; 18 - 19 - constructor(leaf: WorkspaceLeaf, plugin: ATmarkPlugin) { 20 - super(leaf); 21 - this.plugin = plugin; 22 - } 23 - 24 - getViewType() { 25 - return VIEW_TYPE_SEMBLE_COLLECTIONS; 26 - } 27 - 28 - getDisplayText() { 29 - return "Semble collections"; 30 - } 31 - 32 - getIcon() { 33 - return "layout-grid"; 34 - } 35 - 36 - async onOpen() { 37 - await this.render(); 38 - } 39 - 40 - async openCollection(uri: string, name: string) { 41 - const { workspace } = this.app; 42 - const leaf = workspace.getLeaf("tab"); 43 - await leaf.setViewState({ type: VIEW_TYPE_SEMBLE_CARDS, active: true }); 44 - 45 - const view = leaf.view as SembleCardsView; 46 - view.setCollection(uri, name); 47 - 48 - void workspace.revealLeaf(leaf); 49 - } 50 - 51 - async render() { 52 - const container = this.contentEl; 53 - container.empty(); 54 - container.addClass("semble-collections-view"); 55 - 56 - const header = container.createEl("div", { cls: "semble-page-header" }); 57 - const nav = header.createEl("div", { cls: "semble-nav-row" }); 58 - nav.createEl("span", { text: "Semble", cls: "semble-brand" }); 59 - 60 - renderProfileIcon(nav, this.plugin.profile); 61 - 62 - header.createEl("h2", { text: "Collections", cls: "semble-page-title" }); 63 - 64 - if (!this.plugin.client) { 65 - container.createEl("p", { text: "Not connected. Configure credentials in settings." }); 66 - return; 67 - } 68 - 69 - const repo = this.plugin.settings.identifier; 70 - if (!repo) { 71 - container.createEl("p", { text: "No identifier configured in settings." }); 72 - return; 73 - } 74 - 75 - const toolbar = container.createEl("div", { cls: "semble-toolbar" }); 76 - 77 - const createBtn = toolbar.createEl("button", { cls: "semble-create-btn" }); 78 - setIcon(createBtn, "plus"); 79 - createBtn.createEl("span", { text: "New collection" }); 80 - createBtn.addEventListener("click", () => { 81 - new CreateCollectionModal(this.plugin, () => { void this.render(); }).open(); 82 - }); 83 - 84 - const allCardsBtn = toolbar.createEl("button", { cls: "semble-toolbar-btn" }); 85 - setIcon(allCardsBtn, "layers"); 86 - allCardsBtn.createEl("span", { text: "All cards" }); 87 - allCardsBtn.addEventListener("click", () => { 88 - void this.plugin.activateView(VIEW_TYPE_SEMBLE_CARDS); 89 - }); 90 - 91 - const loading = container.createEl("p", { text: "Loading..." }); 92 - 93 - try { 94 - const resp = await getCollections(this.plugin.client, repo); 95 - loading.remove(); 96 - 97 - if (!resp.ok) { 98 - const errorMsg = resp.data?.error ? String(resp.data.error) : "Unknown error"; 99 - container.createEl("p", { text: `Error: ${errorMsg}`, cls: "semble-error" }); 100 - return; 101 - } 102 - 103 - const records = resp.data.records as unknown as CollectionRecord[]; 104 - 105 - if (records.length === 0) { 106 - container.createEl("p", { text: "No collections found." }); 107 - return; 108 - } 109 - 110 - const grid = container.createEl("div", { cls: "semble-card-grid" }); 111 - 112 - for (const record of records) { 113 - const col = record.value; 114 - const card = grid.createEl("div", { cls: "semble-card" }); 115 - 116 - card.addEventListener("click", () => { 117 - void this.plugin.openCollection(record.uri, col.name); 118 - }); 119 - 120 - const cardHeader = card.createEl("div", { cls: "semble-card-header" }); 121 - cardHeader.createEl("span", { text: col.name, cls: "semble-card-title" }); 122 - 123 - const accessIcon = cardHeader.createEl("span", { 124 - cls: `semble-access-icon semble-access-${col.accessType.toLowerCase()}`, 125 - attr: { "aria-label": col.accessType }, 126 - }); 127 - setIcon(accessIcon, col.accessType === "OPEN" ? "globe" : "lock"); 128 - 129 - if (col.description) { 130 - card.createEl("p", { text: col.description, cls: "semble-card-desc" }); 131 - } 132 - 133 - const footer = card.createEl("div", { cls: "semble-card-footer" }); 134 - if (col.createdAt) { 135 - footer.createEl("span", { 136 - text: new Date(col.createdAt).toLocaleDateString(), 137 - cls: "semble-card-date", 138 - }); 139 - } 140 - if (col.collaborators?.length) { 141 - footer.createEl("span", { 142 - text: `${col.collaborators.length} collaborators`, 143 - cls: "semble-card-collabs", 144 - }); 145 - } 146 - } 147 - } catch (err) { 148 - loading.remove(); 149 - const message = err instanceof Error ? err.message : String(err); 150 - container.createEl("p", { text: `Failed to load: ${message}`, cls: "semble-error" }); 151 - } 152 - } 153 - 154 - async onClose() {} 155 - }
+266
styles.css
··· 1 + /* ATmark View */ 2 + .atmark-view { 3 + padding: 20px; 4 + } 5 + 6 + .atmark-header { 7 + margin-bottom: 24px; 8 + padding-bottom: 16px; 9 + border-bottom: 1px solid var(--background-modifier-border); 10 + } 11 + 12 + .atmark-nav { 13 + position: relative; 14 + display: flex; 15 + align-items: center; 16 + justify-content: space-between; 17 + margin-bottom: 16px; 18 + } 19 + 20 + .atmark-title { 21 + margin: 0; 22 + font-size: var(--h1-size); 23 + font-weight: var(--font-bold); 24 + color: var(--text-accent); 25 + } 26 + 27 + .atmark-source-selector { 28 + position: absolute; 29 + left: 50%; 30 + transform: translateX(-50%); 31 + display: flex; 32 + align-items: center; 33 + gap: 8px; 34 + } 35 + 36 + .atmark-source-option { 37 + display: flex; 38 + align-items: center; 39 + justify-content: center; 40 + gap: 6px; 41 + padding: 8px 20px; 42 + cursor: pointer; 43 + user-select: none; 44 + border-radius: var(--radius-m); 45 + border: 1px solid var(--background-modifier-border); 46 + background: var(--background-secondary); 47 + transition: all 0.15s ease; 48 + } 49 + 50 + .atmark-source-option:hover { 51 + background: var(--background-modifier-hover); 52 + border-color: var(--background-modifier-border-hover); 53 + } 54 + 55 + .atmark-source-option:has(input:checked) { 56 + background: var(--interactive-accent); 57 + border-color: var(--interactive-accent); 58 + } 59 + 60 + .atmark-source-option:has(input:checked) .atmark-source-text { 61 + color: var(--text-on-accent); 62 + } 63 + 64 + .atmark-source-radio { 65 + display: none; 66 + } 67 + 68 + .atmark-source-text { 69 + font-size: var(--font-small); 70 + font-weight: var(--font-medium); 71 + color: var(--text-normal); 72 + } 73 + 74 + .atmark-filters { 75 + display: flex; 76 + flex-direction: column; 77 + gap: 12px; 78 + margin-bottom: 16px; 79 + } 80 + 81 + .atmark-filter-section { 82 + display: flex; 83 + flex-direction: column; 84 + gap: 8px; 85 + } 86 + 87 + .atmark-filter-title { 88 + margin: 0; 89 + font-size: var(--font-small); 90 + font-weight: var(--font-semibold); 91 + color: var(--text-muted); 92 + text-transform: uppercase; 93 + letter-spacing: 0.5px; 94 + } 95 + 96 + .atmark-filter-chips { 97 + display: flex; 98 + flex-wrap: wrap; 99 + gap: 8px; 100 + } 101 + 102 + .atmark-chip { 103 + padding: 6px 14px; 104 + border-radius: var(--radius-full); 105 + border: 1px solid var(--background-modifier-border); 106 + background: var(--background-secondary); 107 + color: var(--text-muted); 108 + font-size: var(--font-small); 109 + cursor: pointer; 110 + transition: all 0.15s ease; 111 + } 112 + 113 + .atmark-chip:hover { 114 + background: var(--background-modifier-hover); 115 + color: var(--text-normal); 116 + } 117 + 118 + .atmark-chip-active { 119 + background: var(--interactive-accent); 120 + color: var(--text-on-accent); 121 + border-color: var(--interactive-accent); 122 + } 123 + 124 + .atmark-chip-active:hover { 125 + background: var(--interactive-accent-hover); 126 + } 127 + 128 + .atmark-grid { 129 + display: grid; 130 + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 131 + gap: 16px; 132 + padding: 8px 0; 133 + } 134 + 135 + .atmark-item { 136 + background: var(--background-secondary); 137 + border: 1px solid var(--background-modifier-border); 138 + border-radius: var(--radius-m); 139 + padding: 16px; 140 + display: flex; 141 + flex-direction: column; 142 + gap: 8px; 143 + transition: box-shadow 0.15s ease, border-color 0.15s ease; 144 + cursor: pointer; 145 + } 146 + 147 + .atmark-item:hover { 148 + box-shadow: var(--shadow-s); 149 + border-color: var(--background-modifier-border-hover); 150 + } 151 + 152 + .atmark-item-header { 153 + display: flex; 154 + justify-content: space-between; 155 + align-items: flex-start; 156 + gap: 8px; 157 + } 158 + 159 + .atmark-badge { 160 + font-size: var(--font-smallest); 161 + padding: 2px 8px; 162 + border-radius: var(--radius-s); 163 + text-transform: uppercase; 164 + font-weight: var(--font-medium); 165 + flex-shrink: 0; 166 + opacity: 0.8; 167 + } 168 + 169 + .atmark-badge-semble { 170 + background: var(--color-green); 171 + color: var(--text-on-accent); 172 + } 173 + 174 + .atmark-badge-bookmark { 175 + background: var(--color-cyan); 176 + color: var(--text-on-accent); 177 + } 178 + 179 + .atmark-item-footer { 180 + display: flex; 181 + justify-content: space-between; 182 + font-size: var(--font-smallest); 183 + color: var(--text-faint); 184 + margin-top: auto; 185 + padding-top: 8px; 186 + border-top: 1px solid var(--background-modifier-border); 187 + } 188 + 189 + .atmark-date { 190 + font-size: var(--font-smallest); 191 + color: var(--text-faint); 192 + } 193 + 194 + .atmark-error { 195 + color: var(--text-error); 196 + } 197 + 198 + /* Legacy Semble classes for backwards compatibility */ 1 199 .semble-card-grid { 2 200 display: grid; 3 201 grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); ··· 107 305 color: var(--text-on-accent); 108 306 } 109 307 308 + .semble-badge-bookmark { 309 + background: var(--color-cyan); 310 + color: var(--text-on-accent); 311 + } 312 + 313 + .semble-badge-source { 314 + font-size: var(--font-smallest); 315 + opacity: 0.8; 316 + } 317 + 318 + .semble-badge-semble { 319 + background: var(--color-green); 320 + color: var(--text-on-accent); 321 + } 322 + 323 + /* Tags */ 324 + .semble-card-tags { 325 + display: flex; 326 + flex-wrap: wrap; 327 + gap: 6px; 328 + margin-bottom: 8px; 329 + } 330 + 331 + .semble-tag { 332 + font-size: var(--font-smallest); 333 + padding: 2px 8px; 334 + border-radius: var(--radius-s); 335 + background: var(--background-modifier-border); 336 + color: var(--text-muted); 337 + border: 1px solid var(--background-modifier-border-hover); 338 + } 339 + 340 + .semble-detail-tags-section { 341 + margin-top: 20px; 342 + padding-top: 20px; 343 + border-top: 1px solid var(--background-modifier-border); 344 + } 345 + 110 346 /* Page header */ 111 347 .semble-page-header { 112 348 margin-bottom: 16px; ··· 197 433 198 434 .semble-back-btn:hover { 199 435 background: var(--background-modifier-hover); 436 + color: var(--text-normal); 437 + } 438 + 439 + /* Source filter toggles */ 440 + .semble-source-filters { 441 + display: flex; 442 + align-items: center; 443 + gap: 16px; 444 + padding: 12px 0; 445 + margin-bottom: 12px; 446 + } 447 + 448 + .semble-source-filter-label { 449 + display: flex; 450 + align-items: center; 451 + gap: 8px; 452 + cursor: pointer; 453 + user-select: none; 454 + } 455 + 456 + .semble-source-filter-checkbox { 457 + width: 16px; 458 + height: 16px; 459 + cursor: pointer; 460 + accent-color: var(--interactive-accent); 461 + } 462 + 463 + .semble-source-filter-text { 464 + font-size: var(--font-small); 465 + font-weight: var(--font-medium); 200 466 color: var(--text-normal); 201 467 } 202 468