Various AT Protocol integrations with obsidian
20
fork

Configure Feed

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

wikilinks facets (#33)

* replace wikilinks with markdown links

* handle leaflet facet conversion

* bump version

authored by

treethought and committed by
GitHub
1a3a831c abb281b9

+250 -132
+1 -1
manifest.json
··· 1 1 { 2 2 "id": "atmosphere", 3 3 "name": "Atmosphere", 4 - "version": "0.1.19", 4 + "version": "0.1.20", 5 5 "minAppVersion": "0.15.0", 6 6 "description": "Various integrations with AT Protocol.", 7 7 "author": "treethought",
+1 -1
package.json
··· 1 1 { 2 2 "name": "obsidian-atmosphere", 3 - "version": "0.1.19", 3 + "version": "0.1.20", 4 4 "description": "Various integrations with AT Protocol.", 5 5 "main": "main.js", 6 6 "type": "module",
+6 -4
src/commands/publishDocument.ts
··· 1 1 import { Notice, TFile } from "obsidian"; 2 2 import type AtmospherePlugin from "../main"; 3 - import { createDocument, putDocument, getPublication, markdownToLeafletContent, stripMarkdown, markdownToPcktContent, buildDocumentUrl } from "../lib"; 3 + import { createDocument, putDocument, getPublication, markdownToLeafletContent, stripMarkdown, markdownToPcktContent, buildDocumentUrl, resolveWikilinks } from "../lib"; 4 4 import { PublicationSelection, SelectPublicationModal } from "../components/selectPublicationModal"; 5 5 import { type ResourceUri, } from "@atcute/lexicons"; 6 6 import { SiteStandardDocument, SiteStandardPublication } from "@atcute/standard-site"; ··· 137 137 throw new Error("Missing publication URI."); 138 138 } 139 139 140 + const resolved = resolveWikilinks(content, plugin.app); 141 + 140 142 // TODO: determine which lexicon to use for rich content 141 143 // for now just check url 142 - let textContent = stripMarkdown(content); 144 + let textContent = stripMarkdown(resolved); 143 145 144 146 let richContent: PubLeafletContent.Main | BlogPcktContent.Main | null = null; 145 147 if (pub?.url.contains("leaflet.pub")) { 146 - richContent = markdownToLeafletContent(content) 148 + richContent = markdownToLeafletContent(resolved) 147 149 } else if (pub?.url.contains("pckt.blog")) { 148 - richContent = markdownToPcktContent(content) 150 + richContent = markdownToPcktContent(resolved) 149 151 } 150 152 151 153 let record = {
-72
src/components/profileIcon.ts
··· 1 - import type { Client } from "@atcute/client"; 2 - import { getProfile } from "../lib"; 3 - 4 - export interface ProfileData { 5 - did: string; 6 - handle: string; 7 - displayName?: string; 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 - } 26 - } 27 - 28 - export function renderProfileIcon( 29 - container: HTMLElement, 30 - profile: ProfileData | null, 31 - onClick?: () => void 32 - ): HTMLElement { 33 - const wrapper = container.createEl("div", { cls: "atmosphere-profile-icon" }); 34 - 35 - if (!profile) { 36 - // Fallback when no profile data 37 - const placeholder = wrapper.createEl("div", { cls: "atmosphere-avatar-placeholder" }); 38 - placeholder.createEl("span", { text: "?" }); 39 - return wrapper; 40 - } 41 - 42 - const avatarBtn = wrapper.createEl("button", { cls: "atmosphere-avatar-btn" }); 43 - 44 - if (profile.avatar) { 45 - const img = avatarBtn.createEl("img", { cls: "atmosphere-avatar-img" }); 46 - img.src = profile.avatar; 47 - img.alt = profile.displayName || profile.handle; 48 - } else { 49 - // Fallback initials 50 - const initials = (profile.displayName || profile.handle) 51 - .split(" ") 52 - .map(w => w[0]) 53 - .slice(0, 2) 54 - .join("") 55 - .toUpperCase(); 56 - avatarBtn.createEl("span", { text: initials, cls: "atmosphere-avatar-initials" }); 57 - } 58 - 59 - const info = wrapper.createEl("div", { cls: "atmosphere-profile-info" }); 60 - 61 - if (profile.displayName) { 62 - info.createEl("span", { text: profile.displayName, cls: "atmosphere-profile-name" }); 63 - } 64 - 65 - info.createEl("span", { text: `@${profile.handle}`, cls: "atmosphere-profile-handle" }); 66 - 67 - if (onClick) { 68 - avatarBtn.addEventListener("click", onClick); 69 - } 70 - 71 - return wrapper; 72 - }
+2 -1
src/lib.ts
··· 1 1 import { Record } from "@atcute/atproto/types/repo/listRecords"; 2 2 3 - export { getRecord, deleteRecord, putRecord, getProfile } from "./lib/atproto"; 3 + export { getRecord, deleteRecord, putRecord} from "./lib/atproto"; 4 4 5 5 export { 6 6 getSembleCollections, ··· 38 38 stripMarkdown, 39 39 markdownToLeafletContent, 40 40 markdownToPcktContent, 41 + resolveWikilinks, 41 42 } from "./lib/markdown"; 42 43 43 44 export type ATRecord<T> = Record & { value: T };
-5
src/lib/atproto.ts
··· 42 42 } 43 43 } 44 44 45 - export async function getProfile(client: Client, actor: string) { 46 - return await client.get("app.bsky.actor.getProfile", { 47 - params: { actor: actor as ActorIdentifier }, 48 - }); 49 - }
+1
src/lib/markdown/index.ts
··· 52 52 53 53 export { markdownToPcktContent, pcktContentToMarkdown } from "./pckt"; 54 54 export { markdownToLeafletContent, leafletContentToMarkdown } from "./leaflet"; 55 + export { resolveWikilinks } from "./wikilinks"; 55 56
+178 -47
src/lib/markdown/leaflet.ts
··· 1 - import type { RootContent, Root } from "mdast"; 1 + import type { RootContent, Root, ListItem, List } from "mdast"; 2 2 import { unified } from "unified"; 3 3 import remarkStringify from "remark-stringify"; 4 4 import { 5 5 PubLeafletBlocksUnorderedList, 6 + PubLeafletBlocksText, 7 + PubLeafletBlocksHeader, 6 8 PubLeafletContent, 7 9 PubLeafletPagesLinearDocument, 10 + PubLeafletRichtextFacet, 8 11 } from "@atcute/leaflet"; 9 - import { parseMarkdown, extractText, cleanPlaintext } from "../markdown"; 12 + import { parseMarkdown, cleanPlaintext } from "../markdown"; 13 + 14 + 15 + const textEncoder = new TextEncoder(); 16 + 17 + function byteLength(text: string): number { 18 + return textEncoder.encode(text).length; 19 + } 20 + 21 + function createFacet( 22 + byteStart: number, 23 + byteEnd: number, 24 + features: PubLeafletRichtextFacet.Main["features"] 25 + ): PubLeafletRichtextFacet.Main { 26 + return { 27 + $type: "pub.leaflet.richtext.facet", 28 + index: { 29 + $type: "pub.leaflet.richtext.facet#byteSlice", 30 + byteStart, 31 + byteEnd, 32 + }, 33 + features, 34 + }; 35 + } 36 + 37 + function buildTextFromNodes(nodes: RootContent[]): { 38 + text: string; 39 + facets: PubLeafletRichtextFacet.Main[]; 40 + } { 41 + let text = ""; 42 + let byteOffset = 0; 43 + const facets: PubLeafletRichtextFacet.Main[] = []; 44 + 45 + const appendText = (value: string) => { 46 + if (!value) return; 47 + text += value; 48 + byteOffset += byteLength(value); 49 + }; 50 + 51 + const walk = (node: RootContent) => { 52 + switch (node.type) { 53 + case "text": 54 + appendText(node.value); 55 + return; 56 + case "inlineCode": { 57 + const start = byteOffset; 58 + appendText(node.value); 59 + const end = byteOffset; 60 + if (start < end) { 61 + facets.push(createFacet(start, end, [{ $type: "pub.leaflet.richtext.facet#code" }])); 62 + } 63 + return; 64 + } 65 + case "strong": { 66 + const start = byteOffset; 67 + for (const child of node.children) walk(child); 68 + const end = byteOffset; 69 + if (start < end) { 70 + facets.push(createFacet(start, end, [{ $type: "pub.leaflet.richtext.facet#bold" }])); 71 + } 72 + return; 73 + } 74 + case "emphasis": { 75 + const start = byteOffset; 76 + for (const child of node.children) walk(child); 77 + const end = byteOffset; 78 + if (start < end) { 79 + facets.push(createFacet(start, end, [{ $type: "pub.leaflet.richtext.facet#italic" }])); 80 + } 81 + return; 82 + } 83 + case "delete": { 84 + const start = byteOffset; 85 + for (const child of node.children) walk(child); 86 + const end = byteOffset; 87 + if (start < end) { 88 + facets.push(createFacet(start, end, [{ $type: "pub.leaflet.richtext.facet#strikethrough" }])); 89 + } 90 + return; 91 + } 92 + case "link": { 93 + const start = byteOffset; 94 + for (const child of node.children) walk(child); 95 + const end = byteOffset; 96 + if (start < end && node.url) { 97 + facets.push(createFacet(start, end, [{ $type: "pub.leaflet.richtext.facet#link", uri: node.url }])); 98 + } 99 + return; 100 + } 101 + case "break": 102 + appendText("\n"); 103 + return; 104 + default: { 105 + if ("children" in node && Array.isArray(node.children)) { 106 + for (const child of node.children) walk(child); 107 + return; 108 + } 109 + if ("value" in node && typeof node.value === "string") { 110 + appendText(node.value); 111 + } 112 + return; 113 + } 114 + } 115 + }; 116 + 117 + for (const node of nodes) walk(node); 118 + 119 + return { text, facets }; 120 + } 121 + 122 + function buildTextBlock(node: { children?: RootContent[] }): PubLeafletBlocksText.Main & { $type: "pub.leaflet.blocks.text" } { 123 + const { text, facets } = buildTextFromNodes(node.children ?? []); 124 + return { 125 + $type: "pub.leaflet.blocks.text" as const, 126 + plaintext: text, 127 + textSize: "default", 128 + facets: facets.length > 0 ? facets : undefined, 129 + }; 130 + } 10 131 11 132 export function markdownToLeafletContent(markdown: string): PubLeafletContent.Main { 12 133 const tree = parseMarkdown(markdown); ··· 14 135 15 136 for (const node of tree.children) { 16 137 const block = convertNodeToBlock(node); 17 - if (block) { 18 - blocks.push(block); 19 - } 138 + if (block) blocks.push(block); 20 139 } 21 140 22 - return { 141 + const record = { 23 142 $type: "pub.leaflet.content", 24 143 pages: [{ 25 144 $type: "pub.leaflet.pages.linearDocument", 26 145 blocks, 27 146 }], 28 147 }; 148 + return record as PubLeafletContent.Main; 149 + } 150 + 151 + function convertListItem(item: ListItem): PubLeafletBlocksUnorderedList.ListItem { 152 + const textChildren: RootContent[] = []; 153 + const nestedLists: RootContent[] = []; 154 + 155 + for (const child of item.children) { 156 + if (child.type === "list") { 157 + nestedLists.push(child); 158 + } else { 159 + textChildren.push(child); 160 + } 161 + } 162 + 163 + // handle text content and nested lists separately 164 + const result: PubLeafletBlocksUnorderedList.ListItem = { 165 + $type: "pub.leaflet.blocks.unorderedList#listItem", 166 + content: buildTextBlock({ children: textChildren }), 167 + }; 168 + 169 + if (nestedLists.length > 0) { 170 + result.children = nestedLists.flatMap((list) => 171 + (list as List).children.map(convertListItem) 172 + ); 173 + } 174 + 175 + return result; 29 176 } 30 177 31 178 function convertNodeToBlock(node: RootContent): PubLeafletPagesLinearDocument.Block | null { 32 179 switch (node.type) { 33 - case "heading": 180 + case "heading": { 181 + const { text, facets } = buildTextFromNodes(node.children); 34 182 return { 35 183 block: { 36 184 $type: "pub.leaflet.blocks.header", 37 185 level: node.depth, 38 - plaintext: extractText(node), 39 - }, 186 + plaintext: text, 187 + facets: facets.length > 0 ? facets : undefined, 188 + } as PubLeafletBlocksHeader.Main, 40 189 alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 41 - }; 190 + } as PubLeafletPagesLinearDocument.Block; 191 + } 42 192 43 193 case "paragraph": 44 194 return { 45 - block: { 46 - $type: "pub.leaflet.blocks.text", 47 - plaintext: extractText(node), 48 - textSize: "default", 49 - }, 195 + block: buildTextBlock(node), 50 196 alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 51 - }; 197 + } as PubLeafletPagesLinearDocument.Block; 52 198 53 199 case "list": { 54 - const listItems: PubLeafletBlocksUnorderedList.ListItem[] = node.children.map((item) => ({ 55 - $type: "pub.leaflet.blocks.unorderedList#listItem", 56 - content: { 57 - $type: "pub.leaflet.blocks.text", 58 - plaintext: extractText(item), 59 - textSize: "default", 60 - }, 61 - })); 62 - 63 200 return { 64 201 block: { 65 202 $type: "pub.leaflet.blocks.unorderedList", 66 - children: listItems, 203 + children: node.children.map(convertListItem), 67 204 }, 68 205 alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 69 206 }; ··· 81 218 82 219 case "thematicBreak": 83 220 return { 84 - block: { 85 - $type: "pub.leaflet.blocks.horizontalRule", 86 - }, 221 + block: { $type: "pub.leaflet.blocks.horizontalRule" }, 87 222 alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 88 223 }; 89 224 90 - case "blockquote": 225 + case "blockquote": { 226 + const { text, facets } = buildTextFromNodes( 227 + node.children.flatMap((c) => ("children" in c ? c.children : []) as RootContent[]) 228 + ); 91 229 return { 92 230 block: { 93 231 $type: "pub.leaflet.blocks.blockquote", 94 - plaintext: extractText(node), 232 + plaintext: text, 233 + facets: facets.length > 0 ? facets : undefined, 95 234 }, 96 235 alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 97 236 }; 237 + } 98 238 99 239 default: 100 240 return null; ··· 105 245 const mdastNodes: RootContent[] = []; 106 246 107 247 for (const page of content.pages) { 108 - if (page.$type !== "pub.leaflet.pages.linearDocument") { 109 - continue; 110 - } 248 + if (page.$type !== "pub.leaflet.pages.linearDocument") continue; 111 249 112 250 for (const item of page.blocks) { 113 - const block = item.block; 114 - const node = leafletBlockToMdast(block); 115 - if (node) { 116 - mdastNodes.push(node); 117 - } 251 + const node = leafletBlockToMdast(item.block); 252 + if (node) mdastNodes.push(node); 118 253 } 119 254 } 120 255 ··· 126 261 return unified().use(remarkStringify).stringify(root); 127 262 } 128 263 129 - // Extract the union type of all possible leaflet blocks from the Block interface 130 - type LeafletBlockType = PubLeafletPagesLinearDocument.Block['block']; 264 + type LeafletBlockType = PubLeafletPagesLinearDocument.Block["block"]; 131 265 132 266 function leafletBlockToMdast(block: LeafletBlockType): RootContent | null { 133 267 switch (block.$type) { ··· 150 284 ordered: false, 151 285 spread: false, 152 286 children: block.children.map((item: PubLeafletBlocksUnorderedList.ListItem) => { 153 - // Extract plaintext from the content, which can be Header, Image, or Text 154 - const plaintext = 'plaintext' in item.content ? cleanPlaintext(item.content.plaintext) : ''; 287 + const plaintext = "plaintext" in item.content ? cleanPlaintext(item.content.plaintext) : ""; 155 288 return { 156 289 type: "listItem", 157 290 spread: false, ··· 168 301 type: "code", 169 302 lang: block.language || null, 170 303 meta: null, 171 - value: block.plaintext, // Keep code blocks as-is to preserve formatting 304 + value: block.plaintext, 172 305 }; 173 306 174 307 case "pub.leaflet.blocks.horizontalRule": 175 - return { 176 - type: "thematicBreak", 177 - }; 308 + return { type: "thematicBreak" }; 178 309 179 310 case "pub.leaflet.blocks.blockquote": 180 311 return {
+60
src/lib/markdown/wikilinks.ts
··· 1 + import type { App } from "obsidian"; 2 + 3 + // Matches [[Note]], [[Note|Alias]], [[Note#Heading]], [[Note#Heading|Alias]] 4 + const WIKILINK_RE = /\[\[([^\]|#]+)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/g; 5 + 6 + function titleToSlug(title: string): string { 7 + return title 8 + .toLowerCase() 9 + .trim() 10 + .replace(/\s+/g, "-") 11 + .replace(/[^a-z0-9-]/g, ""); 12 + } 13 + 14 + /** 15 + * Resolves Obsidian wikilinks to standard markdown links. 16 + * 17 + * For each [[Note]] or [[Note|Alias]]: 18 + * - if the linked note has a published `url` in its frontmatter, use it. 19 + * - Otherwise, return the display text without a link 20 + * TODO: return relative url to current note publication? 21 + */ 22 + export function resolveWikilinks( 23 + markdown: string, 24 + app: App, 25 + ): string { 26 + return markdown.replace( 27 + WIKILINK_RE, 28 + (_match, noteName: string, heading: string | undefined, alias: string | undefined) => { 29 + const displayText = (alias ?? noteName).trim(); 30 + const name = noteName.trim(); 31 + 32 + const files = app.vault.getMarkdownFiles(); 33 + const file = files.find( 34 + (f) => f.basename === name || f.path === name + ".md" 35 + ); 36 + 37 + let baseUrl: string | undefined; 38 + 39 + if (file) { 40 + const fm = app.metadataCache.getFileCache(file)?.frontmatter; 41 + if (!fm?.atDocument || !fm?.url) { 42 + return displayText; 43 + } 44 + baseUrl = fm.url as string; 45 + } 46 + 47 + // if (!baseUrl && gardenBaseUrl) { 48 + // const base = gardenBaseUrl.replace(/\/$/, ""); 49 + // baseUrl = `${base}/notes/${titleToSlug(name)}`; 50 + // } 51 + 52 + if (!baseUrl) { 53 + return displayText; 54 + } 55 + 56 + const url = heading ? `${baseUrl}#${titleToSlug(heading)}` : baseUrl; 57 + return `[${displayText}](${url})`; 58 + } 59 + ); 60 + }
+1 -1
src/util.ts
··· 28 28 ); 29 29 imageCache.set(url, match?.[1] ?? ""); 30 30 return match?.[1]; 31 - } catch (e) { 31 + } catch { 32 32 return undefined; 33 33 } 34 34 }