AT protocol bookmarking platforms in obsidian

Merge pull request #1 from treethought/margin

add support for margin.at

authored by

treethought and committed by
GitHub
7d6dd76a 098138dd

+2112 -4
+6
lex.config.js
··· 14 14 ref: 'main', 15 15 pattern: ['src/modules/atproto/infrastructure/lexicons/**/*.json'], 16 16 }, 17 + { 18 + type: 'git', 19 + remote: 'https://tangled.org/margin.at/margin.git', 20 + ref: 'main', 21 + pattern: ['lexicons/**/*.json'], 22 + }, 17 23 ], 18 24 }, 19 25 });
+2
lexicons/README.md
··· 4 4 5 5 - https://github.com/cosmik-network/semble.git (ref: main) 6 6 - commit: 6a806594dd7411268299e3467ed09fd173c8f478 7 + - https://tangled.org/margin.at/margin.git (ref: main) 8 + - commit: 87cca4daaa23df443e7d53aaf58af59f06e4c4c9
+273
lexicons/at/margin/annotation.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.annotation", 4 + "revision": 2, 5 + "description": "W3C Web Annotation Data Model compliant annotation record for ATProto", 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "description": "A W3C-compliant web annotation stored on the AT Protocol", 10 + "key": "tid", 11 + "record": { 12 + "type": "object", 13 + "required": ["target", "createdAt"], 14 + "properties": { 15 + "motivation": { 16 + "type": "string", 17 + "description": "W3C motivation for the annotation", 18 + "knownValues": [ 19 + "commenting", 20 + "highlighting", 21 + "bookmarking", 22 + "tagging", 23 + "describing", 24 + "linking", 25 + "replying", 26 + "editing", 27 + "questioning", 28 + "assessing" 29 + ] 30 + }, 31 + "body": { 32 + "type": "ref", 33 + "ref": "#body", 34 + "description": "The annotation content (text or reference)" 35 + }, 36 + "target": { 37 + "type": "ref", 38 + "ref": "#target", 39 + "description": "The resource being annotated with optional selector" 40 + }, 41 + "tags": { 42 + "type": "array", 43 + "description": "Tags for categorization", 44 + "items": { 45 + "type": "string", 46 + "maxLength": 64, 47 + "maxGraphemes": 32 48 + }, 49 + "maxLength": 10 50 + }, 51 + "createdAt": { 52 + "type": "string", 53 + "format": "datetime" 54 + } 55 + } 56 + } 57 + }, 58 + "body": { 59 + "type": "object", 60 + "description": "Annotation body - the content of the annotation", 61 + "properties": { 62 + "value": { 63 + "type": "string", 64 + "maxLength": 10000, 65 + "maxGraphemes": 3000, 66 + "description": "Text content of the annotation" 67 + }, 68 + "format": { 69 + "type": "string", 70 + "description": "MIME type of the body content", 71 + "default": "text/plain" 72 + }, 73 + "language": { 74 + "type": "string", 75 + "description": "BCP47 language tag" 76 + }, 77 + "uri": { 78 + "type": "string", 79 + "format": "uri", 80 + "description": "Reference to external body content" 81 + } 82 + } 83 + }, 84 + "target": { 85 + "type": "object", 86 + "description": "W3C SpecificResource - the target with optional selector", 87 + "required": ["source"], 88 + "properties": { 89 + "source": { 90 + "type": "string", 91 + "format": "uri", 92 + "description": "The URL being annotated" 93 + }, 94 + "sourceHash": { 95 + "type": "string", 96 + "description": "SHA256 hash of normalized URL for indexing" 97 + }, 98 + "title": { 99 + "type": "string", 100 + "maxLength": 500, 101 + "description": "Page title at time of annotation" 102 + }, 103 + "selector": { 104 + "type": "union", 105 + "description": "Selector to identify the specific segment", 106 + "refs": [ 107 + "#textQuoteSelector", 108 + "#textPositionSelector", 109 + "#cssSelector", 110 + "#xpathSelector", 111 + "#fragmentSelector", 112 + "#rangeSelector" 113 + ] 114 + }, 115 + "state": { 116 + "type": "ref", 117 + "ref": "#timeState", 118 + "description": "State of the resource at annotation time" 119 + } 120 + } 121 + }, 122 + "textQuoteSelector": { 123 + "type": "object", 124 + "description": "W3C TextQuoteSelector - select text by quoting it with context", 125 + "required": ["exact"], 126 + "properties": { 127 + "type": { 128 + "type": "string", 129 + "const": "TextQuoteSelector" 130 + }, 131 + "exact": { 132 + "type": "string", 133 + "maxLength": 5000, 134 + "maxGraphemes": 1500, 135 + "description": "The exact text to match" 136 + }, 137 + "prefix": { 138 + "type": "string", 139 + "maxLength": 500, 140 + "maxGraphemes": 150, 141 + "description": "Text immediately before the selection" 142 + }, 143 + "suffix": { 144 + "type": "string", 145 + "maxLength": 500, 146 + "maxGraphemes": 150, 147 + "description": "Text immediately after the selection" 148 + } 149 + } 150 + }, 151 + "textPositionSelector": { 152 + "type": "object", 153 + "description": "W3C TextPositionSelector - select by character offsets", 154 + "required": ["start", "end"], 155 + "properties": { 156 + "type": { 157 + "type": "string", 158 + "const": "TextPositionSelector" 159 + }, 160 + "start": { 161 + "type": "integer", 162 + "minimum": 0, 163 + "description": "Starting character position (0-indexed, inclusive)" 164 + }, 165 + "end": { 166 + "type": "integer", 167 + "minimum": 0, 168 + "description": "Ending character position (exclusive)" 169 + } 170 + } 171 + }, 172 + "cssSelector": { 173 + "type": "object", 174 + "description": "W3C CssSelector - select DOM elements by CSS selector", 175 + "required": ["value"], 176 + "properties": { 177 + "type": { 178 + "type": "string", 179 + "const": "CssSelector" 180 + }, 181 + "value": { 182 + "type": "string", 183 + "maxLength": 2000, 184 + "description": "CSS selector string" 185 + } 186 + } 187 + }, 188 + "xpathSelector": { 189 + "type": "object", 190 + "description": "W3C XPathSelector - select by XPath expression", 191 + "required": ["value"], 192 + "properties": { 193 + "type": { 194 + "type": "string", 195 + "const": "XPathSelector" 196 + }, 197 + "value": { 198 + "type": "string", 199 + "maxLength": 2000, 200 + "description": "XPath expression" 201 + } 202 + } 203 + }, 204 + "fragmentSelector": { 205 + "type": "object", 206 + "description": "W3C FragmentSelector - select by URI fragment", 207 + "required": ["value"], 208 + "properties": { 209 + "type": { 210 + "type": "string", 211 + "const": "FragmentSelector" 212 + }, 213 + "value": { 214 + "type": "string", 215 + "maxLength": 1000, 216 + "description": "Fragment identifier value" 217 + }, 218 + "conformsTo": { 219 + "type": "string", 220 + "format": "uri", 221 + "description": "Specification the fragment conforms to" 222 + } 223 + } 224 + }, 225 + "rangeSelector": { 226 + "type": "object", 227 + "description": "W3C RangeSelector - select range between two selectors", 228 + "required": ["startSelector", "endSelector"], 229 + "properties": { 230 + "type": { 231 + "type": "string", 232 + "const": "RangeSelector" 233 + }, 234 + "startSelector": { 235 + "type": "union", 236 + "description": "Selector for range start", 237 + "refs": [ 238 + "#textQuoteSelector", 239 + "#textPositionSelector", 240 + "#cssSelector", 241 + "#xpathSelector" 242 + ] 243 + }, 244 + "endSelector": { 245 + "type": "union", 246 + "description": "Selector for range end", 247 + "refs": [ 248 + "#textQuoteSelector", 249 + "#textPositionSelector", 250 + "#cssSelector", 251 + "#xpathSelector" 252 + ] 253 + } 254 + } 255 + }, 256 + "timeState": { 257 + "type": "object", 258 + "description": "W3C TimeState - record when content was captured", 259 + "properties": { 260 + "sourceDate": { 261 + "type": "string", 262 + "format": "datetime", 263 + "description": "When the source was accessed" 264 + }, 265 + "cached": { 266 + "type": "string", 267 + "format": "uri", 268 + "description": "URL to cached/archived version" 269 + } 270 + } 271 + } 272 + } 273 + }
+30
lexicons/at/margin/authFull.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.authFull", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "Margin", 8 + "title:langs": {}, 9 + "detail": "Full access to Margin features including annotations, highlights, bookmarks, and collections.", 10 + "detail:langs": {}, 11 + "permissions": [ 12 + { 13 + "type": "permission", 14 + "resource": "repo", 15 + "action": ["create", "update", "delete"], 16 + "collection": [ 17 + "at.margin.annotation", 18 + "at.margin.highlight", 19 + "at.margin.bookmark", 20 + "at.margin.reply", 21 + "at.margin.like", 22 + "at.margin.collection", 23 + "at.margin.collectionItem", 24 + "at.margin.profile" 25 + ] 26 + } 27 + ] 28 + } 29 + } 30 + }
+52
lexicons/at/margin/bookmark.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.bookmark", 4 + "description": "A bookmark record - save URL for later", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "A bookmarked URL (motivation: bookmarking)", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["source", "createdAt"], 13 + "properties": { 14 + "source": { 15 + "type": "string", 16 + "format": "uri", 17 + "description": "The bookmarked URL" 18 + }, 19 + "sourceHash": { 20 + "type": "string", 21 + "description": "SHA256 hash of normalized URL for indexing" 22 + }, 23 + "title": { 24 + "type": "string", 25 + "maxLength": 500, 26 + "description": "Page title" 27 + }, 28 + "description": { 29 + "type": "string", 30 + "maxLength": 1000, 31 + "maxGraphemes": 300, 32 + "description": "Optional description/note" 33 + }, 34 + "tags": { 35 + "type": "array", 36 + "description": "Tags for categorization", 37 + "items": { 38 + "type": "string", 39 + "maxLength": 64, 40 + "maxGraphemes": 32 41 + }, 42 + "maxLength": 10 43 + }, 44 + "createdAt": { 45 + "type": "string", 46 + "format": "datetime" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+40
lexicons/at/margin/collection.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.collection", 4 + "description": "A collection of annotations (like a folder or notebook)", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "A named collection for organizing annotations", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["name", "createdAt"], 13 + "properties": { 14 + "name": { 15 + "type": "string", 16 + "maxLength": 100, 17 + "maxGraphemes": 50, 18 + "description": "Collection name" 19 + }, 20 + "description": { 21 + "type": "string", 22 + "maxLength": 500, 23 + "maxGraphemes": 150, 24 + "description": "Collection description" 25 + }, 26 + "icon": { 27 + "type": "string", 28 + "maxLength": 100, 29 + "maxGraphemes": 100, 30 + "description": "Emoji icon or icon identifier for the collection" 31 + }, 32 + "createdAt": { 33 + "type": "string", 34 + "format": "datetime" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+37
lexicons/at/margin/collectionItem.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.collectionItem", 4 + "description": "An item in a collection (links annotation to collection)", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "Associates an annotation with a collection", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["collection", "annotation", "createdAt"], 13 + "properties": { 14 + "collection": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "AT URI of the collection" 18 + }, 19 + "annotation": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "AT URI of the annotation, highlight, or bookmark" 23 + }, 24 + "position": { 25 + "type": "integer", 26 + "minimum": 0, 27 + "description": "Sort order within the collection" 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+42
lexicons/at/margin/highlight.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.highlight", 4 + "description": "A lightweight highlight record - annotation without body text", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "A highlight on a web page (motivation: highlighting)", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["target", "createdAt"], 13 + "properties": { 14 + "target": { 15 + "type": "ref", 16 + "ref": "at.margin.annotation#target", 17 + "description": "The resource and segment being highlighted" 18 + }, 19 + "color": { 20 + "type": "string", 21 + "description": "Highlight color (hex or named)", 22 + "maxLength": 20 23 + }, 24 + "tags": { 25 + "type": "array", 26 + "description": "Tags for categorization", 27 + "items": { 28 + "type": "string", 29 + "maxLength": 64, 30 + "maxGraphemes": 32 31 + }, 32 + "maxLength": 10 33 + }, 34 + "createdAt": { 35 + "type": "string", 36 + "format": "datetime" 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+40
lexicons/at/margin/like.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.like", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A like on an annotation or reply", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "ref", 15 + "ref": "#subjectRef", 16 + "description": "Reference to the annotation or reply being liked" 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime" 21 + } 22 + } 23 + } 24 + }, 25 + "subjectRef": { 26 + "type": "object", 27 + "required": ["uri", "cid"], 28 + "properties": { 29 + "uri": { 30 + "type": "string", 31 + "format": "at-uri" 32 + }, 33 + "cid": { 34 + "type": "string", 35 + "format": "cid" 36 + } 37 + } 38 + } 39 + } 40 + }
+40
lexicons/at/margin/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A profile for a user on the Margin network.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["createdAt"], 12 + "properties": { 13 + "bio": { 14 + "type": "string", 15 + "maxLength": 5000, 16 + "description": "User biography or description." 17 + }, 18 + "website": { 19 + "type": "string", 20 + "maxLength": 1000, 21 + "description": "User website URL." 22 + }, 23 + "links": { 24 + "type": "array", 25 + "description": "List of other relevant links (e.g. GitHub, Bluesky, etc).", 26 + "items": { 27 + "type": "string", 28 + "maxLength": 1000 29 + }, 30 + "maxLength": 20 31 + }, 32 + "createdAt": { 33 + "type": "string", 34 + "format": "datetime" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+59
lexicons/at/margin/reply.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.reply", 4 + "revision": 2, 5 + "description": "A reply to an annotation or another reply", 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "description": "A reply to an annotation (motivation: replying)", 10 + "key": "tid", 11 + "record": { 12 + "type": "object", 13 + "required": ["parent", "root", "text", "createdAt"], 14 + "properties": { 15 + "parent": { 16 + "type": "ref", 17 + "ref": "#replyRef", 18 + "description": "Reference to the parent annotation or reply" 19 + }, 20 + "root": { 21 + "type": "ref", 22 + "ref": "#replyRef", 23 + "description": "Reference to the root annotation of the thread" 24 + }, 25 + "text": { 26 + "type": "string", 27 + "maxLength": 10000, 28 + "maxGraphemes": 3000, 29 + "description": "Reply text content" 30 + }, 31 + "format": { 32 + "type": "string", 33 + "description": "MIME type of the text content", 34 + "default": "text/plain" 35 + }, 36 + "createdAt": { 37 + "type": "string", 38 + "format": "datetime" 39 + } 40 + } 41 + } 42 + }, 43 + "replyRef": { 44 + "type": "object", 45 + "description": "Strong reference to an annotation or reply", 46 + "required": ["uri", "cid"], 47 + "properties": { 48 + "uri": { 49 + "type": "string", 50 + "format": "at-uri" 51 + }, 52 + "cid": { 53 + "type": "string", 54 + "format": "cid" 55 + } 56 + } 57 + } 58 + } 59 + }
+118
src/components/createMarginCollectionModal.ts
··· 1 + import { Modal, Notice } from "obsidian"; 2 + import type ATmarkPlugin from "../main"; 3 + import { createMarginCollection } from "../lib"; 4 + 5 + export class CreateMarginCollectionModal extends Modal { 6 + plugin: ATmarkPlugin; 7 + onSuccess?: () => void; 8 + 9 + constructor(plugin: ATmarkPlugin, onSuccess?: () => void) { 10 + super(plugin.app); 11 + this.plugin = plugin; 12 + this.onSuccess = onSuccess; 13 + } 14 + 15 + onOpen() { 16 + const { contentEl } = this; 17 + contentEl.empty(); 18 + contentEl.addClass("atmark-modal"); 19 + 20 + contentEl.createEl("h2", { text: "New margin collection" }); 21 + 22 + if (!this.plugin.client) { 23 + // contentEl.createEl("p", { text: "Not Logged In. Please Login Using Settings." }); 24 + return; 25 + } 26 + 27 + const form = contentEl.createEl("form", { cls: "atmark-form" }); 28 + 29 + // Name field 30 + const nameGroup = form.createEl("div", { cls: "atmark-form-group" }); 31 + nameGroup.createEl("label", { text: "Name", attr: { for: "collection-name" } }); 32 + const nameInput = nameGroup.createEl("input", { 33 + type: "text", 34 + cls: "atmark-input", 35 + attr: { id: "collection-name", placeholder: "Collection name", required: "true" }, 36 + }); 37 + 38 + // Icon field 39 + const iconGroup = form.createEl("div", { cls: "atmark-form-group" }); 40 + iconGroup.createEl("label", { text: "Icon (optional)", attr: { for: "collection-icon" } }); 41 + const iconInput = iconGroup.createEl("input", { 42 + type: "text", 43 + cls: "atmark-input", 44 + attr: { id: "collection-icon" }, 45 + }); 46 + 47 + // Description field 48 + const descGroup = form.createEl("div", { cls: "atmark-form-group" }); 49 + descGroup.createEl("label", { text: "Description", attr: { for: "collection-desc" } }); 50 + const descInput = descGroup.createEl("textarea", { 51 + cls: "atmark-textarea", 52 + attr: { id: "collection-desc", placeholder: "Optional description", rows: "3" }, 53 + }); 54 + 55 + // Action buttons 56 + const actions = form.createEl("div", { cls: "atmark-modal-actions" }); 57 + 58 + const cancelBtn = actions.createEl("button", { 59 + text: "Cancel", 60 + cls: "atmark-btn atmark-btn-secondary", 61 + type: "button", 62 + }); 63 + cancelBtn.addEventListener("click", () => this.close()); 64 + 65 + const createBtn = actions.createEl("button", { 66 + text: "Create", 67 + cls: "atmark-btn atmark-btn-primary", 68 + type: "submit", 69 + }); 70 + 71 + form.addEventListener("submit", (e) => { 72 + e.preventDefault(); 73 + void this.handleSubmit(nameInput, iconInput, descInput, createBtn); 74 + }); 75 + 76 + // Focus name input 77 + nameInput.focus(); 78 + } 79 + 80 + private async handleSubmit( 81 + nameInput: HTMLInputElement, 82 + iconInput: HTMLInputElement, 83 + descInput: HTMLTextAreaElement, 84 + createBtn: HTMLButtonElement 85 + ) { 86 + const name = nameInput.value.trim(); 87 + if (!name) { 88 + new Notice("Please enter a collection name"); 89 + return; 90 + } 91 + 92 + createBtn.disabled = true; 93 + createBtn.textContent = "Creating..."; 94 + 95 + try { 96 + await createMarginCollection( 97 + this.plugin.client!, 98 + this.plugin.settings.identifier, 99 + name, 100 + descInput.value.trim() || undefined, 101 + iconInput.value.trim() || undefined 102 + ); 103 + 104 + new Notice(`Created collection "${name}"`); 105 + this.close(); 106 + this.onSuccess?.(); 107 + } catch (err) { 108 + const message = err instanceof Error ? err.message : String(err); 109 + new Notice(`Failed to create collection: ${message}`); 110 + createBtn.disabled = false; 111 + createBtn.textContent = "Create"; 112 + } 113 + } 114 + 115 + onClose() { 116 + this.contentEl.empty(); 117 + } 118 + }
+211
src/components/editMarginBookmarkModal.ts
··· 1 + import { Modal, Notice } from "obsidian"; 2 + import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 + import type { Main as MarginBookmark } from "../lexicons/types/at/margin/bookmark"; 4 + import type ATmarkPlugin from "../main"; 5 + import { putRecord, deleteRecord } from "../lib"; 6 + 7 + type MarginBookmarkRecord = Record & { value: MarginBookmark }; 8 + 9 + export class EditMarginBookmarkModal extends Modal { 10 + plugin: ATmarkPlugin; 11 + record: MarginBookmarkRecord; 12 + onSuccess?: () => void; 13 + tagInputs: HTMLInputElement[] = []; 14 + 15 + constructor(plugin: ATmarkPlugin, record: MarginBookmarkRecord, onSuccess?: () => void) { 16 + super(plugin.app); 17 + this.plugin = plugin; 18 + this.record = record; 19 + this.onSuccess = onSuccess; 20 + } 21 + 22 + onOpen() { 23 + const { contentEl } = this; 24 + contentEl.empty(); 25 + contentEl.addClass("atmark-modal"); 26 + 27 + contentEl.createEl("h2", { text: "Edit margin bookmark" }); 28 + 29 + if (!this.plugin.client) { 30 + contentEl.createEl("p", { text: "Not connected." }); 31 + return; 32 + } 33 + 34 + const existingTags = this.record.value.tags || []; 35 + 36 + const form = contentEl.createEl("div", { cls: "atmark-form" }); 37 + 38 + // Tags section 39 + const tagsGroup = form.createEl("div", { cls: "atmark-form-group" }); 40 + tagsGroup.createEl("label", { text: "Tags" }); 41 + 42 + const tagsContainer = tagsGroup.createEl("div", { cls: "atmark-tags-container" }); 43 + 44 + // Render existing tags 45 + for (const tag of existingTags) { 46 + this.addTagInput(tagsContainer, tag); 47 + } 48 + 49 + // Add empty input for new tag 50 + this.addTagInput(tagsContainer, ""); 51 + 52 + // Add tag button 53 + const addTagBtn = tagsGroup.createEl("button", { 54 + text: "Add tag", 55 + cls: "atmark-btn atmark-btn-secondary" 56 + }); 57 + addTagBtn.addEventListener("click", (e) => { 58 + e.preventDefault(); 59 + this.addTagInput(tagsContainer, ""); 60 + }); 61 + 62 + // Action buttons 63 + const actions = contentEl.createEl("div", { cls: "atmark-modal-actions" }); 64 + 65 + const deleteBtn = actions.createEl("button", { 66 + text: "Delete", 67 + cls: "atmark-btn atmark-btn-danger" 68 + }); 69 + deleteBtn.addEventListener("click", () => { this.confirmDelete(contentEl); }); 70 + 71 + actions.createEl("div", { cls: "atmark-spacer" }); 72 + 73 + const cancelBtn = actions.createEl("button", { 74 + text: "Cancel", 75 + cls: "atmark-btn atmark-btn-secondary" 76 + }); 77 + cancelBtn.addEventListener("click", () => { this.close(); }); 78 + 79 + const saveBtn = actions.createEl("button", { 80 + text: "Save", 81 + cls: "atmark-btn atmark-btn-primary" 82 + }); 83 + saveBtn.addEventListener("click", () => { void this.saveChanges(); }); 84 + } 85 + 86 + private addTagInput(container: HTMLElement, value: string) { 87 + const tagRow = container.createEl("div", { cls: "atmark-tag-row" }); 88 + 89 + const input = tagRow.createEl("input", { 90 + type: "text", 91 + cls: "atmark-input", 92 + value, 93 + attr: { placeholder: "Enter tag..." } 94 + }); 95 + this.tagInputs.push(input); 96 + 97 + const removeBtn = tagRow.createEl("button", { 98 + text: "×", 99 + cls: "atmark-btn atmark-btn-secondary atmark-tag-remove-btn" 100 + }); 101 + removeBtn.addEventListener("click", (e) => { 102 + e.preventDefault(); 103 + tagRow.remove(); 104 + this.tagInputs = this.tagInputs.filter(i => i !== input); 105 + }); 106 + } 107 + 108 + private confirmDelete(contentEl: HTMLElement) { 109 + contentEl.empty(); 110 + contentEl.createEl("h2", { text: "Delete bookmark" }); 111 + contentEl.createEl("p", { text: "Delete this bookmark?", cls: "atmark-warning-text" }); 112 + 113 + const actions = contentEl.createEl("div", { cls: "atmark-modal-actions" }); 114 + 115 + const cancelBtn = actions.createEl("button", { 116 + text: "Cancel", 117 + cls: "atmark-btn atmark-btn-secondary" 118 + }); 119 + cancelBtn.addEventListener("click", () => { 120 + void this.onOpen(); 121 + }); 122 + 123 + const confirmBtn = actions.createEl("button", { 124 + text: "Delete", 125 + cls: "atmark-btn atmark-btn-danger" 126 + }); 127 + confirmBtn.addEventListener("click", () => { void this.deleteBookmark(); }); 128 + } 129 + 130 + private async deleteBookmark() { 131 + if (!this.plugin.client) return; 132 + 133 + const { contentEl } = this; 134 + contentEl.empty(); 135 + contentEl.createEl("p", { text: "Deleting bookmark..." }); 136 + 137 + try { 138 + const rkey = this.record.uri.split("/").pop(); 139 + if (!rkey) { 140 + contentEl.empty(); 141 + contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "atmark-error" }); 142 + return; 143 + } 144 + 145 + await deleteRecord( 146 + this.plugin.client, 147 + this.plugin.settings.identifier, 148 + "at.margin.bookmark", 149 + rkey 150 + ); 151 + 152 + new Notice("Bookmark deleted"); 153 + this.close(); 154 + this.onSuccess?.(); 155 + } catch (err) { 156 + contentEl.empty(); 157 + const message = err instanceof Error ? err.message : String(err); 158 + contentEl.createEl("p", { text: `Failed to delete: ${message}`, cls: "atmark-error" }); 159 + } 160 + } 161 + 162 + private async saveChanges() { 163 + if (!this.plugin.client) return; 164 + 165 + const { contentEl } = this; 166 + contentEl.empty(); 167 + contentEl.createEl("p", { text: "Saving changes..." }); 168 + 169 + try { 170 + // Get non-empty unique tags 171 + const tags = [...new Set( 172 + this.tagInputs 173 + .map(input => input.value.trim()) 174 + .filter(tag => tag.length > 0) 175 + )]; 176 + 177 + const rkey = this.record.uri.split("/").pop(); 178 + if (!rkey) { 179 + contentEl.empty(); 180 + contentEl.createEl("p", { text: "Invalid bookmark uri.", cls: "atmark-error" }); 181 + return; 182 + } 183 + 184 + // Update the record with new tags 185 + const updatedRecord: MarginBookmark = { 186 + ...this.record.value, 187 + tags, 188 + }; 189 + 190 + await putRecord( 191 + this.plugin.client, 192 + this.plugin.settings.identifier, 193 + "at.margin.bookmark", 194 + rkey, 195 + updatedRecord 196 + ); 197 + 198 + new Notice("Tags updated"); 199 + this.close(); 200 + this.onSuccess?.(); 201 + } catch (err) { 202 + contentEl.empty(); 203 + const message = err instanceof Error ? err.message : String(err); 204 + contentEl.createEl("p", { text: `Failed to save: ${message}`, cls: "atmark-error" }); 205 + } 206 + } 207 + 208 + onClose() { 209 + this.contentEl.empty(); 210 + } 211 + }
+8
src/lexicons/index.ts
··· 1 + export * as AtMarginAnnotation from "./types/at/margin/annotation.js"; 2 + export * as AtMarginBookmark from "./types/at/margin/bookmark.js"; 3 + export * as AtMarginCollection from "./types/at/margin/collection.js"; 4 + export * as AtMarginCollectionItem from "./types/at/margin/collectionItem.js"; 5 + export * as AtMarginHighlight from "./types/at/margin/highlight.js"; 6 + export * as AtMarginLike from "./types/at/margin/like.js"; 7 + export * as AtMarginProfile from "./types/at/margin/profile.js"; 8 + export * as AtMarginReply from "./types/at/margin/reply.js"; 1 9 export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js"; 2 10 export * as NetworkCosmikCard from "./types/network/cosmik/card.js"; 3 11 export * as NetworkCosmikCollection from "./types/network/cosmik/collection.js";
+330
src/lexicons/types/at/margin/annotation.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _bodySchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("at.margin.annotation#body"), 8 + ), 9 + /** 10 + * MIME type of the body content 11 + * @default "text/plain" 12 + */ 13 + format: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string(), "text/plain"), 14 + /** 15 + * BCP47 language tag 16 + */ 17 + language: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 18 + /** 19 + * Reference to external body content 20 + */ 21 + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 22 + /** 23 + * Text content of the annotation 24 + * @maxLength 10000 25 + * @maxGraphemes 3000 26 + */ 27 + value: /*#__PURE__*/ v.optional( 28 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 29 + /*#__PURE__*/ v.stringLength(0, 10000), 30 + /*#__PURE__*/ v.stringGraphemes(0, 3000), 31 + ]), 32 + ), 33 + }); 34 + const _cssSelectorSchema = /*#__PURE__*/ v.object({ 35 + $type: /*#__PURE__*/ v.optional( 36 + /*#__PURE__*/ v.literal("at.margin.annotation#cssSelector"), 37 + ), 38 + type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("CssSelector")), 39 + /** 40 + * CSS selector string 41 + * @maxLength 2000 42 + */ 43 + value: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 44 + /*#__PURE__*/ v.stringLength(0, 2000), 45 + ]), 46 + }); 47 + const _fragmentSelectorSchema = /*#__PURE__*/ v.object({ 48 + $type: /*#__PURE__*/ v.optional( 49 + /*#__PURE__*/ v.literal("at.margin.annotation#fragmentSelector"), 50 + ), 51 + /** 52 + * Specification the fragment conforms to 53 + */ 54 + conformsTo: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 55 + type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("FragmentSelector")), 56 + /** 57 + * Fragment identifier value 58 + * @maxLength 1000 59 + */ 60 + value: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 61 + /*#__PURE__*/ v.stringLength(0, 1000), 62 + ]), 63 + }); 64 + const _mainSchema = /*#__PURE__*/ v.record( 65 + /*#__PURE__*/ v.tidString(), 66 + /*#__PURE__*/ v.object({ 67 + $type: /*#__PURE__*/ v.literal("at.margin.annotation"), 68 + /** 69 + * The annotation content (text or reference) 70 + */ 71 + get body() { 72 + return /*#__PURE__*/ v.optional(bodySchema); 73 + }, 74 + createdAt: /*#__PURE__*/ v.datetimeString(), 75 + /** 76 + * W3C motivation for the annotation 77 + */ 78 + motivation: /*#__PURE__*/ v.optional( 79 + /*#__PURE__*/ v.string< 80 + | "assessing" 81 + | "bookmarking" 82 + | "commenting" 83 + | "describing" 84 + | "editing" 85 + | "highlighting" 86 + | "linking" 87 + | "questioning" 88 + | "replying" 89 + | "tagging" 90 + | (string & {}) 91 + >(), 92 + ), 93 + /** 94 + * Tags for categorization 95 + * @maxLength 10 96 + */ 97 + tags: /*#__PURE__*/ v.optional( 98 + /*#__PURE__*/ v.constrain( 99 + /*#__PURE__*/ v.array( 100 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 101 + /*#__PURE__*/ v.stringLength(0, 64), 102 + /*#__PURE__*/ v.stringGraphemes(0, 32), 103 + ]), 104 + ), 105 + [/*#__PURE__*/ v.arrayLength(0, 10)], 106 + ), 107 + ), 108 + /** 109 + * The resource being annotated with optional selector 110 + */ 111 + get target() { 112 + return targetSchema; 113 + }, 114 + }), 115 + ); 116 + const _rangeSelectorSchema = /*#__PURE__*/ v.object({ 117 + $type: /*#__PURE__*/ v.optional( 118 + /*#__PURE__*/ v.literal("at.margin.annotation#rangeSelector"), 119 + ), 120 + /** 121 + * Selector for range end 122 + */ 123 + get endSelector() { 124 + return /*#__PURE__*/ v.variant([ 125 + cssSelectorSchema, 126 + textPositionSelectorSchema, 127 + textQuoteSelectorSchema, 128 + xpathSelectorSchema, 129 + ]); 130 + }, 131 + /** 132 + * Selector for range start 133 + */ 134 + get startSelector() { 135 + return /*#__PURE__*/ v.variant([ 136 + cssSelectorSchema, 137 + textPositionSelectorSchema, 138 + textQuoteSelectorSchema, 139 + xpathSelectorSchema, 140 + ]); 141 + }, 142 + type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("RangeSelector")), 143 + }); 144 + const _targetSchema = /*#__PURE__*/ v.object({ 145 + $type: /*#__PURE__*/ v.optional( 146 + /*#__PURE__*/ v.literal("at.margin.annotation#target"), 147 + ), 148 + /** 149 + * Selector to identify the specific segment 150 + */ 151 + get selector() { 152 + return /*#__PURE__*/ v.optional( 153 + /*#__PURE__*/ v.variant([ 154 + cssSelectorSchema, 155 + fragmentSelectorSchema, 156 + rangeSelectorSchema, 157 + textPositionSelectorSchema, 158 + textQuoteSelectorSchema, 159 + xpathSelectorSchema, 160 + ]), 161 + ); 162 + }, 163 + /** 164 + * The URL being annotated 165 + */ 166 + source: /*#__PURE__*/ v.genericUriString(), 167 + /** 168 + * SHA256 hash of normalized URL for indexing 169 + */ 170 + sourceHash: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 171 + /** 172 + * State of the resource at annotation time 173 + */ 174 + get state() { 175 + return /*#__PURE__*/ v.optional(timeStateSchema); 176 + }, 177 + /** 178 + * Page title at time of annotation 179 + * @maxLength 500 180 + */ 181 + title: /*#__PURE__*/ v.optional( 182 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 183 + /*#__PURE__*/ v.stringLength(0, 500), 184 + ]), 185 + ), 186 + }); 187 + const _textPositionSelectorSchema = /*#__PURE__*/ v.object({ 188 + $type: /*#__PURE__*/ v.optional( 189 + /*#__PURE__*/ v.literal("at.margin.annotation#textPositionSelector"), 190 + ), 191 + /** 192 + * Ending character position (exclusive) 193 + * @minimum 0 194 + */ 195 + end: /*#__PURE__*/ v.integer(), 196 + /** 197 + * Starting character position (0-indexed, inclusive) 198 + * @minimum 0 199 + */ 200 + start: /*#__PURE__*/ v.integer(), 201 + type: /*#__PURE__*/ v.optional( 202 + /*#__PURE__*/ v.literal("TextPositionSelector"), 203 + ), 204 + }); 205 + const _textQuoteSelectorSchema = /*#__PURE__*/ v.object({ 206 + $type: /*#__PURE__*/ v.optional( 207 + /*#__PURE__*/ v.literal("at.margin.annotation#textQuoteSelector"), 208 + ), 209 + /** 210 + * The exact text to match 211 + * @maxLength 5000 212 + * @maxGraphemes 1500 213 + */ 214 + exact: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 215 + /*#__PURE__*/ v.stringLength(0, 5000), 216 + /*#__PURE__*/ v.stringGraphemes(0, 1500), 217 + ]), 218 + /** 219 + * Text immediately before the selection 220 + * @maxLength 500 221 + * @maxGraphemes 150 222 + */ 223 + prefix: /*#__PURE__*/ v.optional( 224 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 225 + /*#__PURE__*/ v.stringLength(0, 500), 226 + /*#__PURE__*/ v.stringGraphemes(0, 150), 227 + ]), 228 + ), 229 + /** 230 + * Text immediately after the selection 231 + * @maxLength 500 232 + * @maxGraphemes 150 233 + */ 234 + suffix: /*#__PURE__*/ v.optional( 235 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 236 + /*#__PURE__*/ v.stringLength(0, 500), 237 + /*#__PURE__*/ v.stringGraphemes(0, 150), 238 + ]), 239 + ), 240 + type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("TextQuoteSelector")), 241 + }); 242 + const _timeStateSchema = /*#__PURE__*/ v.object({ 243 + $type: /*#__PURE__*/ v.optional( 244 + /*#__PURE__*/ v.literal("at.margin.annotation#timeState"), 245 + ), 246 + /** 247 + * URL to cached/archived version 248 + */ 249 + cached: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 250 + /** 251 + * When the source was accessed 252 + */ 253 + sourceDate: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 254 + }); 255 + const _xpathSelectorSchema = /*#__PURE__*/ v.object({ 256 + $type: /*#__PURE__*/ v.optional( 257 + /*#__PURE__*/ v.literal("at.margin.annotation#xpathSelector"), 258 + ), 259 + type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("XPathSelector")), 260 + /** 261 + * XPath expression 262 + * @maxLength 2000 263 + */ 264 + value: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 265 + /*#__PURE__*/ v.stringLength(0, 2000), 266 + ]), 267 + }); 268 + 269 + type body$schematype = typeof _bodySchema; 270 + type cssSelector$schematype = typeof _cssSelectorSchema; 271 + type fragmentSelector$schematype = typeof _fragmentSelectorSchema; 272 + type main$schematype = typeof _mainSchema; 273 + type rangeSelector$schematype = typeof _rangeSelectorSchema; 274 + type target$schematype = typeof _targetSchema; 275 + type textPositionSelector$schematype = typeof _textPositionSelectorSchema; 276 + type textQuoteSelector$schematype = typeof _textQuoteSelectorSchema; 277 + type timeState$schematype = typeof _timeStateSchema; 278 + type xpathSelector$schematype = typeof _xpathSelectorSchema; 279 + 280 + export interface bodySchema extends body$schematype {} 281 + export interface cssSelectorSchema extends cssSelector$schematype {} 282 + export interface fragmentSelectorSchema extends fragmentSelector$schematype {} 283 + export interface mainSchema extends main$schematype {} 284 + export interface rangeSelectorSchema extends rangeSelector$schematype {} 285 + export interface targetSchema extends target$schematype {} 286 + export interface textPositionSelectorSchema extends textPositionSelector$schematype {} 287 + export interface textQuoteSelectorSchema extends textQuoteSelector$schematype {} 288 + export interface timeStateSchema extends timeState$schematype {} 289 + export interface xpathSelectorSchema extends xpathSelector$schematype {} 290 + 291 + export const bodySchema = _bodySchema as bodySchema; 292 + export const cssSelectorSchema = _cssSelectorSchema as cssSelectorSchema; 293 + export const fragmentSelectorSchema = 294 + _fragmentSelectorSchema as fragmentSelectorSchema; 295 + export const mainSchema = _mainSchema as mainSchema; 296 + export const rangeSelectorSchema = _rangeSelectorSchema as rangeSelectorSchema; 297 + export const targetSchema = _targetSchema as targetSchema; 298 + export const textPositionSelectorSchema = 299 + _textPositionSelectorSchema as textPositionSelectorSchema; 300 + export const textQuoteSelectorSchema = 301 + _textQuoteSelectorSchema as textQuoteSelectorSchema; 302 + export const timeStateSchema = _timeStateSchema as timeStateSchema; 303 + export const xpathSelectorSchema = _xpathSelectorSchema as xpathSelectorSchema; 304 + 305 + export interface Body extends v.InferInput<typeof bodySchema> {} 306 + export interface CssSelector extends v.InferInput<typeof cssSelectorSchema> {} 307 + export interface FragmentSelector extends v.InferInput< 308 + typeof fragmentSelectorSchema 309 + > {} 310 + export interface Main extends v.InferInput<typeof mainSchema> {} 311 + export interface RangeSelector extends v.InferInput< 312 + typeof rangeSelectorSchema 313 + > {} 314 + export interface Target extends v.InferInput<typeof targetSchema> {} 315 + export interface TextPositionSelector extends v.InferInput< 316 + typeof textPositionSelectorSchema 317 + > {} 318 + export interface TextQuoteSelector extends v.InferInput< 319 + typeof textQuoteSelectorSchema 320 + > {} 321 + export interface TimeState extends v.InferInput<typeof timeStateSchema> {} 322 + export interface XpathSelector extends v.InferInput< 323 + typeof xpathSelectorSchema 324 + > {} 325 + 326 + declare module "@atcute/lexicons/ambient" { 327 + interface Records { 328 + "at.margin.annotation": mainSchema; 329 + } 330 + }
+68
src/lexicons/types/at/margin/bookmark.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.record( 6 + /*#__PURE__*/ v.tidString(), 7 + /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("at.margin.bookmark"), 9 + createdAt: /*#__PURE__*/ v.datetimeString(), 10 + /** 11 + * Optional description/note 12 + * @maxLength 1000 13 + * @maxGraphemes 300 14 + */ 15 + description: /*#__PURE__*/ v.optional( 16 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 17 + /*#__PURE__*/ v.stringLength(0, 1000), 18 + /*#__PURE__*/ v.stringGraphemes(0, 300), 19 + ]), 20 + ), 21 + /** 22 + * The bookmarked URL 23 + */ 24 + source: /*#__PURE__*/ v.genericUriString(), 25 + /** 26 + * SHA256 hash of normalized URL for indexing 27 + */ 28 + sourceHash: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 29 + /** 30 + * Tags for categorization 31 + * @maxLength 10 32 + */ 33 + tags: /*#__PURE__*/ v.optional( 34 + /*#__PURE__*/ v.constrain( 35 + /*#__PURE__*/ v.array( 36 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 37 + /*#__PURE__*/ v.stringLength(0, 64), 38 + /*#__PURE__*/ v.stringGraphemes(0, 32), 39 + ]), 40 + ), 41 + [/*#__PURE__*/ v.arrayLength(0, 10)], 42 + ), 43 + ), 44 + /** 45 + * Page title 46 + * @maxLength 500 47 + */ 48 + title: /*#__PURE__*/ v.optional( 49 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 50 + /*#__PURE__*/ v.stringLength(0, 500), 51 + ]), 52 + ), 53 + }), 54 + ); 55 + 56 + type main$schematype = typeof _mainSchema; 57 + 58 + export interface mainSchema extends main$schematype {} 59 + 60 + export const mainSchema = _mainSchema as mainSchema; 61 + 62 + export interface Main extends v.InferInput<typeof mainSchema> {} 63 + 64 + declare module "@atcute/lexicons/ambient" { 65 + interface Records { 66 + "at.margin.bookmark": mainSchema; 67 + } 68 + }
+56
src/lexicons/types/at/margin/collection.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.record( 6 + /*#__PURE__*/ v.tidString(), 7 + /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("at.margin.collection"), 9 + createdAt: /*#__PURE__*/ v.datetimeString(), 10 + /** 11 + * Collection description 12 + * @maxLength 500 13 + * @maxGraphemes 150 14 + */ 15 + description: /*#__PURE__*/ v.optional( 16 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 17 + /*#__PURE__*/ v.stringLength(0, 500), 18 + /*#__PURE__*/ v.stringGraphemes(0, 150), 19 + ]), 20 + ), 21 + /** 22 + * Emoji icon or icon identifier for the collection 23 + * @maxLength 100 24 + * @maxGraphemes 100 25 + */ 26 + icon: /*#__PURE__*/ v.optional( 27 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 28 + /*#__PURE__*/ v.stringLength(0, 100), 29 + /*#__PURE__*/ v.stringGraphemes(0, 100), 30 + ]), 31 + ), 32 + /** 33 + * Collection name 34 + * @maxLength 100 35 + * @maxGraphemes 50 36 + */ 37 + name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 38 + /*#__PURE__*/ v.stringLength(0, 100), 39 + /*#__PURE__*/ v.stringGraphemes(0, 50), 40 + ]), 41 + }), 42 + ); 43 + 44 + type main$schematype = typeof _mainSchema; 45 + 46 + export interface mainSchema extends main$schematype {} 47 + 48 + export const mainSchema = _mainSchema as mainSchema; 49 + 50 + export interface Main extends v.InferInput<typeof mainSchema> {} 51 + 52 + declare module "@atcute/lexicons/ambient" { 53 + interface Records { 54 + "at.margin.collection": mainSchema; 55 + } 56 + }
+38
src/lexicons/types/at/margin/collectionItem.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.record( 6 + /*#__PURE__*/ v.tidString(), 7 + /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("at.margin.collectionItem"), 9 + /** 10 + * AT URI of the annotation, highlight, or bookmark 11 + */ 12 + annotation: /*#__PURE__*/ v.resourceUriString(), 13 + /** 14 + * AT URI of the collection 15 + */ 16 + collection: /*#__PURE__*/ v.resourceUriString(), 17 + createdAt: /*#__PURE__*/ v.datetimeString(), 18 + /** 19 + * Sort order within the collection 20 + * @minimum 0 21 + */ 22 + position: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 23 + }), 24 + ); 25 + 26 + type main$schematype = typeof _mainSchema; 27 + 28 + export interface mainSchema extends main$schematype {} 29 + 30 + export const mainSchema = _mainSchema as mainSchema; 31 + 32 + export interface Main extends v.InferInput<typeof mainSchema> {} 33 + 34 + declare module "@atcute/lexicons/ambient" { 35 + interface Records { 36 + "at.margin.collectionItem": mainSchema; 37 + } 38 + }
+56
src/lexicons/types/at/margin/highlight.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as AtMarginAnnotation from "./annotation.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.record( 7 + /*#__PURE__*/ v.tidString(), 8 + /*#__PURE__*/ v.object({ 9 + $type: /*#__PURE__*/ v.literal("at.margin.highlight"), 10 + /** 11 + * Highlight color (hex or named) 12 + * @maxLength 20 13 + */ 14 + color: /*#__PURE__*/ v.optional( 15 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 16 + /*#__PURE__*/ v.stringLength(0, 20), 17 + ]), 18 + ), 19 + createdAt: /*#__PURE__*/ v.datetimeString(), 20 + /** 21 + * Tags for categorization 22 + * @maxLength 10 23 + */ 24 + tags: /*#__PURE__*/ v.optional( 25 + /*#__PURE__*/ v.constrain( 26 + /*#__PURE__*/ v.array( 27 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 28 + /*#__PURE__*/ v.stringLength(0, 64), 29 + /*#__PURE__*/ v.stringGraphemes(0, 32), 30 + ]), 31 + ), 32 + [/*#__PURE__*/ v.arrayLength(0, 10)], 33 + ), 34 + ), 35 + /** 36 + * The resource and segment being highlighted 37 + */ 38 + get target() { 39 + return AtMarginAnnotation.targetSchema; 40 + }, 41 + }), 42 + ); 43 + 44 + type main$schematype = typeof _mainSchema; 45 + 46 + export interface mainSchema extends main$schematype {} 47 + 48 + export const mainSchema = _mainSchema as mainSchema; 49 + 50 + export interface Main extends v.InferInput<typeof mainSchema> {} 51 + 52 + declare module "@atcute/lexicons/ambient" { 53 + interface Records { 54 + "at.margin.highlight": mainSchema; 55 + } 56 + }
+42
src/lexicons/types/at/margin/like.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.record( 6 + /*#__PURE__*/ v.tidString(), 7 + /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("at.margin.like"), 9 + createdAt: /*#__PURE__*/ v.datetimeString(), 10 + /** 11 + * Reference to the annotation or reply being liked 12 + */ 13 + get subject() { 14 + return subjectRefSchema; 15 + }, 16 + }), 17 + ); 18 + const _subjectRefSchema = /*#__PURE__*/ v.object({ 19 + $type: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.literal("at.margin.like#subjectRef"), 21 + ), 22 + cid: /*#__PURE__*/ v.cidString(), 23 + uri: /*#__PURE__*/ v.resourceUriString(), 24 + }); 25 + 26 + type main$schematype = typeof _mainSchema; 27 + type subjectRef$schematype = typeof _subjectRefSchema; 28 + 29 + export interface mainSchema extends main$schematype {} 30 + export interface subjectRefSchema extends subjectRef$schematype {} 31 + 32 + export const mainSchema = _mainSchema as mainSchema; 33 + export const subjectRefSchema = _subjectRefSchema as subjectRefSchema; 34 + 35 + export interface Main extends v.InferInput<typeof mainSchema> {} 36 + export interface SubjectRef extends v.InferInput<typeof subjectRefSchema> {} 37 + 38 + declare module "@atcute/lexicons/ambient" { 39 + interface Records { 40 + "at.margin.like": mainSchema; 41 + } 42 + }
+57
src/lexicons/types/at/margin/profile.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.record( 6 + /*#__PURE__*/ v.literal("self"), 7 + /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("at.margin.profile"), 9 + /** 10 + * User biography or description. 11 + * @maxLength 5000 12 + */ 13 + bio: /*#__PURE__*/ v.optional( 14 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 15 + /*#__PURE__*/ v.stringLength(0, 5000), 16 + ]), 17 + ), 18 + createdAt: /*#__PURE__*/ v.datetimeString(), 19 + /** 20 + * List of other relevant links (e.g. GitHub, Bluesky, etc). 21 + * @maxLength 20 22 + */ 23 + links: /*#__PURE__*/ v.optional( 24 + /*#__PURE__*/ v.constrain( 25 + /*#__PURE__*/ v.array( 26 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 27 + /*#__PURE__*/ v.stringLength(0, 1000), 28 + ]), 29 + ), 30 + [/*#__PURE__*/ v.arrayLength(0, 20)], 31 + ), 32 + ), 33 + /** 34 + * User website URL. 35 + * @maxLength 1000 36 + */ 37 + website: /*#__PURE__*/ v.optional( 38 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 39 + /*#__PURE__*/ v.stringLength(0, 1000), 40 + ]), 41 + ), 42 + }), 43 + ); 44 + 45 + type main$schematype = typeof _mainSchema; 46 + 47 + export interface mainSchema extends main$schematype {} 48 + 49 + export const mainSchema = _mainSchema as mainSchema; 50 + 51 + export interface Main extends v.InferInput<typeof mainSchema> {} 52 + 53 + declare module "@atcute/lexicons/ambient" { 54 + interface Records { 55 + "at.margin.profile": mainSchema; 56 + } 57 + }
+62
src/lexicons/types/at/margin/reply.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.record( 6 + /*#__PURE__*/ v.tidString(), 7 + /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("at.margin.reply"), 9 + createdAt: /*#__PURE__*/ v.datetimeString(), 10 + /** 11 + * MIME type of the text content 12 + * @default "text/plain" 13 + */ 14 + format: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string(), "text/plain"), 15 + /** 16 + * Reference to the parent annotation or reply 17 + */ 18 + get parent() { 19 + return replyRefSchema; 20 + }, 21 + /** 22 + * Reference to the root annotation of the thread 23 + */ 24 + get root() { 25 + return replyRefSchema; 26 + }, 27 + /** 28 + * Reply text content 29 + * @maxLength 10000 30 + * @maxGraphemes 3000 31 + */ 32 + text: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 33 + /*#__PURE__*/ v.stringLength(0, 10000), 34 + /*#__PURE__*/ v.stringGraphemes(0, 3000), 35 + ]), 36 + }), 37 + ); 38 + const _replyRefSchema = /*#__PURE__*/ v.object({ 39 + $type: /*#__PURE__*/ v.optional( 40 + /*#__PURE__*/ v.literal("at.margin.reply#replyRef"), 41 + ), 42 + cid: /*#__PURE__*/ v.cidString(), 43 + uri: /*#__PURE__*/ v.resourceUriString(), 44 + }); 45 + 46 + type main$schematype = typeof _mainSchema; 47 + type replyRef$schematype = typeof _replyRefSchema; 48 + 49 + export interface mainSchema extends main$schematype {} 50 + export interface replyRefSchema extends replyRef$schematype {} 51 + 52 + export const mainSchema = _mainSchema as mainSchema; 53 + export const replyRefSchema = _replyRefSchema as replyRefSchema; 54 + 55 + export interface Main extends v.InferInput<typeof mainSchema> {} 56 + export interface ReplyRef extends v.InferInput<typeof replyRefSchema> {} 57 + 58 + declare module "@atcute/lexicons/ambient" { 59 + interface Records { 60 + "at.margin.reply": mainSchema; 61 + } 62 + }
+8
src/lib.ts
··· 11 11 } from "./lib/cosmik"; 12 12 13 13 export { getBookmarks, createBookmark, getTags, createTag } from "./lib/bookmarks"; 14 + 15 + export { 16 + getMarginBookmarks, 17 + createMarginBookmark, 18 + getMarginCollections, 19 + getMarginCollectionItems, 20 + createMarginCollection, 21 + } from "./lib/margin";
+78
src/lib/margin.ts
··· 1 + import type { Client } from "@atcute/client"; 2 + import type { ActorIdentifier, Nsid } from "@atcute/lexicons"; 3 + 4 + export async function getMarginBookmarks(client: Client, repo: string) { 5 + return await client.get("com.atproto.repo.listRecords", { 6 + params: { 7 + repo: repo as ActorIdentifier, 8 + collection: "at.margin.bookmark" as Nsid, 9 + limit: 100, 10 + }, 11 + }); 12 + } 13 + 14 + export async function createMarginBookmark( 15 + client: Client, 16 + repo: string, 17 + source: 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: "at.margin.bookmark" as Nsid, 26 + record: { 27 + $type: "at.margin.bookmark", 28 + source, 29 + title, 30 + description, 31 + tags, 32 + createdAt: new Date().toISOString(), 33 + }, 34 + }, 35 + }); 36 + } 37 + 38 + export async function getMarginCollections(client: Client, repo: string) { 39 + return await client.get("com.atproto.repo.listRecords", { 40 + params: { 41 + repo: repo as ActorIdentifier, 42 + collection: "at.margin.collection" as Nsid, 43 + limit: 100, 44 + }, 45 + }); 46 + } 47 + 48 + export async function getMarginCollectionItems(client: Client, repo: string) { 49 + return await client.get("com.atproto.repo.listRecords", { 50 + params: { 51 + repo: repo as ActorIdentifier, 52 + collection: "at.margin.collectionItem" as Nsid, 53 + limit: 100, 54 + }, 55 + }); 56 + } 57 + 58 + export async function createMarginCollection( 59 + client: Client, 60 + repo: string, 61 + name: string, 62 + description?: string, 63 + icon?: string 64 + ) { 65 + return await client.post("com.atproto.repo.createRecord", { 66 + input: { 67 + repo: repo as ActorIdentifier, 68 + collection: "at.margin.collection" as Nsid, 69 + record: { 70 + $type: "at.margin.collection", 71 + name, 72 + description, 73 + icon, 74 + createdAt: new Date().toISOString(), 75 + }, 76 + }, 77 + }); 78 + }
+5
src/main.ts
··· 19 19 return new ATmarkView(leaf, this); 20 20 }); 21 21 22 + // eslint-disable-next-line obsidianmd/ui/sentence-case 23 + this.addRibbonIcon("layers", "Open ATmark", () => { 24 + void this.activateView(VIEW_TYPE_ATMARK); 25 + }); 26 + 22 27 this.addCommand({ 23 28 id: "view", 24 29 name: "Open view",
+317
src/sources/margin.ts
··· 1 + import type { Client } from "@atcute/client"; 2 + import type { Record } from "@atcute/atproto/types/repo/listRecords"; 3 + import { setIcon } from "obsidian"; 4 + import type ATmarkPlugin from "../main"; 5 + import { getMarginBookmarks, getMarginCollections, getMarginCollectionItems } from "../lib"; 6 + import type { ATmarkItem, DataSource, SourceFilter } from "./types"; 7 + import type { Main as MarginBookmark } from "../lexicons/types/at/margin/bookmark"; 8 + import type { Main as MarginCollection } from "../lexicons/types/at/margin/collection"; 9 + import type { Main as MarginCollectionItem } from "../lexicons/types/at/margin/collectionItem"; 10 + import { EditMarginBookmarkModal } from "../components/editMarginBookmarkModal"; 11 + import { CreateMarginCollectionModal } from "../components/createMarginCollectionModal"; 12 + 13 + type MarginBookmarkRecord = Record & { value: MarginBookmark }; 14 + type MarginCollectionRecord = Record & { value: MarginCollection }; 15 + type MarginCollectionItemRecord = Record & { value: MarginCollectionItem }; 16 + 17 + class MarginItem implements ATmarkItem { 18 + private record: MarginBookmarkRecord; 19 + private plugin: ATmarkPlugin; 20 + private collections: Array<{ uri: string; name: string }>; 21 + 22 + constructor(record: MarginBookmarkRecord, collections: Array<{ uri: string; name: string }>, plugin: ATmarkPlugin) { 23 + this.record = record; 24 + this.collections = collections; 25 + this.plugin = plugin; 26 + } 27 + 28 + getUri(): string { 29 + return this.record.uri; 30 + } 31 + 32 + getCid(): string { 33 + return this.record.cid; 34 + } 35 + 36 + getCreatedAt(): string { 37 + return this.record.value.createdAt; 38 + } 39 + 40 + getSource(): "margin" { 41 + return "margin"; 42 + } 43 + 44 + canAddNotes(): boolean { 45 + return false; 46 + } 47 + 48 + canEdit(): boolean { 49 + return true; 50 + } 51 + 52 + openEditModal(onSuccess?: () => void): void { 53 + new EditMarginBookmarkModal(this.plugin, this.record, onSuccess).open(); 54 + } 55 + 56 + render(container: HTMLElement): void { 57 + const el = container.createEl("div", { cls: "atmark-item-content" }); 58 + const bookmark = this.record.value; 59 + 60 + // Display collections 61 + if (this.collections.length > 0) { 62 + const collectionsContainer = el.createEl("div", { cls: "atmark-item-collections" }); 63 + for (const collection of this.collections) { 64 + collectionsContainer.createEl("span", { text: collection.name, cls: "atmark-collection" }); 65 + } 66 + } 67 + 68 + // Display tags 69 + if (bookmark.tags && bookmark.tags.length > 0) { 70 + const tagsContainer = el.createEl("div", { cls: "atmark-item-tags" }); 71 + for (const tag of bookmark.tags) { 72 + tagsContainer.createEl("span", { text: tag, cls: "atmark-tag" }); 73 + } 74 + } 75 + 76 + if (bookmark.title) { 77 + el.createEl("div", { text: bookmark.title, cls: "atmark-item-title" }); 78 + } 79 + 80 + if (bookmark.description) { 81 + const desc = bookmark.description.length > 200 82 + ? bookmark.description.slice(0, 200) + "…" 83 + : bookmark.description; 84 + el.createEl("p", { text: desc, cls: "atmark-item-desc" }); 85 + } 86 + 87 + const link = el.createEl("a", { 88 + text: bookmark.source, 89 + href: bookmark.source, 90 + cls: "atmark-item-url", 91 + }); 92 + link.setAttr("target", "_blank"); 93 + } 94 + 95 + renderDetail(container: HTMLElement): void { 96 + const body = container.createEl("div", { cls: "atmark-detail-body" }); 97 + const bookmark = this.record.value; 98 + 99 + if (bookmark.title) { 100 + body.createEl("h2", { text: bookmark.title, cls: "atmark-detail-title" }); 101 + } 102 + 103 + if (bookmark.description) { 104 + body.createEl("p", { text: bookmark.description, cls: "atmark-detail-description" }); 105 + } 106 + 107 + const linkWrapper = body.createEl("div", { cls: "atmark-detail-link-wrapper" }); 108 + const link = linkWrapper.createEl("a", { 109 + text: bookmark.source, 110 + href: bookmark.source, 111 + cls: "atmark-detail-link", 112 + }); 113 + link.setAttr("target", "_blank"); 114 + 115 + // Collections section 116 + if (this.collections.length > 0) { 117 + const collectionsSection = container.createEl("div", { cls: "atmark-item-collections-section" }); 118 + collectionsSection.createEl("h3", { text: "Collections", cls: "atmark-detail-section-title" }); 119 + const collectionsContainer = collectionsSection.createEl("div", { cls: "atmark-item-collections" }); 120 + for (const collection of this.collections) { 121 + collectionsContainer.createEl("span", { text: collection.name, cls: "atmark-collection" }); 122 + } 123 + } 124 + 125 + // Tags section 126 + if (bookmark.tags && bookmark.tags.length > 0) { 127 + const tagsSection = container.createEl("div", { cls: "atmark-item-tags-section" }); 128 + tagsSection.createEl("h3", { text: "Tags", cls: "atmark-detail-section-title" }); 129 + const tagsContainer = tagsSection.createEl("div", { cls: "atmark-item-tags" }); 130 + for (const tag of bookmark.tags) { 131 + tagsContainer.createEl("span", { text: tag, cls: "atmark-tag" }); 132 + } 133 + } 134 + } 135 + 136 + getTags() { 137 + return this.record.value.tags || []; 138 + } 139 + 140 + getRecord() { 141 + return this.record; 142 + } 143 + } 144 + 145 + export class MarginSource implements DataSource { 146 + readonly name = "margin" as const; 147 + private client: Client; 148 + private repo: string; 149 + 150 + constructor(client: Client, repo: string) { 151 + this.client = client; 152 + this.repo = repo; 153 + } 154 + 155 + async fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]> { 156 + const bookmarksResp = await getMarginBookmarks(this.client, this.repo); 157 + if (!bookmarksResp.ok) return []; 158 + 159 + let bookmarks = bookmarksResp.data.records as MarginBookmarkRecord[]; 160 + 161 + // Build collections map (bookmark URI -> collection info) 162 + const collectionsMap = new Map<string, Array<{ uri: string; name: string }>>(); 163 + const collectionsResp = await getMarginCollections(this.client, this.repo); 164 + const itemsResp = await getMarginCollectionItems(this.client, this.repo); 165 + 166 + if (collectionsResp.ok && itemsResp.ok) { 167 + const collections = collectionsResp.data.records as MarginCollectionRecord[]; 168 + const collectionNameMap = new Map<string, string>(); 169 + for (const collection of collections) { 170 + collectionNameMap.set(collection.uri, collection.value.name); 171 + } 172 + 173 + const items = itemsResp.data.records as MarginCollectionItemRecord[]; 174 + for (const item of items) { 175 + const bookmarkUri = item.value.annotation; 176 + const collectionUri = item.value.collection; 177 + const collectionName = collectionNameMap.get(collectionUri); 178 + 179 + if (collectionName) { 180 + const existing = collectionsMap.get(bookmarkUri) || []; 181 + existing.push({ uri: collectionUri, name: collectionName }); 182 + collectionsMap.set(bookmarkUri, existing); 183 + } 184 + } 185 + } 186 + 187 + // Apply collection filter if specified 188 + const collectionFilter = filters.find(f => f.type === "marginCollection"); 189 + if (collectionFilter && collectionFilter.value) { 190 + if (itemsResp.ok) { 191 + const items = itemsResp.data.records as MarginCollectionItemRecord[]; 192 + const filteredItems = items.filter((item: MarginCollectionItemRecord) => 193 + item.value.collection === collectionFilter.value 194 + ); 195 + const bookmarkUris = new Set(filteredItems.map((item: MarginCollectionItemRecord) => item.value.annotation)); 196 + bookmarks = bookmarks.filter((bookmark: MarginBookmarkRecord) => bookmarkUris.has(bookmark.uri)); 197 + } 198 + } 199 + 200 + // Apply tag filter if specified 201 + const tagFilter = filters.find(f => f.type === "marginTag"); 202 + if (tagFilter && tagFilter.value) { 203 + bookmarks = bookmarks.filter((record: MarginBookmarkRecord) => 204 + record.value.tags?.includes(tagFilter.value) 205 + ); 206 + } 207 + 208 + return bookmarks.map((record: MarginBookmarkRecord) => 209 + new MarginItem(record, collectionsMap.get(record.uri) || [], plugin) 210 + ); 211 + } 212 + 213 + async getAvailableFilters(): Promise<SourceFilter[]> { 214 + const filters: SourceFilter[] = []; 215 + 216 + // Get collections 217 + const collectionsResp = await getMarginCollections(this.client, this.repo); 218 + if (collectionsResp.ok) { 219 + const collections = collectionsResp.data.records as MarginCollectionRecord[]; 220 + filters.push(...collections.map((c: MarginCollectionRecord) => ({ 221 + type: "marginCollection", 222 + value: c.uri, 223 + label: c.value.name, 224 + }))); 225 + } 226 + 227 + // Get tags 228 + const bookmarksResp = await getMarginBookmarks(this.client, this.repo); 229 + if (bookmarksResp.ok) { 230 + const tagSet = new Set<string>(); 231 + const records = bookmarksResp.data.records as MarginBookmarkRecord[]; 232 + for (const record of records) { 233 + if (record.value.tags) { 234 + for (const tag of record.value.tags) { 235 + tagSet.add(tag); 236 + } 237 + } 238 + } 239 + filters.push(...Array.from(tagSet).map(tag => ({ 240 + type: "marginTag", 241 + value: tag, 242 + label: tag, 243 + }))); 244 + } 245 + 246 + return filters; 247 + } 248 + 249 + renderFilterUI(container: HTMLElement, activeFilters: Map<string, SourceFilter>, onChange: () => void, plugin: ATmarkPlugin): void { 250 + // Collections section 251 + const collectionsSection = container.createEl("div", { cls: "atmark-filter-section" }); 252 + 253 + const collectionsTitleRow = collectionsSection.createEl("div", { cls: "atmark-filter-title-row" }); 254 + collectionsTitleRow.createEl("h3", { text: "Collections", cls: "atmark-filter-title" }); 255 + 256 + const createCollectionBtn = collectionsTitleRow.createEl("button", { cls: "atmark-filter-create-btn" }); 257 + setIcon(createCollectionBtn, "plus"); 258 + createCollectionBtn.addEventListener("click", () => { 259 + new CreateMarginCollectionModal(plugin, onChange).open(); 260 + }); 261 + 262 + const collectionsChips = collectionsSection.createEl("div", { cls: "atmark-filter-chips" }); 263 + 264 + // All collections chip 265 + const allCollectionsChip = collectionsChips.createEl("button", { 266 + text: "All", 267 + cls: `atmark-chip ${!activeFilters.has("marginCollection") ? "atmark-chip-active" : ""}`, 268 + }); 269 + allCollectionsChip.addEventListener("click", () => { 270 + activeFilters.delete("marginCollection"); 271 + onChange(); 272 + }); 273 + 274 + // Tags section 275 + const tagsSection = container.createEl("div", { cls: "atmark-filter-section" }); 276 + 277 + const tagsTitleRow = tagsSection.createEl("div", { cls: "atmark-filter-title-row" }); 278 + tagsTitleRow.createEl("h3", { text: "Tags", cls: "atmark-filter-title" }); 279 + 280 + const tagsChips = tagsSection.createEl("div", { cls: "atmark-filter-chips" }); 281 + 282 + // All tags chip 283 + const allTagsChip = tagsChips.createEl("button", { 284 + text: "All", 285 + cls: `atmark-chip ${!activeFilters.has("marginTag") ? "atmark-chip-active" : ""}`, 286 + }); 287 + allTagsChip.addEventListener("click", () => { 288 + activeFilters.delete("marginTag"); 289 + onChange(); 290 + }); 291 + 292 + // Get filters and render chips 293 + void this.getAvailableFilters().then(filters => { 294 + for (const filter of filters) { 295 + if (filter.type === "marginCollection") { 296 + const chip = collectionsChips.createEl("button", { 297 + text: filter.label, 298 + cls: `atmark-chip ${activeFilters.get("marginCollection")?.value === filter.value ? "atmark-chip-active" : ""}`, 299 + }); 300 + chip.addEventListener("click", () => { 301 + activeFilters.set("marginCollection", filter); 302 + onChange(); 303 + }); 304 + } else if (filter.type === "marginTag") { 305 + const chip = tagsChips.createEl("button", { 306 + text: filter.label, 307 + cls: `atmark-chip ${activeFilters.get("marginTag")?.value === filter.value ? "atmark-chip-active" : ""}`, 308 + }); 309 + chip.addEventListener("click", () => { 310 + activeFilters.set("marginTag", filter); 311 + onChange(); 312 + }); 313 + } 314 + } 315 + }); 316 + } 317 + }
+2 -2
src/sources/types.ts
··· 9 9 getUri(): string; 10 10 getCid(): string; 11 11 getCreatedAt(): string; 12 - getSource(): "semble" | "bookmark"; 12 + getSource(): "semble" | "bookmark" | "margin"; 13 13 getAttachedNotes?(): Array<{ uri: string; text: string }>; 14 14 } 15 15 ··· 20 20 } 21 21 22 22 export interface DataSource { 23 - readonly name: "semble" | "bookmark"; 23 + readonly name: "semble" | "bookmark" | "margin"; 24 24 fetchItems(filters: SourceFilter[], plugin: ATmarkPlugin): Promise<ATmarkItem[]>; 25 25 getAvailableFilters(): Promise<SourceFilter[]>; 26 26 renderFilterUI(container: HTMLElement, activeFilters: Map<string, SourceFilter>, onChange: () => void, plugin: ATmarkPlugin): void;
+7 -2
src/views/atmark.ts
··· 9 9 import type { ATmarkItem } from "../sources/types"; 10 10 import { SembleSource } from "../sources/semble"; 11 11 import { BookmarkSource } from "../sources/bookmark"; 12 + import { MarginSource } from "../sources/margin"; 12 13 13 14 export const VIEW_TYPE_ATMARK = "atmark-view"; 14 15 15 - type SourceType = "semble" | "bookmark"; 16 + type SourceType = "semble" | "bookmark" | "margin"; 16 17 17 18 export class ATmarkView extends ItemView { 18 19 plugin: ATmarkPlugin; ··· 32 33 }); 33 34 this.sources.set("bookmark", { 34 35 source: new BookmarkSource(this.plugin.client, repo), 36 + filters: new Map() 37 + }); 38 + this.sources.set("margin", { 39 + source: new MarginSource(this.plugin.client, repo), 35 40 filters: new Map() 36 41 }); 37 42 } ··· 112 117 113 118 // Source selector in the center 114 119 const sourceSelector = nav.createEl("div", { cls: "atmark-source-selector" }); 115 - const sources: SourceType[] = ["semble", "bookmark"]; 120 + const sources: SourceType[] = ["semble", "bookmark", "margin"]; 116 121 117 122 for (const source of sources) { 118 123 const label = sourceSelector.createEl("label", { cls: "atmark-source-option" });
+28
styles.css
··· 242 242 border: 1px solid rgba(3, 169, 244, 0.3); 243 243 } 244 244 245 + .atmark-badge-margin { 246 + background: rgba(156, 39, 176, 0.15); 247 + color: rgb(186, 104, 200); 248 + border: 1px solid rgba(156, 39, 176, 0.3); 249 + } 250 + 245 251 .atmark-item-footer { 246 252 display: flex; 247 253 justify-content: space-between; ··· 334 340 background: var(--background-modifier-border); 335 341 color: var(--text-muted); 336 342 border: 1px solid var(--background-modifier-border-hover); 343 + } 344 + 345 + .atmark-item-collections { 346 + display: flex; 347 + flex-wrap: wrap; 348 + gap: 6px; 349 + margin-bottom: 8px; 350 + } 351 + 352 + .atmark-collection { 353 + font-size: var(--font-smallest); 354 + padding: 2px 8px; 355 + border-radius: var(--radius-s); 356 + background: rgba(156, 39, 176, 0.1); 357 + color: rgb(186, 104, 200); 358 + border: 1px solid rgba(156, 39, 176, 0.3); 359 + } 360 + 361 + .atmark-item-collections-section { 362 + margin-top: 20px; 363 + padding-top: 20px; 364 + border-top: 1px solid var(--background-modifier-border); 337 365 } 338 366 339 367 .atmark-item-tags-section {