AT protocol bookmarking platforms in obsidian

Compare changes

Choose any two refs to compare.

+936 -293
+17
bun.lock
··· 15 15 "@atcute/standard-site": "^1.0.0", 16 16 "obsidian": "latest", 17 17 "remark-parse": "^11.0.0", 18 + "remark-stringify": "^11.0.0", 18 19 "unified": "^11.0.5", 19 20 }, 20 21 "devDependencies": { ··· 540 541 541 542 "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], 542 543 544 + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], 545 + 543 546 "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], 544 547 545 548 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 546 549 547 550 "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], 551 + 552 + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], 553 + 554 + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], 548 555 549 556 "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], 550 557 ··· 667 674 "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], 668 675 669 676 "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], 677 + 678 + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], 670 679 671 680 "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], 672 681 ··· 774 783 775 784 "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], 776 785 786 + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], 787 + 777 788 "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], 789 + 790 + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], 791 + 792 + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], 778 793 779 794 "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], 780 795 ··· 801 816 "yaml-eslint-parser": ["yaml-eslint-parser@1.3.2", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0", "yaml": "^2.0.0" } }, "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg=="], 802 817 803 818 "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 819 + 820 + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], 804 821 805 822 "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], 806 823
+1
package.json
··· 35 35 "@atcute/standard-site": "^1.0.0", 36 36 "obsidian": "latest", 37 37 "remark-parse": "^11.0.0", 38 + "remark-stringify": "^11.0.0", 38 39 "unified": "^11.0.5" 39 40 } 40 41 }
+2 -17
src/commands/publishDocument.ts
··· 1 1 import { Notice, TFile } from "obsidian"; 2 2 import type ATmarkPlugin from "../main"; 3 - import { createDocument, putDocument, getPublication, markdownToLeafletContent, stripMarkdown, markdownToPcktContent } from "../lib"; 3 + import { createDocument, putDocument, getPublication, markdownToLeafletContent, stripMarkdown, markdownToPcktContent, buildDocumentUrl } from "../lib"; 4 4 import { PublicationSelection, SelectPublicationModal } from "../components/selectPublicationModal"; 5 - import { parseResourceUri, type ResourceUri, } from "@atcute/lexicons"; 5 + import { type ResourceUri, } from "@atcute/lexicons"; 6 6 import { SiteStandardDocument, SiteStandardPublication } from "@atcute/standard-site"; 7 7 import { PubLeafletContent } from "@atcute/leaflet"; 8 8 import { BlogPcktContent } from "@atcute/pckt"; ··· 41 41 new Notice(`Error publishing document: ${message}`); 42 42 console.error("Publish document error:", error); 43 43 } 44 - } 45 - 46 - function buildDocumentUrl(pubUrl: string, docUri: string, record: SiteStandardDocument.Main): string { 47 - const baseUrl = pubUrl.replace(/\/$/, ''); 48 - 49 - // leaflet does not use path, url just uses rkey 50 - if (record.path === undefined || record.path === '') { 51 - const parsed = parseResourceUri(docUri) 52 - if (parsed.ok) { 53 - return `${baseUrl}/${parsed.value.rkey}`; 54 - } 55 - return "" 56 - } 57 - 58 - return `${baseUrl}/${record.path}` 59 44 } 60 45 61 46 async function updateFrontMatter(
+3
src/lib/client.ts
··· 26 26 return this.hh.cm.session; 27 27 } 28 28 29 + getActor(identifier: string): Promise<ResolvedActor> { 30 + return this.hh.getActor(identifier); 31 + } 29 32 } 30 33 31 34 export class Handler implements FetchHandlerObject {
+118
src/lib/clipper.ts
··· 1 + import { ATRecord, buildDocumentUrl } from "lib"; 2 + import { Main as Document } from "@atcute/standard-site/types/document"; 3 + import { Main as Publication } from "@atcute/standard-site/types/publication"; 4 + import { is, parseResourceUri } from "@atcute/lexicons"; 5 + import { Notice, TFile } from "obsidian"; 6 + import ATmarkPlugin from "main"; 7 + import { leafletContentToMarkdown } from "./markdown/leaflet"; 8 + import { pcktContentToMarkdown } from "./markdown/pckt"; 9 + import { ResolvedActor } from "@atcute/identity-resolver"; 10 + import { PubLeafletContent } from "@atcute/leaflet"; 11 + import { BlogPcktContent } from "@atcute/pckt"; 12 + 13 + 14 + function bskyLink(handle: string) { 15 + return `https://bsky.app/profile/${handle}`; 16 + } 17 + 18 + export class Clipper { 19 + plugin: ATmarkPlugin; 20 + 21 + constructor(plugin: ATmarkPlugin) { 22 + this.plugin = plugin; 23 + } 24 + 25 + safeFilePath(title: string, clipDir: string) { 26 + const safeTitle = title.replace(/[/\\?%*:|"<>]/g, "-").substring(0, 50); 27 + return `${clipDir}/${safeTitle}.md`; 28 + } 29 + 30 + existsInClipDir(doc: ATRecord<Document>) { 31 + const vault = this.plugin.app.vault; 32 + const clipDir = this.plugin.settings.clipDir 33 + 34 + 35 + const filePath = this.safeFilePath(doc.value.title, clipDir); 36 + const file = vault.getAbstractFileByPath(filePath); 37 + return file !== null; 38 + } 39 + 40 + 41 + async writeFrontmatter(file: TFile, doc: ATRecord<Document>, pub: ATRecord<Publication>) { 42 + let actor: ResolvedActor | null = null; 43 + const repoParsed = parseResourceUri(doc.uri); 44 + if (repoParsed.ok) { 45 + actor = await this.plugin.client.getActor(repoParsed.value.repo); 46 + } 47 + // Add frontmatter using Obsidian's processFrontMatter 48 + await this.plugin.app.fileManager.processFrontMatter(file, (fm: Record<string, unknown>) => { 49 + fm["title"] = doc.value.title; 50 + if (actor && actor.handle) { 51 + fm["author"] = `[${actor.handle}](${bskyLink(actor.handle)})`; 52 + } 53 + fm["aturi"] = doc.uri; 54 + 55 + let docUrl = ""; 56 + 57 + // pubUrl is at:// record uri or https:// for loose document 58 + // fetch pub if at:// so we can get the url 59 + // otherwise just use the url as is 60 + if (doc.value.site.startsWith("https://")) { 61 + docUrl = buildDocumentUrl(doc.value.site, doc.uri, doc.value); 62 + } else { 63 + docUrl = buildDocumentUrl(pub.value.url, doc.uri, doc.value); 64 + 65 + } 66 + if (docUrl) { 67 + fm["url"] = docUrl; 68 + } 69 + }); 70 + } 71 + 72 + async clipDocument(doc: ATRecord<Document>, pub: ATRecord<Publication>) { 73 + const vault = this.plugin.app.vault; 74 + const clipDir = this.plugin.settings.clipDir 75 + 76 + const parsed = parseResourceUri(pub.uri); 77 + if (!parsed.ok) { 78 + throw new Error(`Invalid publication URI: ${pub.uri}`); 79 + } 80 + if (!vault.getAbstractFileByPath(clipDir)) { 81 + await vault.createFolder(clipDir); 82 + } 83 + const filePath = this.safeFilePath(doc.value.title, clipDir); 84 + 85 + let content = `# ${doc.value.title}\n\n`; 86 + 87 + if (doc.value.description) { 88 + content += `> ${doc.value.description}\n\n`; 89 + } 90 + 91 + content += `---\n\n`; 92 + 93 + let bodyContent = ""; 94 + if (doc.value.content) { 95 + if (is(PubLeafletContent.mainSchema, doc.value.content)) { 96 + bodyContent = leafletContentToMarkdown(doc.value.content); 97 + } else if (is(BlogPcktContent.mainSchema, doc.value.content)) { 98 + bodyContent = pcktContentToMarkdown(doc.value.content); 99 + } 100 + } 101 + 102 + if (!bodyContent && doc.value.textContent) { 103 + bodyContent = doc.value.textContent; 104 + } 105 + 106 + content += bodyContent; 107 + 108 + const file = await vault.create(filePath, content); 109 + await this.writeFrontmatter(file, doc, pub); 110 + 111 + 112 + const leaf = this.plugin.app.workspace.getLeaf(false); 113 + await leaf.openFile(file); 114 + 115 + new Notice(`Clipped document to ${filePath}`); 116 + } 117 + } 118 +
+46
src/lib/markdown/index.ts
··· 1 + import { unified } from "unified"; 2 + import remarkParse from "remark-parse"; 3 + import type { Root, RootContent } from "mdast"; 4 + 5 + export function parseMarkdown(markdown: string): Root { 6 + return unified().use(remarkParse).parse(markdown); 7 + } 8 + 9 + export function extractText(node: RootContent | Root): string { 10 + if (node.type === "text") { 11 + return node.value; 12 + } 13 + 14 + if (node.type === "inlineCode") { 15 + return node.value; 16 + } 17 + 18 + if ("children" in node && Array.isArray(node.children)) { 19 + return node.children.map(extractText).join(""); 20 + } 21 + 22 + if ("value" in node && typeof node.value === "string") { 23 + return node.value; 24 + } 25 + 26 + return ""; 27 + } 28 + 29 + /** 30 + * Strip markdown formatting to plain text 31 + * Used for the textContent field in standard.site documents 32 + */ 33 + export function stripMarkdown(markdown: string): string { 34 + const tree = parseMarkdown(markdown); 35 + return tree.children.map(extractText).join("\n\n").trim(); 36 + } 37 + 38 + export function cleanPlaintext(text: string): string { 39 + return text.trim(); 40 + } 41 + 42 + export type { Root, RootContent }; 43 + 44 + export { markdownToPcktContent, pcktContentToMarkdown } from "./pckt"; 45 + export { markdownToLeafletContent, leafletContentToMarkdown } from "./leaflet"; 46 +
+191
src/lib/markdown/leaflet.ts
··· 1 + import type { RootContent, Root } from "mdast"; 2 + import { unified } from "unified"; 3 + import remarkStringify from "remark-stringify"; 4 + import { 5 + PubLeafletBlocksUnorderedList, 6 + PubLeafletContent, 7 + PubLeafletPagesLinearDocument, 8 + } from "@atcute/leaflet"; 9 + import { parseMarkdown, extractText, cleanPlaintext } from "../markdown"; 10 + 11 + export function markdownToLeafletContent(markdown: string): PubLeafletContent.Main { 12 + const tree = parseMarkdown(markdown); 13 + const blocks: PubLeafletPagesLinearDocument.Block[] = []; 14 + 15 + for (const node of tree.children) { 16 + const block = convertNodeToBlock(node); 17 + if (block) { 18 + blocks.push(block); 19 + } 20 + } 21 + 22 + return { 23 + $type: "pub.leaflet.content", 24 + pages: [{ 25 + $type: "pub.leaflet.pages.linearDocument", 26 + blocks, 27 + }], 28 + }; 29 + } 30 + 31 + function convertNodeToBlock(node: RootContent): PubLeafletPagesLinearDocument.Block | null { 32 + switch (node.type) { 33 + case "heading": 34 + return { 35 + block: { 36 + $type: "pub.leaflet.blocks.header", 37 + level: node.depth, 38 + plaintext: extractText(node), 39 + }, 40 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 41 + }; 42 + 43 + case "paragraph": 44 + return { 45 + block: { 46 + $type: "pub.leaflet.blocks.text", 47 + plaintext: extractText(node), 48 + textSize: "default", 49 + }, 50 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 51 + }; 52 + 53 + 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 + return { 64 + block: { 65 + $type: "pub.leaflet.blocks.unorderedList", 66 + children: listItems, 67 + }, 68 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 69 + }; 70 + } 71 + 72 + case "code": 73 + return { 74 + block: { 75 + $type: "pub.leaflet.blocks.code", 76 + plaintext: node.value, 77 + language: node.lang || undefined, 78 + }, 79 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 80 + }; 81 + 82 + case "thematicBreak": 83 + return { 84 + block: { 85 + $type: "pub.leaflet.blocks.horizontalRule", 86 + }, 87 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 88 + }; 89 + 90 + case "blockquote": 91 + return { 92 + block: { 93 + $type: "pub.leaflet.blocks.blockquote", 94 + plaintext: extractText(node), 95 + }, 96 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 97 + }; 98 + 99 + default: 100 + return null; 101 + } 102 + } 103 + 104 + export function leafletContentToMarkdown(content: PubLeafletContent.Main): string { 105 + const mdastNodes: RootContent[] = []; 106 + 107 + for (const page of content.pages) { 108 + if (page.$type !== "pub.leaflet.pages.linearDocument") { 109 + continue; 110 + } 111 + 112 + for (const item of page.blocks) { 113 + const block = item.block; 114 + const node = leafletBlockToMdast(block); 115 + if (node) { 116 + mdastNodes.push(node); 117 + } 118 + } 119 + } 120 + 121 + const root: Root = { 122 + type: "root", 123 + children: mdastNodes, 124 + }; 125 + 126 + return unified().use(remarkStringify).stringify(root); 127 + } 128 + 129 + // Extract the union type of all possible leaflet blocks from the Block interface 130 + type LeafletBlockType = PubLeafletPagesLinearDocument.Block['block']; 131 + 132 + function leafletBlockToMdast(block: LeafletBlockType): RootContent | null { 133 + switch (block.$type) { 134 + case "pub.leaflet.blocks.header": 135 + return { 136 + type: "heading", 137 + depth: block.level as 1 | 2 | 3 | 4 | 5 | 6, 138 + children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 139 + }; 140 + 141 + case "pub.leaflet.blocks.text": 142 + return { 143 + type: "paragraph", 144 + children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 145 + }; 146 + 147 + case "pub.leaflet.blocks.unorderedList": 148 + return { 149 + type: "list", 150 + ordered: false, 151 + spread: false, 152 + 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) : ''; 155 + return { 156 + type: "listItem", 157 + spread: false, 158 + children: [{ 159 + type: "paragraph", 160 + children: [{ type: "text", value: plaintext }], 161 + }], 162 + }; 163 + }), 164 + }; 165 + 166 + case "pub.leaflet.blocks.code": 167 + return { 168 + type: "code", 169 + lang: block.language || null, 170 + meta: null, 171 + value: block.plaintext, // Keep code blocks as-is to preserve formatting 172 + }; 173 + 174 + case "pub.leaflet.blocks.horizontalRule": 175 + return { 176 + type: "thematicBreak", 177 + }; 178 + 179 + case "pub.leaflet.blocks.blockquote": 180 + return { 181 + type: "blockquote", 182 + children: [{ 183 + type: "paragraph", 184 + children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 185 + }], 186 + }; 187 + 188 + default: 189 + return null; 190 + } 191 + }
+223
src/lib/markdown/pckt.ts
··· 1 + import type { RootContent, Root } from "mdast"; 2 + import { unified } from "unified"; 3 + import remarkStringify from "remark-stringify"; 4 + import { 5 + BlogPcktBlockListItem, 6 + BlogPcktBlockText, 7 + BlogPcktBlockHeading, 8 + BlogPcktBlockCodeBlock, 9 + BlogPcktBlockBulletList, 10 + BlogPcktBlockOrderedList, 11 + BlogPcktBlockHorizontalRule, 12 + BlogPcktBlockBlockquote, 13 + BlogPcktContent, 14 + } from "@atcute/pckt"; 15 + import { parseMarkdown, extractText, cleanPlaintext } from "../markdown"; 16 + 17 + type PcktBlock = 18 + | BlogPcktBlockText.Main 19 + | BlogPcktBlockHeading.Main 20 + | BlogPcktBlockCodeBlock.Main 21 + | BlogPcktBlockBulletList.Main 22 + | BlogPcktBlockOrderedList.Main 23 + | BlogPcktBlockHorizontalRule.Main 24 + | BlogPcktBlockBlockquote.Main; 25 + 26 + export function markdownToPcktContent(markdown: string): BlogPcktContent.Main { 27 + const tree = parseMarkdown(markdown); 28 + const items: PcktBlock[] = []; 29 + 30 + for (const node of tree.children) { 31 + const block = convertNodeToBlock(node); 32 + if (block) { 33 + items.push(block); 34 + } 35 + } 36 + 37 + return { 38 + $type: "blog.pckt.content", 39 + items, 40 + } as BlogPcktContent.Main; 41 + } 42 + 43 + function convertNodeToBlock(node: RootContent): PcktBlock | null { 44 + switch (node.type) { 45 + case "heading": { 46 + const block: BlogPcktBlockHeading.Main = { 47 + $type: "blog.pckt.block.heading", 48 + level: node.depth, 49 + plaintext: extractText(node), 50 + }; 51 + return block; 52 + } 53 + 54 + case "paragraph": { 55 + const block: BlogPcktBlockText.Main = { 56 + $type: "blog.pckt.block.text", 57 + plaintext: extractText(node), 58 + }; 59 + return block; 60 + } 61 + 62 + case "list": { 63 + const listItems: BlogPcktBlockListItem.Main[] = node.children.map((item) => ({ 64 + $type: "blog.pckt.block.listItem", 65 + content: [{ 66 + $type: "blog.pckt.block.text", 67 + plaintext: extractText(item), 68 + }], 69 + })); 70 + 71 + if (node.ordered) { 72 + const block: BlogPcktBlockOrderedList.Main = { 73 + $type: "blog.pckt.block.orderedList", 74 + content: listItems, 75 + }; 76 + return block; 77 + } else { 78 + const block: BlogPcktBlockBulletList.Main = { 79 + $type: "blog.pckt.block.bulletList", 80 + content: listItems, 81 + }; 82 + return block; 83 + } 84 + } 85 + 86 + case "code": { 87 + const block: BlogPcktBlockCodeBlock.Main = { 88 + $type: "blog.pckt.block.codeBlock", 89 + plaintext: node.value, 90 + language: node.lang || undefined, 91 + }; 92 + return block; 93 + } 94 + 95 + case "thematicBreak": { 96 + const block: BlogPcktBlockHorizontalRule.Main = { 97 + $type: "blog.pckt.block.horizontalRule", 98 + }; 99 + return block; 100 + } 101 + 102 + case "blockquote": { 103 + const block: BlogPcktBlockBlockquote.Main = { 104 + $type: "blog.pckt.block.blockquote", 105 + content: [{ 106 + $type: "blog.pckt.block.text", 107 + plaintext: extractText(node), 108 + }], 109 + }; 110 + return block; 111 + } 112 + 113 + default: 114 + return null; 115 + } 116 + } 117 + 118 + /** 119 + * Convert pckt content to markdown string 120 + */ 121 + export function pcktContentToMarkdown(content: BlogPcktContent.Main): string { 122 + const mdastNodes: RootContent[] = []; 123 + 124 + for (const block of content.items) { 125 + const node = pcktBlockToMdast(block); 126 + if (node) { 127 + mdastNodes.push(node); 128 + } 129 + } 130 + 131 + const root: Root = { 132 + type: "root", 133 + children: mdastNodes, 134 + }; 135 + 136 + return unified().use(remarkStringify).stringify(root); 137 + } 138 + 139 + function pcktBlockToMdast(block: PcktBlock): RootContent | null { 140 + switch (block.$type) { 141 + case "blog.pckt.block.heading": 142 + return { 143 + type: "heading", 144 + depth: block.level as 1 | 2 | 3 | 4 | 5 | 6, 145 + children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 146 + }; 147 + 148 + case "blog.pckt.block.text": 149 + return { 150 + type: "paragraph", 151 + children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 152 + }; 153 + 154 + case "blog.pckt.block.bulletList": 155 + return { 156 + type: "list", 157 + ordered: false, 158 + spread: false, 159 + children: block.content.map((item: BlogPcktBlockListItem.Main) => { 160 + const text = item.content 161 + .map((c) => ('plaintext' in c ? cleanPlaintext(c.plaintext) : '')) 162 + .join(" "); 163 + return { 164 + type: "listItem", 165 + spread: false, 166 + children: [{ 167 + type: "paragraph", 168 + children: [{ type: "text", value: text }], 169 + }], 170 + }; 171 + }), 172 + }; 173 + 174 + case "blog.pckt.block.orderedList": 175 + return { 176 + type: "list", 177 + ordered: true, 178 + spread: false, 179 + children: block.content.map((item: BlogPcktBlockListItem.Main) => { 180 + const text = item.content 181 + .map((c) => ('plaintext' in c ? cleanPlaintext(c.plaintext) : '')) 182 + .join(" "); 183 + return { 184 + type: "listItem", 185 + spread: false, 186 + children: [{ 187 + type: "paragraph", 188 + children: [{ type: "text", value: text }], 189 + }], 190 + }; 191 + }), 192 + }; 193 + 194 + case "blog.pckt.block.codeBlock": 195 + return { 196 + type: "code", 197 + lang: block.language || null, 198 + meta: null, 199 + value: block.plaintext, 200 + }; 201 + 202 + case "blog.pckt.block.horizontalRule": 203 + return { 204 + type: "thematicBreak", 205 + }; 206 + 207 + case "blog.pckt.block.blockquote": { 208 + const text = block.content 209 + .map((c: BlogPcktBlockText.Main) => cleanPlaintext(c.plaintext)) 210 + .join("\n"); 211 + return { 212 + type: "blockquote", 213 + children: [{ 214 + type: "paragraph", 215 + children: [{ type: "text", value: text }], 216 + }], 217 + }; 218 + } 219 + 220 + default: 221 + return null; 222 + } 223 + }
-38
src/lib/markdown.ts
··· 1 - import { unified } from "unified"; 2 - import remarkParse from "remark-parse"; 3 - import type { Root, RootContent } from "mdast"; 4 - 5 - export function parseMarkdown(markdown: string): Root { 6 - return unified().use(remarkParse).parse(markdown); 7 - } 8 - 9 - export function extractText(node: RootContent | Root): string { 10 - if (node.type === "text") { 11 - return node.value; 12 - } 13 - 14 - if (node.type === "inlineCode") { 15 - return node.value; 16 - } 17 - 18 - if ("children" in node && Array.isArray(node.children)) { 19 - return node.children.map(extractText).join(""); 20 - } 21 - 22 - if ("value" in node && typeof node.value === "string") { 23 - return node.value; 24 - } 25 - 26 - return ""; 27 - } 28 - 29 - /** 30 - * Strip markdown formatting to plain text 31 - * Used for the textContent field in standard.site documents 32 - */ 33 - export function stripMarkdown(markdown: string): string { 34 - const tree = parseMarkdown(markdown); 35 - return tree.children.map(extractText).join("\n\n").trim(); 36 - } 37 - 38 - export type { Root, RootContent };
+16 -1
src/lib/standardsite/index.ts
··· 9 9 import { ATRecord } from "lib"; 10 10 import { SiteStandardDocument, SiteStandardGraphSubscription, SiteStandardPublication } from "@atcute/standard-site"; 11 11 12 + export function buildDocumentUrl(pubUrl: string, docUri: string, record: SiteStandardDocument.Main): string { 13 + const baseUrl = pubUrl.replace(/\/$/, ''); 14 + 15 + // leaflet does not use path, url just uses rkey 16 + if (record.path === undefined || record.path === '') { 17 + const parsed = parseResourceUri(docUri) 18 + if (parsed.ok) { 19 + return `${baseUrl}/${parsed.value.rkey}`; 20 + } 21 + return "" 22 + } 23 + 24 + return `${baseUrl}/${record.path}` 25 + } 26 + 27 + 12 28 export async function getPublicationDocuments(client: Client, repo: string, pubUri: ResourceUri) { 13 29 const response = await ok(client.call(ComAtprotoRepoListRecords, { 14 30 params: { ··· 18 34 }, 19 35 })); 20 36 21 - // filter records by publication uri 22 37 const pubDocs = response.records.filter(record => { 23 38 const parsed = parse(SiteStandardDocument.mainSchema, record.value); 24 39 return parsed.site === pubUri;
-100
src/lib/standardsite/leaflet.ts
··· 1 - import type { RootContent } from "mdast"; 2 - import { 3 - PubLeafletBlocksUnorderedList, 4 - PubLeafletContent, 5 - PubLeafletPagesLinearDocument, 6 - } from "@atcute/leaflet"; 7 - import { parseMarkdown, extractText } from "../markdown"; 8 - 9 - export function markdownToLeafletContent(markdown: string): PubLeafletContent.Main { 10 - const tree = parseMarkdown(markdown); 11 - const blocks: PubLeafletPagesLinearDocument.Block[] = []; 12 - 13 - for (const node of tree.children) { 14 - const block = convertNodeToBlock(node); 15 - if (block) { 16 - blocks.push(block); 17 - } 18 - } 19 - 20 - return { 21 - $type: "pub.leaflet.content", 22 - pages: [{ 23 - $type: "pub.leaflet.pages.linearDocument", 24 - blocks, 25 - }], 26 - }; 27 - } 28 - 29 - function convertNodeToBlock(node: RootContent): PubLeafletPagesLinearDocument.Block | null { 30 - switch (node.type) { 31 - case "heading": 32 - return { 33 - block: { 34 - $type: "pub.leaflet.blocks.header", 35 - level: node.depth, 36 - plaintext: extractText(node), 37 - }, 38 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 39 - }; 40 - 41 - case "paragraph": 42 - return { 43 - block: { 44 - $type: "pub.leaflet.blocks.text", 45 - plaintext: extractText(node), 46 - textSize: "default", 47 - }, 48 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 49 - }; 50 - 51 - case "list": { 52 - const listItems: PubLeafletBlocksUnorderedList.ListItem[] = node.children.map((item) => ({ 53 - $type: "pub.leaflet.blocks.unorderedList#listItem", 54 - content: { 55 - $type: "pub.leaflet.blocks.text", 56 - plaintext: extractText(item), 57 - textSize: "default", 58 - }, 59 - })); 60 - 61 - return { 62 - block: { 63 - $type: "pub.leaflet.blocks.unorderedList", 64 - children: listItems, 65 - }, 66 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 67 - }; 68 - } 69 - 70 - case "code": 71 - return { 72 - block: { 73 - $type: "pub.leaflet.blocks.code", 74 - plaintext: node.value, 75 - language: node.lang || undefined, 76 - }, 77 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 78 - }; 79 - 80 - case "thematicBreak": 81 - return { 82 - block: { 83 - $type: "pub.leaflet.blocks.horizontalRule", 84 - }, 85 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 86 - }; 87 - 88 - case "blockquote": 89 - return { 90 - block: { 91 - $type: "pub.leaflet.blocks.blockquote", 92 - plaintext: extractText(node), 93 - }, 94 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 95 - }; 96 - 97 - default: 98 - return null; 99 - } 100 - }
-119
src/lib/standardsite/pckt.ts
··· 1 - /** 2 - * Markdown to Pckt blocks parser 3 - * Converts markdown content to blog.pckt.content format 4 - */ 5 - 6 - import type { RootContent } from "mdast"; 7 - import { 8 - BlogPcktBlockListItem, 9 - BlogPcktBlockText, 10 - BlogPcktBlockHeading, 11 - BlogPcktBlockCodeBlock, 12 - BlogPcktBlockBulletList, 13 - BlogPcktBlockOrderedList, 14 - BlogPcktBlockHorizontalRule, 15 - BlogPcktBlockBlockquote, 16 - BlogPcktContent, 17 - } from "@atcute/pckt"; 18 - import { parseMarkdown, extractText } from "../markdown"; 19 - 20 - type PcktBlock = 21 - | BlogPcktBlockText.Main 22 - | BlogPcktBlockHeading.Main 23 - | BlogPcktBlockCodeBlock.Main 24 - | BlogPcktBlockBulletList.Main 25 - | BlogPcktBlockOrderedList.Main 26 - | BlogPcktBlockHorizontalRule.Main 27 - | BlogPcktBlockBlockquote.Main; 28 - 29 - export function markdownToPcktContent(markdown: string): BlogPcktContent.Main { 30 - const tree = parseMarkdown(markdown); 31 - const items: PcktBlock[] = []; 32 - 33 - for (const node of tree.children) { 34 - const block = convertNodeToBlock(node); 35 - if (block) { 36 - items.push(block); 37 - } 38 - } 39 - 40 - return { 41 - $type: "blog.pckt.content", 42 - items, 43 - } as BlogPcktContent.Main; 44 - } 45 - 46 - function convertNodeToBlock(node: RootContent): PcktBlock | null { 47 - switch (node.type) { 48 - case "heading": { 49 - const block: BlogPcktBlockHeading.Main = { 50 - $type: "blog.pckt.block.heading", 51 - level: node.depth, 52 - plaintext: extractText(node), 53 - }; 54 - return block; 55 - } 56 - 57 - case "paragraph": { 58 - const block: BlogPcktBlockText.Main = { 59 - $type: "blog.pckt.block.text", 60 - plaintext: extractText(node), 61 - }; 62 - return block; 63 - } 64 - 65 - case "list": { 66 - const listItems: BlogPcktBlockListItem.Main[] = node.children.map((item) => ({ 67 - $type: "blog.pckt.block.listItem", 68 - content: [{ 69 - $type: "blog.pckt.block.text", 70 - plaintext: extractText(item), 71 - }], 72 - })); 73 - 74 - if (node.ordered) { 75 - const block: BlogPcktBlockOrderedList.Main = { 76 - $type: "blog.pckt.block.orderedList", 77 - content: listItems, 78 - }; 79 - return block; 80 - } else { 81 - const block: BlogPcktBlockBulletList.Main = { 82 - $type: "blog.pckt.block.bulletList", 83 - content: listItems, 84 - }; 85 - return block; 86 - } 87 - } 88 - 89 - case "code": { 90 - const block: BlogPcktBlockCodeBlock.Main = { 91 - $type: "blog.pckt.block.codeBlock", 92 - plaintext: node.value, 93 - language: node.lang || undefined, 94 - }; 95 - return block; 96 - } 97 - 98 - case "thematicBreak": { 99 - const block: BlogPcktBlockHorizontalRule.Main = { 100 - $type: "blog.pckt.block.horizontalRule", 101 - }; 102 - return block; 103 - } 104 - 105 - case "blockquote": { 106 - const block: BlogPcktBlockBlockquote.Main = { 107 - $type: "blog.pckt.block.blockquote", 108 - content: [{ 109 - $type: "blog.pckt.block.text", 110 - plaintext: extractText(node), 111 - }], 112 - }; 113 - return block; 114 - } 115 - 116 - default: 117 - return null; 118 - } 119 - }
+7 -3
src/lib.ts
··· 24 24 } from "./lib/bookmarks/margin"; 25 25 26 26 export { 27 + getPublicationDocuments, 27 28 createDocument, 28 29 putDocument, 29 30 getPublication, 30 31 getPublications, 31 32 getSubscribedPublications, 32 33 createPublication, 34 + buildDocumentUrl 33 35 } from "./lib/standardsite"; 34 36 35 - export { markdownToLeafletContent } from "./lib/standardsite/leaflet"; 36 - export { markdownToPcktContent } from "./lib/standardsite/pckt"; 37 - export { stripMarkdown } from "./lib/markdown"; 37 + export { 38 + stripMarkdown, 39 + markdownToLeafletContent, 40 + markdownToPcktContent, 41 + } from "./lib/markdown"; 38 42 39 43 export type ATRecord<T> = Record & { value: T };
+4 -1
src/main.ts
··· 4 4 import { publishFileAsDocument } from "./commands/publishDocument"; 5 5 import { StandardFeedView, VIEW_STANDARD_FEED } from "views/standardfeed"; 6 6 import { ATClient } from "lib/client"; 7 + import { Clipper } from "lib/clipper"; 7 8 8 9 export default class ATmarkPlugin extends Plugin { 9 10 settings: AtProtoSettings = DEFAULT_SETTINGS; 10 - client: ATClient 11 + client: ATClient; 12 + clipper: Clipper; 11 13 12 14 async onload() { 13 15 await this.loadSettings(); ··· 17 19 password: this.settings.appPassword, 18 20 }; 19 21 this.client = new ATClient(creds); 22 + this.clipper = new Clipper(this); 20 23 21 24 this.registerView(VIEW_TYPE_ATMARK, (leaf) => { 22 25 return new ATmarkView(leaf, this);
+13
src/settings.ts
··· 4 4 export interface AtProtoSettings { 5 5 identifier: string; 6 6 appPassword: string; 7 + clipDir: string; 7 8 } 8 9 9 10 export const DEFAULT_SETTINGS: AtProtoSettings = { 10 11 identifier: "", 11 12 appPassword: "", 13 + clipDir: "AtmosphereClips", 12 14 }; 13 15 14 16 export class SettingTab extends PluginSettingTab { ··· 48 50 await this.plugin.saveSettings(); 49 51 }); 50 52 }); 53 + new Setting(containerEl) 54 + .setName("Clip directory") 55 + .setDesc("Directory in your vault to save clips (will be created if it doesn't exist)") 56 + .addText((text) => 57 + text 58 + .setValue(this.plugin.settings.clipDir) 59 + .onChange(async (value) => { 60 + this.plugin.settings.clipDir = value; 61 + await this.plugin.saveSettings(); 62 + }) 63 + ); 51 64 } 52 65 }
+148 -14
src/views/standardfeed.ts
··· 1 1 import { getSubscribedPublications } from "lib/standardsite"; 2 2 import ATmarkPlugin from "main"; 3 - import { ItemView, WorkspaceLeaf } from "obsidian"; 4 - import { SiteStandardPublication } from "@atcute/standard-site"; 3 + import { ItemView, Notice, WorkspaceLeaf, setIcon } from "obsidian"; 4 + import { Main as Document } from "@atcute/standard-site/types/document"; 5 + import { Main as Publication } from "@atcute/standard-site/types/publication"; 5 6 import { ATRecord } from "lib"; 7 + import { parseResourceUri } from "@atcute/lexicons"; 8 + import { getPublicationDocuments } from "lib/standardsite"; 6 9 7 10 export const VIEW_STANDARD_FEED = "standard-site-feed"; 8 11 ··· 36 39 container.addClass("standard-site-view"); 37 40 this.renderHeader(container); 38 41 39 - const loading = container.createEl("p", { text: "Loading feed..." }); 40 42 43 + const loading = container.createEl("p", { text: "Loading feed..." }); 41 44 try { 42 45 const pubs = await getSubscribedPublications(this.plugin.client, this.plugin.settings.identifier); 43 46 loading.remove(); 44 47 45 48 if (pubs.length === 0) { 46 - container.createEl("p", { text: "No subscriptions found. Subscribe to publications first." }); 49 + container.createEl("p", { text: "No subscriptions found" }); 47 50 return; 48 51 } 49 52 50 53 const list = container.createEl("div", { cls: "standard-site-list" }); 51 54 52 55 for (const pub of pubs) { 53 - this.renderPublicationCard(list, pub); 56 + void this.renderPublicationCard(list, pub); 54 57 } 55 58 } catch (error) { 56 - loading.remove(); 57 59 const message = error instanceof Error ? error.message : String(error); 60 + console.error("Failed to load feed:", error); 58 61 container.createEl("p", { text: `Failed to load feed: ${message}`, cls: "standard-site-error" }); 62 + } finally { 63 + loading.remove(); 59 64 } 60 65 } 61 66 62 - private renderPublicationCard(container: HTMLElement, pub: ATRecord<SiteStandardPublication.Main>) { 67 + private async renderPublicationCard(container: HTMLElement, pub: ATRecord<Publication>) { 63 68 const card = container.createEl("div", { cls: "standard-site-publication" }); 64 69 65 - // Header with name 66 70 const header = card.createEl("div", { cls: "standard-site-publication-header" }); 67 71 header.createEl("h3", { 68 72 text: pub.value.name, 69 73 cls: "standard-site-publication-name" 70 74 }); 75 + const extLink = header.createEl("span", { cls: "clickable standard-site-publication-external" }); 76 + setIcon(extLink, "external-link"); 77 + extLink.addEventListener("click", (e) => { 78 + e.stopPropagation(); 79 + window.open(pub.value.url, "_blank"); 80 + }); 71 81 72 - // Body 73 82 const body = card.createEl("div", { cls: "standard-site-publication-body" }); 74 83 75 - // URL 84 + const handleEl = body.createEl("span", { cls: "standard-site-author-handle", text: "..." }); 85 + const parsed = parseResourceUri(pub.uri); 86 + if (parsed.ok) { 87 + this.plugin.client.getActor(parsed.value.repo).then(actor => { 88 + if (actor?.handle) { 89 + handleEl.setText(`@${actor.handle}`); 90 + } else { 91 + handleEl.setText(""); 92 + } 93 + }).catch(() => { 94 + handleEl.setText(""); 95 + }); 96 + } 97 + 76 98 const urlLine = body.createEl("div", { cls: "standard-site-publication-url" }); 77 99 const link = urlLine.createEl("a", { text: pub.value.url, href: pub.value.url }); 78 100 link.setAttr("target", "_blank"); 79 101 80 - // Description 81 102 if (pub.value.description) { 82 103 body.createEl("p", { 83 104 text: pub.value.description, ··· 85 106 }); 86 107 } 87 108 88 - // Make card clickable 89 109 card.addClass("clickable"); 90 110 card.addEventListener("click", (e) => { 91 - // Don't trigger if clicking the link 92 111 if ((e.target as HTMLElement).tagName !== "A") { 93 - window.open(pub.value.url, "_blank"); 112 + void this.renderPublicationDocuments(pub); 94 113 } 95 114 }); 115 + } 116 + 117 + private async renderPublicationDocuments(pub: ATRecord<Publication>) { 118 + const container = this.contentEl; 119 + container.empty(); 120 + container.addClass("standard-site-view"); 121 + 122 + const header = container.createEl("div", { cls: "standard-site-header" }); 123 + const backBtn = header.createEl("span", { text: "Back", cls: "clickable standard-site-back" }); 124 + setIcon(backBtn, "arrow-left"); 125 + backBtn.addEventListener("click", () => { 126 + void this.render(); 127 + }); 128 + 129 + const titleGroup = header.createEl("div", { cls: "standard-site-title-group" }); 130 + titleGroup.createEl("h2", { text: pub.value.name }); 131 + const handleEl = titleGroup.createEl("span", { cls: "standard-site-author-handle", text: "..." }); 132 + 133 + const parsed = parseResourceUri(pub.uri); 134 + if (!parsed.ok) { 135 + // This is the name of the plugin, which contains the acronym "AT" 136 + // eslint-disable-next-line obsidianmd/ui/sentence-case 137 + container.createEl("p", { text: "Failed to parse publication URI." }); 138 + console.error("Failed to parse publication URI:", parsed.error); 139 + 140 + return; 141 + } 142 + 143 + // Fetch actor handle asynchronously without blocking document load 144 + this.plugin.client.getActor(parsed.value.repo).then(actor => { 145 + if (actor?.handle) { 146 + handleEl.setText(`@${actor.handle}`); 147 + } else { 148 + handleEl.setText(""); 149 + } 150 + }).catch(() => { 151 + handleEl.setText(""); 152 + }); 153 + 154 + const loading = container.createEl("p", { text: "Loading documents..." }); 155 + 156 + try { 157 + const docsResp = await getPublicationDocuments(this.plugin.client, parsed.value.repo, pub.uri); 158 + loading.remove(); 159 + 160 + if (docsResp.records.length === 0) { 161 + container.createEl("p", { text: "No documents found for this publication." }); 162 + return; 163 + } 164 + 165 + const list = container.createEl("div", { cls: "standard-site-list" }); 166 + for (const doc of docsResp.records) { 167 + this.renderDocumentCard(list, doc, pub); 168 + } 169 + } catch (error) { 170 + loading.remove(); 171 + const message = error instanceof Error ? error.message : String(error); 172 + container.createEl("p", { text: `Failed to load documents: ${message}`, cls: "standard-site-error" }); 173 + } 174 + } 175 + 176 + 177 + private renderDocumentCard(container: HTMLElement, doc: ATRecord<Document>, pub: ATRecord<Publication>) { 178 + const card = container.createEl("div", { cls: "standard-site-document" }); 179 + 180 + const header = card.createEl("div", { cls: "standard-site-document-header" }); 181 + header.createEl("h3", { text: doc.value.title, cls: "standard-site-document-title" }); 182 + 183 + let clipIcon = "book-open"; 184 + if (this.plugin.clipper.existsInClipDir(doc)) { 185 + clipIcon = "book-open-check"; 186 + } 187 + const clipBtn = header.createEl("span", { cls: "clickable standard-site-document-clip" }); 188 + setIcon(clipBtn, clipIcon); 189 + clipBtn.addEventListener("click", (e) => { 190 + e.stopPropagation(); 191 + try { 192 + void this.plugin.clipper.clipDocument(doc, pub); 193 + } catch (error) { 194 + const message = error instanceof Error ? error.message : String(error); 195 + new Notice(`Failed to clip document: ${message}`); 196 + console.error("Failed to clip document:", error); 197 + } 198 + }) 199 + 200 + 201 + if (doc.value.path) { 202 + const extLink = header.createEl("span", { cls: "clickable standard-site-document-external" }); 203 + setIcon(extLink, "external-link"); 204 + const baseUrl = pub.value.url.replace(/\/+$/, ""); 205 + const path = doc.value.path.startsWith("/") ? doc.value.path : `/${doc.value.path}`; 206 + extLink.addEventListener("click", (e) => { 207 + e.stopPropagation(); 208 + window.open(`${baseUrl}${path}`, "_blank"); 209 + }); 210 + } 211 + 212 + const body = card.createEl("div", { cls: "standard-site-document-body" }); 213 + 214 + if (doc.value.description) { 215 + body.createEl("p", { text: doc.value.description, cls: "standard-site-document-description" }); 216 + } 217 + 218 + if (doc.value.tags && doc.value.tags.length > 0) { 219 + const tags = body.createEl("div", { cls: "standard-site-document-tags" }); 220 + for (const tag of doc.value.tags) { 221 + tags.createEl("span", { text: tag, cls: "standard-site-document-tag" }); 222 + } 223 + } 224 + 225 + if (doc.value.publishedAt) { 226 + const footer = card.createEl("div", { cls: "standard-site-document-footer" }); 227 + const date = new Date(doc.value.publishedAt).toLocaleDateString(); 228 + footer.createEl("span", { text: date, cls: "standard-site-document-date" }); 229 + } 96 230 } 97 231 98 232 renderHeader(container: HTMLElement) {
+147
styles.css
··· 1207 1207 .standard-site-error { 1208 1208 color: var(--text-error); 1209 1209 } 1210 + 1211 + /* Standard Site Documents */ 1212 + .standard-site-document { 1213 + background: var(--background-secondary); 1214 + border: 1px solid var(--background-modifier-border); 1215 + border-radius: var(--radius-m); 1216 + padding: 16px; 1217 + display: flex; 1218 + flex-direction: column; 1219 + transition: box-shadow 0.15s ease, border-color 0.15s ease; 1220 + } 1221 + 1222 + .standard-site-document:hover { 1223 + box-shadow: var(--shadow-s); 1224 + border-color: var(--background-modifier-border-hover); 1225 + } 1226 + 1227 + .standard-site-document-header { 1228 + display: flex; 1229 + align-items: flex-start; 1230 + justify-content: space-between; 1231 + gap: 8px; 1232 + margin-bottom: 8px; 1233 + } 1234 + 1235 + .standard-site-document-title { 1236 + margin: 0; 1237 + font-size: var(--h3-size); 1238 + font-weight: var(--font-semibold); 1239 + color: var(--text-normal); 1240 + flex: 1; 1241 + line-height: 1.3; 1242 + } 1243 + 1244 + .standard-site-document-external { 1245 + display: flex; 1246 + align-items: center; 1247 + justify-content: center; 1248 + flex-shrink: 0; 1249 + width: 24px; 1250 + height: 24px; 1251 + border-radius: var(--radius-s); 1252 + color: var(--text-faint); 1253 + transition: all 0.15s ease; 1254 + } 1255 + 1256 + .standard-site-document-external:hover { 1257 + background: var(--background-modifier-hover); 1258 + color: var(--text-normal); 1259 + } 1260 + 1261 + .standard-site-document-external svg { 1262 + width: 14px; 1263 + height: 14px; 1264 + } 1265 + 1266 + .standard-site-document-body { 1267 + display: flex; 1268 + flex-direction: column; 1269 + gap: 8px; 1270 + } 1271 + 1272 + .standard-site-document-description { 1273 + margin: 0; 1274 + color: var(--text-muted); 1275 + font-size: var(--font-small); 1276 + line-height: var(--line-height-normal); 1277 + display: -webkit-box; 1278 + -webkit-line-clamp: 3; 1279 + -webkit-box-orient: vertical; 1280 + overflow: hidden; 1281 + } 1282 + 1283 + .standard-site-document-tags { 1284 + display: flex; 1285 + flex-wrap: wrap; 1286 + gap: 6px; 1287 + } 1288 + 1289 + .standard-site-document-tag { 1290 + font-size: var(--font-smallest); 1291 + padding: 2px 8px; 1292 + border-radius: var(--radius-s); 1293 + background: var(--background-modifier-border); 1294 + color: var(--text-muted); 1295 + border: 1px solid var(--background-modifier-border-hover); 1296 + } 1297 + 1298 + .standard-site-document-footer { 1299 + display: flex; 1300 + align-items: center; 1301 + margin-top: 12px; 1302 + padding-top: 8px; 1303 + border-top: 1px solid var(--background-modifier-border); 1304 + } 1305 + 1306 + .standard-site-document-date { 1307 + font-size: var(--font-smallest); 1308 + color: var(--text-faint); 1309 + } 1310 + 1311 + .standard-site-title-group { 1312 + display: flex; 1313 + flex-direction: column; 1314 + flex: 1; 1315 + min-width: 0; 1316 + } 1317 + 1318 + .standard-site-author-handle { 1319 + font-size: var(--font-small); 1320 + color: var(--text-muted); 1321 + } 1322 + 1323 + .standard-site-back { 1324 + font-size: var(--font-small); 1325 + color: var(--text-muted); 1326 + padding: 4px 8px; 1327 + border-radius: var(--radius-s); 1328 + transition: all 0.15s ease; 1329 + } 1330 + 1331 + .standard-site-back:hover { 1332 + background: var(--background-modifier-hover); 1333 + color: var(--text-normal); 1334 + } 1335 + 1336 + .standard-site-publication-external { 1337 + display: flex; 1338 + align-items: center; 1339 + justify-content: center; 1340 + flex-shrink: 0; 1341 + width: 24px; 1342 + height: 24px; 1343 + border-radius: var(--radius-s); 1344 + color: var(--text-faint); 1345 + transition: all 0.15s ease; 1346 + } 1347 + 1348 + .standard-site-publication-external:hover { 1349 + background: var(--background-modifier-hover); 1350 + color: var(--text-normal); 1351 + } 1352 + 1353 + .standard-site-publication-external svg { 1354 + width: 14px; 1355 + height: 14px; 1356 + }