diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..991bdc6 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--no-usage-statistics", + "--browser-url=http://127.0.0.1:9222" + ], + "env": {} + } + } +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fbb21c4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release Obsidian plugin + +on: + push: + tags: + - "*" + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: "18.x" + + - name: Build plugin + run: | + npm install + npm run build + + - name: Create release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${GITHUB_REF#refs/tags/}" + + gh release create "$tag" \ + --title="$tag" \ + --draft \ + main.js manifest.json styles.css diff --git a/LICENSE b/LICENSE index 287f37a..80c0f95 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,17 @@ -Copyright (C) 2020-2025 by Dynalist Inc. + DO WHAT THE FUCK YOU WANT TO BUT IT'S NOT MY FAULT PUBLIC LICENSE + Version 1, October 2013 -Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. + Copyright (C) 2026 Lukas '@dotWee' Wolfsteiner -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO BUT IT'S NOT MY FAULT PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. + + 1. Do not hold the author(s), creator(s), developer(s) or + distributor(s) liable for anything that happens or goes wrong + with your use of the work. diff --git a/README.md b/README.md index 82bf6c6..e0d151a 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,143 @@ -# Obsidian Sample Plugin +# Raindrop.io Plugin for Obsidian -This is a sample plugin for Obsidian (https://obsidian.md). +Raindrop.io Plugin for Obsidian shows saved Raindrop.io bookmarks inside notes and in a note-aware explorer side pane. -This project uses TypeScript to provide type checking and documentation. -The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does. +The plugin uses a Raindrop.io access token and the official Raindrop.io REST API. OAuth is planned for a later release. -This sample plugin demonstrates some of the basic functionality the plugin API can do. -- Adds a ribbon icon, which shows a Notice when clicked. -- Adds a command "Open modal (simple)" which opens a Modal. -- Adds a plugin setting tab to the settings page. -- Registers a global click event and output 'click' to the console. -- Registers a global interval which logs 'setInterval' to the console. +## Features -## First time developing plugins? +- **Raindrop.io explorer**: Browse saved bookmarks from the configured collection in a side pane. +- **Note-aware filtering**: Let the explorer follow the active note by turning note tags and external links into a Raindrop.io search query. +- **Manual search**: Search Raindrop.io directly with keywords, `#tag` filters, exact phrases, or operators such as `type:article`, `notag:true`, and `created:2024-01`. +- **Note blocks**: Render saved Raindrop.io links inline from fenced `raindrop` code blocks. +- **Configurable tag clicks**: Choose whether tags on rendered Raindrop.io items search Obsidian notes, filter the explorer, or do nothing. +- **Native Obsidian UI**: Uses Obsidian commands, a ribbon icon, theme-friendly styling, and no hidden telemetry. -Quick starting guide for new plugin devs: +## Setup -- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. -- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). -- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. -- Install NodeJS, then run `npm i` in the command line under your repo folder. -- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. -- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. -- Reload Obsidian to load the new version of your plugin. -- Enable plugin in settings window. -- For updates to the Obsidian API run `npm update` in the command line under your repo folder. +1. Create or copy a Raindrop.io access token. +2. Open **Settings -> Community plugins -> Raindrop.io Plugin for Obsidian**. +3. Paste the token into **Access token**. +4. Use **Open explorer** from the command palette or ribbon icon. +5. Optionally add a `raindrop` block to a note for inline results. -## Releasing new releases +The access token is stored in Obsidian plugin data and sent only to the Raindrop.io REST API. -- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. -- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. -- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases -- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. -- Publish the release. +## Explorer -> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. -> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` +The side pane opens as **Raindrop.io explorer**. It can browse all saved links from the configured collection, run manual searches, and load more paginated results. -## Adding your plugin to the community plugin list +Controls: -- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines). -- Publish an initial version. -- Make sure you have a `README.md` file in the root of your repo. -- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. +- **Search**: Runs the query in the search field against Raindrop.io. +- **Use note filter**: Rebuilds the query from the active markdown note. +- **Browse all**: Clears the query and shows the configured collection. +- **Refresh**: Reloads the current explorer state. +- **Load more**: Requests the next page of results when more are available. -## How to use +When the explorer follows a note, it uses: -- Clone this repo. -- Make sure your NodeJS is at least v16 (`node --version`). -- `npm i` or `yarn` to install dependencies. -- `npm run dev` to start compilation in watch mode. +- Obsidian tags from the active note as Raindrop.io tag filters, for example `#project`. +- External `http` and `https` links as exact phrase searches. +- `match:OR` when multiple note-derived filters are present, so a bookmark can match any note tag or link. -## Manually installing the plugin +Opening and interacting with the explorer preserves the last active markdown note as context, so the note filter remains stable while you browse. -- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. +## Note blocks -## Improve code quality with eslint -- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. -- This project already has eslint preconfigured, you can invoke a check by running`npm run lint` -- Together with a custom eslint [plugin](https://github.com/eslint-plugin) for Obsidan specific code guidelines. -- A GitHub action is preconfigured to automatically lint every commit on all branches. +Add a fenced code block to any note: -## Funding URL +````markdown +```raindrop +collection: 0 +tag: project-x +search: important +sort: -created +limit: 20 +``` +```` -You can include funding URLs where people who use your plugin can financially support it. +Options: -The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: +- `collection`: Raindrop.io collection ID. Use `0` for all collections. Defaults to **Default collection**. +- `tag`: Raindrop.io tag to include in the search. Multi-word tags are quoted automatically, for example `#"coffee beans"`. +- `search`: Raindrop.io search query. This is passed through to Raindrop.io. +- `sort`: Raindrop.io sort value, for example `-created`. Defaults to **Default sort**. +- `limit`: Number of links to request, clamped between 1 and 100. Defaults to **Default limit**. -```json -{ - "fundingUrl": "https://buymeacoffee.com" -} +You can combine `tag` and `search`; the plugin joins them into one Raindrop.io query. + +## Settings + +- **Access token**: Token used for Raindrop.io API requests. +- **Default collection**: Collection ID used by the explorer and note blocks unless a block overrides it. Use `0` for all collections. +- **Default limit**: Number of links requested per page or block render. Values are clamped between 1 and 100. +- **Default sort**: Sort value passed to Raindrop.io, such as `-created`. +- **Tag click behavior**: Controls clicks on tags shown on Raindrop.io items. + +Tag click behavior options: + +- **Search notes for the tag**: Opens Obsidian search for the clicked tag. +- **Filter explorer by the tag**: Opens the explorer and applies the clicked tag as a Raindrop.io filter. +- **Do nothing**: Leaves tag clicks inactive. + +## Commands + +- **Open explorer**: Opens the Raindrop.io explorer side pane. +- **Refresh explorer**: Reloads open explorer panes. + +## Search syntax + +The plugin passes search text through to Raindrop.io. Useful examples: + +- `#project-x`: Bookmarks tagged `project-x`. +- `#"coffee beans"`: Bookmarks with a multi-word tag. +- `"exact phrase"`: Exact phrase match. +- `type:article`: Article bookmarks. +- `notag:true`: Bookmarks without tags. +- `created:2024-01`: Bookmarks created in January 2024. +- `#work #research match:OR`: Bookmarks matching either tag. + +## Privacy and network access + +- The plugin only makes network requests to `https://api.raindrop.io`. +- The access token is stored locally in Obsidian plugin data. +- Note-aware filtering sends the generated Raindrop.io search query to Raindrop.io. If the active note contains external links, those URLs can be included in the query. +- The plugin does not collect analytics or use hidden telemetry. + +## Release files + +Obsidian release assets are built at the repository root: + +- `main.js` +- `manifest.json` +- `styles.css` + +Do not commit generated `main.js`; attach it to GitHub releases together with `manifest.json` and `styles.css`. + +## Development + +Install dependencies and build the plugin: + +```bash +npm install +npm run build ``` -If you have multiple URLs, you can also do: +For watch mode: -```json -{ - "fundingUrl": { - "Buy Me a Coffee": "https://buymeacoffee.com", - "GitHub Sponsor": "https://github.com/sponsors", - "Patreon": "https://www.patreon.com/" - } -} +```bash +npm run dev ``` -## API Documentation +Quality checks: -See https://docs.obsidian.md +```bash +npm run lint +``` + +## License + +Copyright (C) 2026 Lukas '@dotWee' Wolfsteiner + +Licensed under the _[DO WHAT THE FUCK YOU WANT TO BUT IT'S NOT MY FAULT PUBLIC LICENSE](LICENSE)_. diff --git a/manifest.json b/manifest.json index dfa940e..e1539ad 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,9 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", + "id": "raindrop-io-plugin", + "name": "Raindrop.io Plugin for Obsidian", "version": "1.0.0", "minAppVersion": "0.15.0", - "description": "Demonstrates some of the capabilities of the Obsidian API.", - "author": "Obsidian", - "authorUrl": "https://obsidian.md", - "fundingUrl": "https://obsidian.md/pricing", + "description": "Browse Raindrop.io links in Obsidian with note-aware search filters.", + "author": "Lukas '@dotWee' Wolfsteiner ", "isDesktopOnly": false } diff --git a/package-lock.json b/package-lock.json index d0dac39..d2b2220 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { - "name": "obsidian-sample-plugin", + "name": "obsidian-raindropio-plugin", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "obsidian-sample-plugin", + "name": "obsidian-raindropio-plugin", "version": "1.0.0", - "license": "0-BSD", + "license": "WTFPL", "dependencies": { "obsidian": "latest" }, diff --git a/package.json b/package.json index 17268d7..1cb51f6 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,31 @@ { - "name": "obsidian-sample-plugin", + "name": "obsidian-raindropio-plugin", "version": "1.0.0", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "description": "Browse Raindrop.io links in Obsidian with note-aware search filters.", "main": "main.js", "type": "module", + "author": "Lukas '@dotWee' Wolfsteiner (http://lukas.wolfsteiner.media/)", + "homepage": "https://github.com/dotWee/obsidian-raindropio-plugin", + "repository": { + "type": "git", + "url": "git+https://github.com/dotWee/obsidian-raindropio-plugin.git" + }, + "bugs": { + "url": "https://github.com/dotWee/obsidian-raindropio-plugin/issues" + }, + "license": "WTFPL", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "version": "node version-bump.mjs && git add manifest.json versions.json", "lint": "eslint ." }, - "keywords": [], - "license": "0-BSD", + "keywords": [ + "obsidian", + "obsidian-plugin", + "raindrop.io", + "bookmarks" + ], "devDependencies": { "@types/node": "^16.11.6", "esbuild": "0.25.5", diff --git a/src/block-parser.ts b/src/block-parser.ts new file mode 100644 index 0000000..e4b1c2b --- /dev/null +++ b/src/block-parser.ts @@ -0,0 +1,71 @@ +export interface RaindropBlockOptions { + collectionId?: number; + search?: string; + tag?: string; + sort?: string; + limit?: number; +} + +export interface ParsedRaindropBlock { + options: RaindropBlockOptions; + warnings: string[]; +} + +function parseKeyValueLines(source: string): Record { + const out: Record = {}; + for (const rawLine of source.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const idx = line.indexOf(":"); + if (idx === -1) continue; + + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) out[key] = value; + } + return out; +} + +function clampLimit(limit: number, { min, max }: { min: number; max: number }): number { + if (!Number.isFinite(limit)) return min; + return Math.max(min, Math.min(max, Math.floor(limit))); +} + +export function parseRaindropBlock(source: string): ParsedRaindropBlock { + const warnings: string[] = []; + const kv = parseKeyValueLines(source); + + const collectionRaw = kv.collection ?? kv.collectionId; + const collectionId = collectionRaw === undefined ? undefined : Number(collectionRaw); + if (collectionId !== undefined && (!Number.isFinite(collectionId) || collectionId < 0)) { + warnings.push("Invalid `collection`; using the default collection."); + } + + const limitRaw = kv.limit; + const limit = limitRaw ? clampLimit(Number(limitRaw), { min: 1, max: 100 }) : undefined; + if (limitRaw && !Number.isFinite(Number(limitRaw))) { + warnings.push("Invalid `limit`; using the plugin default."); + } + + const tag = kv.tag?.trim() || undefined; + const search = kv.search?.trim() || undefined; + const sort = kv.sort?.trim() || undefined; + + return { + options: { + collectionId: collectionId !== undefined && Number.isFinite(collectionId) && collectionId >= 0 ? collectionId : undefined, + tag, + search, + sort, + limit, + }, + warnings, + }; +} + +export function extractFirstRaindropBlock(source: string): string | null { + const match = source.match(/```raindrop\s*\n([\s\S]*?)```/i); + const block = match?.[1]; + return typeof block === "string" ? block : null; +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..8a814f7 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,2 @@ +export const RAINDROP_CODE_BLOCK = "raindrop"; +export const RAINDROP_VIEW_TYPE = "raindrop-view"; diff --git a/src/icons.ts b/src/icons.ts new file mode 100644 index 0000000..0d0678a --- /dev/null +++ b/src/icons.ts @@ -0,0 +1,20 @@ +import { addIcon, removeIcon } from "obsidian"; + +export const RAINDROP_BRAND_ICON_ID = "raindropio-brand"; +export const RAINDROP_FAVICON_ICON_ID = "raindropio-favicon"; + +const RAINDROP_BRAND_ICON_SVG = + ''; + +const RAINDROP_FAVICON_ICON_SVG = + ''; + +export function registerRaindropIcons(): void { + addIcon(RAINDROP_BRAND_ICON_ID, RAINDROP_BRAND_ICON_SVG); + addIcon(RAINDROP_FAVICON_ICON_ID, RAINDROP_FAVICON_ICON_SVG); +} + +export function unregisterRaindropIcons(): void { + removeIcon(RAINDROP_BRAND_ICON_ID); + removeIcon(RAINDROP_FAVICON_ICON_ID); +} diff --git a/src/main.ts b/src/main.ts index 6fe0c83..0db1702 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,99 +1,231 @@ -import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian'; -import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings"; +import { MarkdownView, Notice, Plugin, TFile } from "obsidian"; +import { parseRaindropBlock } from "./block-parser"; +import { RAINDROP_CODE_BLOCK, RAINDROP_VIEW_TYPE } from "./constants"; +import { + RAINDROP_FAVICON_ICON_ID, + registerRaindropIcons, + unregisterRaindropIcons, +} from "./icons"; +import { RaindropApi } from "./raindrop-api"; +import { buildRaindropSearchQuery, formatRaindropTagFilter } from "./raindrop-search"; +import { RaindropSideView } from "./raindrop-view"; +import { renderRaindropItems, renderRaindropStatus } from "./renderer"; +import { DEFAULT_SETTINGS, isRaindropTagClickBehavior, RaindropSettingTab, RaindropViewSettings } from "./settings"; -// Remember to rename these classes and interfaces! +interface GlobalSearchPluginInstance { + openGlobalSearch?: (query?: string) => void; + setQuery?: (query: string) => void; + setGlobalQuery?: (query: string) => void; +} -export default class MyPlugin extends Plugin { - settings: MyPluginSettings; +export default class RaindropViewPlugin extends Plugin { + settings: RaindropViewSettings; + activeMarkdownFile: TFile | null = null; + private refreshTimeoutId: number | null = null; + private readonly refreshDebounceMs = 200; - async onload() { + async onload(): Promise { await this.loadSettings(); + this.captureActiveMarkdownFile(); + registerRaindropIcons(); - // This creates an icon in the left ribbon. - this.addRibbonIcon('dice', 'Sample', (evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); + this.registerView(RAINDROP_VIEW_TYPE, (leaf) => new RaindropSideView(leaf, this)); + + this.registerMarkdownCodeBlockProcessor(RAINDROP_CODE_BLOCK, async (source, el, ctx) => { + await this.renderRaindropSource(source, el, undefined, ctx.sourcePath); }); - // This adds a status bar item to the bottom of the app. Does not work on mobile apps. - const statusBarItemEl = this.addStatusBarItem(); - statusBarItemEl.setText('Status bar text'); + this.addRibbonIcon(RAINDROP_FAVICON_ICON_ID, "Open explorer", () => { + void this.openRaindropView(); + }); - // This adds a simple command that can be triggered anywhere this.addCommand({ - id: 'open-modal-simple', - name: 'Open modal (simple)', + id: "open-raindrop-view", + name: "Open explorer", callback: () => { - new SampleModal(this.app).open(); - } + void this.openRaindropView(); + }, }); - // This adds an editor command that can perform some operation on the current editor instance - this.addCommand({ - id: 'replace-selected', - name: 'Replace selected content', - editorCallback: (editor: Editor, view: MarkdownView) => { - editor.replaceSelection('Sample editor command'); - } - }); - // This adds a complex command that can check whether the current state of the app allows execution of the command - this.addCommand({ - id: 'open-modal-complex', - name: 'Open modal (complex)', - checkCallback: (checking: boolean) => { - // Conditions to check - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - // If checking is true, we're simply "checking" if the command can be run. - // If checking is false, then we want to actually perform the operation. - if (!checking) { - new SampleModal(this.app).open(); - } - // This command will only show up in Command Palette when the check function returns true - return true; + this.addCommand({ + id: "refresh-raindrop-view", + name: "Refresh explorer", + callback: () => { + void this.refreshRaindropViews(); + }, + }); + + this.addSettingTab(new RaindropSettingTab(this.app, this)); + + this.registerEvent( + this.app.workspace.on("active-leaf-change", () => { + const didChange = this.captureActiveMarkdownFile(); + if (didChange) { + this.scheduleRefreshRaindropViews(); } - return false; - } - }); - - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new SampleSettingTab(this.app, this)); - - // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) - // Using this function will automatically remove the event listener when this plugin is disabled. - this.registerDomEvent(document, 'click', (evt: MouseEvent) => { - new Notice("Click"); - }); - - // When registering intervals, this function will automatically clear the interval when the plugin is disabled. - this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); + }), + ); + this.registerEvent( + this.app.vault.on("modify", (file) => { + if (file === this.activeMarkdownFile) { + this.scheduleRefreshRaindropViews(); + } + }), + ); } - onunload() { + onunload(): void { + if (this.refreshTimeoutId !== null) { + window.clearTimeout(this.refreshTimeoutId); + this.refreshTimeoutId = null; + } + unregisterRaindropIcons(); } - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, (await this.loadData()) as Partial); + this.settings.defaultLimit = Math.max(1, Math.min(100, Math.floor(this.settings.defaultLimit))); + if (!isRaindropTagClickBehavior(this.settings.tagClickBehavior)) { + this.settings.tagClickBehavior = DEFAULT_SETTINGS.tagClickBehavior; + } } - async saveSettings() { + async saveSettings(): Promise { await this.saveData(this.settings); } -} -class SampleModal extends Modal { - constructor(app: App) { - super(app); + async openRaindropView(searchQuery?: string, context?: string): Promise { + this.captureActiveMarkdownFile(); + + const existingLeaf = this.app.workspace.getLeavesOfType(RAINDROP_VIEW_TYPE)[0]; + const leaf = existingLeaf ?? this.app.workspace.getRightLeaf(false); + if (!leaf) { + new Notice("Could not open explorer."); + return; + } + + await leaf.setViewState({ type: RAINDROP_VIEW_TYPE, active: true }); + await this.app.workspace.revealLeaf(leaf); + if (searchQuery !== undefined && leaf.view instanceof RaindropSideView) { + await leaf.view.applySearchQuery(searchQuery, context); + return; + } + + await this.refreshRaindropViews(true); } - onOpen() { - let {contentEl} = this; - contentEl.setText('Woah!'); + async refreshRaindropViews(force = false): Promise { + const refreshes = this.app.workspace + .getLeavesOfType(RAINDROP_VIEW_TYPE) + .map((leaf) => leaf.view) + .filter((view): view is RaindropSideView => view instanceof RaindropSideView) + .map((view) => view.refresh(force)); + + await Promise.all(refreshes); } - onClose() { - const {contentEl} = this; - contentEl.empty(); + async renderRaindropSource(source: string, container: HTMLElement, title?: string, sourcePath?: string): Promise { + renderRaindropStatus(container, "Loading Raindrop.io links...", "loading"); + + try { + const parsed = parseRaindropBlock(source); + const api = new RaindropApi(this.settings.accessToken); + if (!api.isConfigured) { + renderRaindropStatus(container, "Add a Raindrop.io access token in plugin settings.", "info"); + return; + } + + const limit = parsed.options.limit ?? this.settings.defaultLimit; + const items = await api.listRaindrops({ + collectionId: parsed.options.collectionId ?? this.settings.defaultCollectionId, + search: buildRaindropSearchQuery({ + search: parsed.options.search, + tags: parsed.options.tag ? [parsed.options.tag] : [], + }), + sort: (parsed.options.sort ?? this.settings.defaultSort) || undefined, + perpage: limit, + }); + + renderRaindropItems(container, items, { + title, + warnings: parsed.warnings, + onTagClick: (tag) => { + void this.handleRaindropTagClick(tag, sourcePath); + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Could not load Raindrop.io links."; + renderRaindropStatus(container, message, "error"); + } + } + + private captureActiveMarkdownFile(): boolean { + const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); + const nextFile = markdownView?.file; + if (!nextFile) return false; + + const currentPath = this.activeMarkdownFile?.path ?? ""; + const nextPath = nextFile.path; + this.activeMarkdownFile = nextFile; + return currentPath !== nextPath; + } + + private scheduleRefreshRaindropViews(): void { + if (this.refreshTimeoutId !== null) { + window.clearTimeout(this.refreshTimeoutId); + } + + this.refreshTimeoutId = window.setTimeout(() => { + this.refreshTimeoutId = null; + void this.refreshRaindropViews(); + }, this.refreshDebounceMs); + } + + async handleRaindropTagClick(tag: string, sourcePath?: string): Promise { + switch (this.settings.tagClickBehavior) { + case "obsidian-search": + await this.openObsidianTag(tag, sourcePath); + return; + case "raindrop-search": { + const tagFilter = formatRaindropTagFilter(tag); + if (!tagFilter) return; + await this.openRaindropView(tagFilter, `Filtering Raindrop.io by ${tagFilter}.`); + return; + } + case "none": + return; + } + } + + async openObsidianTag(tag: string, sourcePath?: string): Promise { + const normalized = tag.replace(/^#/, "").trim(); + if (!normalized) return; + + const searchQuery = `tag:#${normalized}`; + const appWithInternals = this.app as typeof this.app & { + internalPlugins?: { + plugins?: Record; + }; + }; + const globalSearch = appWithInternals.internalPlugins?.plugins?.["global-search"]?.instance as + | GlobalSearchPluginInstance + | undefined; + + // Prefer native global search behavior for tag clicks from any view. + if (globalSearch?.openGlobalSearch) { + globalSearch.openGlobalSearch(searchQuery); + return; + } + if (globalSearch?.setGlobalQuery) { + globalSearch.setGlobalQuery(searchQuery); + return; + } + if (globalSearch?.setQuery) { + globalSearch.setQuery(searchQuery); + return; + } + + await this.app.workspace.openLinkText(`#${normalized}`, sourcePath ?? this.activeMarkdownFile?.path ?? "", false); } } diff --git a/src/note-parser.ts b/src/note-parser.ts new file mode 100644 index 0000000..647d65b --- /dev/null +++ b/src/note-parser.ts @@ -0,0 +1,43 @@ +import { CachedMetadata, getAllTags } from "obsidian"; + +export interface NoteRaindropReferences { + tags: string[]; + urls: string[]; +} + +function unique(values: string[]): string[] { + return Array.from(new Set(values)); +} + +function normalizeUrl(value: string): string { + return value.replace(/[),.;:!?]+$/g, ""); +} + +export function getNoteRaindropReferences(source: string, metadata: CachedMetadata | null): NoteRaindropReferences { + const markdownLinkUrls = Array.from(source.matchAll(/\[[^\]]*]\((https?:\/\/[^\s)]+)(?:\s+["'][^"']*["'])?\)/g)).map( + (match) => normalizeUrl(match[1] ?? ""), + ); + + const bareUrls = Array.from(source.matchAll(/\bhttps?:\/\/[^\s<>)]+/g)).map((match) => normalizeUrl(match[0])); + + const tags = unique( + (metadata ? getAllTags(metadata) ?? [] : []) + .map((tag) => tag.replace(/^#/, "").trim()) + .filter(Boolean), + ); + + return { + tags, + urls: unique([...markdownLinkUrls, ...bareUrls].filter(Boolean)), + }; +} + +export function getUrlKey(value: string): string { + try { + const url = new URL(value); + url.hash = ""; + return url.toString().replace(/\/$/, "").toLowerCase(); + } catch { + return value.replace(/\/$/, "").toLowerCase(); + } +} diff --git a/src/note-search.ts b/src/note-search.ts new file mode 100644 index 0000000..70d0845 --- /dev/null +++ b/src/note-search.ts @@ -0,0 +1,27 @@ +import type { CachedMetadata } from "obsidian"; +import { getNoteRaindropReferences } from "./note-parser"; +import { formatRaindropTagFilter } from "./raindrop-search"; + +export interface NoteRaindropSearch { + query: string; + urlCount: number; + tagCount: number; +} + +function formatExactSearchTerm(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function buildNoteRaindropSearch(source: string, metadata: CachedMetadata | null): NoteRaindropSearch { + const references = getNoteRaindropReferences(source, metadata); + const filters = [ + ...references.tags.map(formatRaindropTagFilter).filter((filter): filter is string => Boolean(filter)), + ...references.urls.map(formatExactSearchTerm), + ]; + + return { + query: filters.length > 1 ? `${filters.join(" ")} match:OR` : (filters[0] ?? ""), + urlCount: references.urls.length, + tagCount: references.tags.length, + }; +} diff --git a/src/raindrop-api.ts b/src/raindrop-api.ts new file mode 100644 index 0000000..b72f4a5 --- /dev/null +++ b/src/raindrop-api.ts @@ -0,0 +1,91 @@ +import { requestUrl } from "obsidian"; + +export interface RaindropItem { + _id: number; + link: string; + title: string; + excerpt?: string; + domain?: string; + created?: string; + cover?: string; + tags?: string[]; +} + +interface RaindropListResponse { + items?: RaindropItem[]; + result?: boolean; + count?: number; +} + +export interface RaindropListParams { + collectionId: number; + search?: string; + sort?: string; + page?: number; + perpage?: number; +} + +export class RaindropApi { + private readonly baseUrl = "https://api.raindrop.io/rest/v1"; + private readonly token: string; + + constructor(accessToken: string) { + this.token = accessToken.trim(); + } + + get isConfigured(): boolean { + return this.token.length > 0; + } + + async listRaindrops(params: RaindropListParams): Promise { + if (!this.isConfigured) { + throw new Error("Missing Raindrop.io access token. Add one in plugin settings."); + } + + const collectionId = Number.isFinite(params.collectionId) ? params.collectionId : 0; + const url = new URL(`${this.baseUrl}/raindrops/${collectionId}`); + if (params.search) url.searchParams.set("search", params.search); + if (params.sort) url.searchParams.set("sort", params.sort); + if (typeof params.page === "number") url.searchParams.set("page", String(params.page)); + if (typeof params.perpage === "number") url.searchParams.set("perpage", String(params.perpage)); + + const res = await requestUrl({ + url: url.toString(), + method: "GET", + headers: { + Authorization: `Bearer ${this.token}`, + }, + }); + + if (res.status < 200 || res.status >= 300) { + throw new Error(`Raindrop.io request failed with status ${res.status}.`); + } + + const data = res.json as RaindropListResponse; + if (!data.result || !Array.isArray(data.items)) { + throw new Error("Unexpected Raindrop.io API response."); + } + + return data.items; + } + + async listAllRaindrops(params: RaindropListParams, maxItems = 500): Promise { + const items: RaindropItem[] = []; + const perpage = Math.max(1, Math.min(50, params.perpage ?? 50)); + let page = params.page ?? 0; + + while (items.length < maxItems) { + const batch = await this.listRaindrops({ + ...params, + page, + perpage, + }); + + items.push(...batch); + if (batch.length < perpage) break; + page += 1; + } + + return items.slice(0, maxItems); + } +} diff --git a/src/raindrop-search.ts b/src/raindrop-search.ts new file mode 100644 index 0000000..9f6c4b2 --- /dev/null +++ b/src/raindrop-search.ts @@ -0,0 +1,43 @@ +const SIMPLE_TAG_PATTERN = /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/; + +function escapeQuotedValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +export function formatRaindropTagFilter(tag: string): string | null { + const normalized = tag.trim().replace(/^#/, ""); + if (!normalized) return null; + + if (SIMPLE_TAG_PATTERN.test(normalized)) { + return `#${normalized}`; + } + + return `#"${escapeQuotedValue(normalized)}"`; +} + +export function buildRaindropTagSearch(tags: string[], matchAny: boolean): string | undefined { + const filters = tags.map(formatRaindropTagFilter).filter((filter): filter is string => Boolean(filter)); + if (filters.length === 0) return undefined; + + if (matchAny && filters.length > 1) { + return `${filters.join(" ")} match:OR`; + } + + return filters.join(" "); +} + +export function buildRaindropSearchQuery({ + search, + tags, + matchAnyTags = false, +}: { + search?: string; + tags?: string[]; + matchAnyTags?: boolean; +}): string | undefined { + const parts = [buildRaindropTagSearch(tags ?? [], matchAnyTags), search?.trim()].filter( + (part): part is string => Boolean(part), + ); + + return parts.length > 0 ? parts.join(" ") : undefined; +} diff --git a/src/raindrop-view.ts b/src/raindrop-view.ts new file mode 100644 index 0000000..dd1261d --- /dev/null +++ b/src/raindrop-view.ts @@ -0,0 +1,253 @@ +import { ItemView, MarkdownView, Notice, TFile, WorkspaceLeaf } from "obsidian"; +import { RAINDROP_VIEW_TYPE } from "./constants"; +import { RAINDROP_BRAND_ICON_ID } from "./icons"; +import { buildNoteRaindropSearch } from "./note-search"; +import { RaindropApi, type RaindropItem } from "./raindrop-api"; +import { renderRaindropItems, renderRaindropStatus } from "./renderer"; +import type RaindropViewPlugin from "./main"; + +export class RaindropSideView extends ItemView { + private readonly plugin: RaindropViewPlugin; + private resultsEl: HTMLElement | null = null; + private searchInputEl: HTMLInputElement | null = null; + private contextEl: HTMLElement | null = null; + private loadMoreButtonEl: HTMLButtonElement | null = null; + private inFlightRefresh: Promise | null = null; + private needsRefresh = false; + private pendingForceRefresh = false; + private lastFilePath: string | null = null; + private lastFileMtime: number | null = null; + private lastFileSize: number | null = null; + private isFollowingNote = true; + private query = ""; + private page = 0; + private items: RaindropItem[] = []; + + constructor(leaf: WorkspaceLeaf, plugin: RaindropViewPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return RAINDROP_VIEW_TYPE; + } + + getDisplayText(): string { + return "Raindrop.io explorer"; + } + + getIcon(): string { + return RAINDROP_BRAND_ICON_ID; + } + + async onOpen(): Promise { + const root = this.containerEl.children[1]; + if (!(root instanceof HTMLElement)) return; + + root.empty(); + root.addClass("raindrop-side-view"); + + const header = root.createDiv({ cls: "raindrop-side-header" }); + header.createEl("h3", { text: "Raindrop.io explorer" }); + const refreshButton = header.createEl("button", { text: "Refresh" }); + refreshButton.addEventListener("click", () => { + void this.refresh(true); + }); + + const controls = root.createDiv({ cls: "raindrop-explorer-controls" }); + const searchForm = controls.createEl("form", { cls: "raindrop-explorer-search" }); + this.searchInputEl = searchForm.createEl("input", { + attr: { + placeholder: "Search, #tag, type:article...", + type: "search", + }, + }); + searchForm.createEl("button", { text: "Search", attr: { type: "submit" } }); + searchForm.addEventListener("submit", (event) => { + event.preventDefault(); + this.isFollowingNote = false; + this.setQuery(this.searchInputEl?.value.trim() ?? ""); + void this.loadResults(true); + }); + + const actions = controls.createDiv({ cls: "raindrop-explorer-actions" }); + const noteFilterButton = actions.createEl("button", { text: "Use note filter" }); + noteFilterButton.addEventListener("click", () => { + this.isFollowingNote = true; + void this.refresh(true); + }); + const clearButton = actions.createEl("button", { text: "Browse all" }); + clearButton.addEventListener("click", () => { + this.isFollowingNote = false; + this.setQuery(""); + this.updateContext("Browsing the configured collection."); + void this.loadResults(true); + }); + + this.contextEl = root.createDiv({ cls: "raindrop-explorer-context" }); + this.resultsEl = root.createDiv({ cls: "raindrop-side-content" }); + this.loadMoreButtonEl = root.createEl("button", { + cls: "raindrop-load-more", + text: "Load more", + }); + this.loadMoreButtonEl.addEventListener("click", () => { + void this.loadResults(false); + }); + + await this.refresh(true); + } + + async refresh(force = false): Promise { + if (this.inFlightRefresh) { + this.needsRefresh = true; + this.pendingForceRefresh = this.pendingForceRefresh || force; + return this.inFlightRefresh; + } + + this.inFlightRefresh = this.runRefresh(force); + try { + await this.inFlightRefresh; + } finally { + this.inFlightRefresh = null; + if (this.needsRefresh) { + const nextForce = this.pendingForceRefresh; + this.needsRefresh = false; + this.pendingForceRefresh = false; + void this.refresh(nextForce); + } + } + } + + async applySearchQuery(query: string, context?: string): Promise { + this.isFollowingNote = false; + this.setQuery(query); + this.updateContext(context ?? (this.query ? "Filtering the configured collection." : "Browsing the configured collection.")); + await this.loadResults(true); + } + + private async runRefresh(force = false): Promise { + if (!this.resultsEl) return; + + const file = this.getSourceFile(); + if (this.isFollowingNote) { + const noteQueryChanged = await this.updateQueryFromNote(file, force); + if (!noteQueryChanged && !force) return; + } + + await this.loadResults(true); + } + + private async updateQueryFromNote(file: TFile | null, force: boolean): Promise { + if (!file) { + const changed = this.lastFilePath !== null || this.query !== ""; + this.lastFilePath = null; + this.lastFileMtime = null; + this.lastFileSize = null; + this.setQuery(""); + this.updateContext("Browsing the configured collection. Open a markdown note to use it as a filter."); + return force || changed; + } + + const unchanged = + !force && + this.lastFilePath === file.path && + this.lastFileMtime === file.stat.mtime && + this.lastFileSize === file.stat.size; + if (unchanged) return false; + + try { + const content = await this.app.vault.cachedRead(file); + const noteSearch = buildNoteRaindropSearch(content, this.app.metadataCache.getFileCache(file)); + this.setQuery(noteSearch.query); + this.lastFilePath = file.path; + this.lastFileMtime = file.stat.mtime; + this.lastFileSize = file.stat.size; + + if (this.query) { + this.updateContext( + `Filtering from ${file.basename}: ${noteSearch.tagCount} tags, ${noteSearch.urlCount} links.`, + ); + } else { + this.updateContext(`No tags or external links found in ${file.basename}. Browsing the configured collection.`); + } + + return true; + } catch (error) { + const message = error instanceof Error ? error.message : "Could not read this note."; + this.updateContext("Could not build a note filter."); + if (this.resultsEl) renderRaindropStatus(this.resultsEl, message, "error"); + new Notice(message); + return false; + } + } + + private async loadResults(reset: boolean): Promise { + if (!this.resultsEl) return; + + const api = new RaindropApi(this.plugin.settings.accessToken); + if (!api.isConfigured) { + renderRaindropStatus(this.resultsEl, "Add a Raindrop.io access token in plugin settings.", "info"); + this.updateLoadMore(false); + return; + } + + if (reset) { + this.resetResults(); + } else { + this.page += 1; + } + + const perpage = Math.max(1, Math.min(50, this.plugin.settings.defaultLimit)); + renderRaindropStatus(this.resultsEl, reset ? "Loading Raindrop.io links..." : "Loading more Raindrop.io links...", "loading"); + + try { + const batch = await api.listRaindrops({ + collectionId: this.plugin.settings.defaultCollectionId, + search: this.query || undefined, + sort: this.plugin.settings.defaultSort || undefined, + page: this.page, + perpage, + }); + + this.items = reset ? batch : [...this.items, ...batch]; + renderRaindropItems(this.resultsEl, this.items, { + title: this.query ? "Filtered Raindrop.io links" : "All Raindrop.io links", + onTagClick: (tag) => { + void this.plugin.handleRaindropTagClick(tag, this.getSourceFile()?.path); + }, + }); + this.updateLoadMore(batch.length >= perpage); + } catch (error) { + if (!reset) this.page = Math.max(0, this.page - 1); + const message = error instanceof Error ? error.message : "Could not load Raindrop.io links."; + renderRaindropStatus(this.resultsEl, message, "error"); + this.updateLoadMore(false); + } + } + + private setQuery(query: string): void { + this.query = query.trim(); + if (this.searchInputEl) this.searchInputEl.value = this.query; + } + + private resetResults(): void { + this.page = 0; + this.items = []; + } + + private updateContext(message: string): void { + if (this.contextEl) this.contextEl.setText(message); + } + + private updateLoadMore(show: boolean): void { + if (!this.loadMoreButtonEl) return; + this.loadMoreButtonEl.toggle(show); + } + + private getSourceFile(): TFile | null { + const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (markdownView?.file) return markdownView.file; + + return this.plugin.activeMarkdownFile; + } +} diff --git a/src/renderer.ts b/src/renderer.ts new file mode 100644 index 0000000..b3eed7e --- /dev/null +++ b/src/renderer.ts @@ -0,0 +1,92 @@ +import type { RaindropItem } from "./raindrop-api"; + +export type RaindropStatusKind = "loading" | "empty" | "error" | "info"; + +export interface RenderRaindropOptions { + title?: string; + warnings?: string[]; + onTagClick?: (tag: string) => void; +} + +function getDisplayDate(value: string | undefined): string | undefined { + if (!value) return undefined; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + + return date.toLocaleDateString(); +} + +export function renderRaindropStatus(container: HTMLElement, message: string, kind: RaindropStatusKind): void { + container.empty(); + container.createDiv({ + cls: `raindrop-status raindrop-status-${kind}`, + text: message, + }); +} + +export function renderRaindropItems( + container: HTMLElement, + items: RaindropItem[], + options: RenderRaindropOptions = {}, +): void { + container.empty(); + + const root = container.createDiv({ cls: "raindrop-results" }); + if (options.title) { + root.createEl("h4", { cls: "raindrop-results-title", text: options.title }); + } + + if (options.warnings && options.warnings.length > 0) { + const warnings = root.createDiv({ cls: "raindrop-warnings" }); + for (const warning of options.warnings) { + warnings.createDiv({ cls: "raindrop-warning", text: warning }); + } + } + + if (items.length === 0) { + root.createDiv({ cls: "raindrop-status raindrop-status-empty", text: "No Raindrop.io links matched this filter." }); + return; + } + + const list = root.createDiv({ cls: "raindrop-list" }); + for (const item of items) { + const row = list.createDiv({ cls: "raindrop-item" }); + const title = row.createEl("a", { + cls: "raindrop-item-title", + text: item.title || item.link, + attr: { + href: item.link, + target: "_blank", + rel: "noopener noreferrer", + }, + }); + title.setAttr("aria-label", `Open ${item.title || item.link}`); + + const metaParts = [item.domain, getDisplayDate(item.created)].filter((part): part is string => Boolean(part)); + if (metaParts.length > 0) { + row.createDiv({ cls: "raindrop-item-meta", text: metaParts.join(" - ") }); + } + + if (item.excerpt) { + row.createDiv({ cls: "raindrop-item-excerpt", text: item.excerpt }); + } + + if (item.tags && item.tags.length > 0) { + const tags = row.createDiv({ cls: "raindrop-item-tags" }); + for (const tag of item.tags) { + const tagEl = tags.createEl("a", { + cls: "tag raindrop-item-tag", + text: `#${tag}`, + attr: { + href: `#${tag}`, + }, + }); + tagEl.addEventListener("click", (event) => { + event.preventDefault(); + options.onTagClick?.(tag); + }); + } + } + } +} diff --git a/src/settings.ts b/src/settings.ts index 352121e..0ed176e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,36 +1,118 @@ -import {App, PluginSettingTab, Setting} from "obsidian"; -import MyPlugin from "./main"; +import { App, PluginSettingTab, Setting } from "obsidian"; +import type RaindropViewPlugin from "./main"; -export interface MyPluginSettings { - mySetting: string; +export type RaindropTagClickBehavior = "obsidian-search" | "raindrop-search" | "none"; + +const TAG_CLICK_BEHAVIORS = new Set(["obsidian-search", "raindrop-search", "none"]); + +export interface RaindropViewSettings { + accessToken: string; + defaultCollectionId: number; + defaultLimit: number; + defaultSort: string; + tagClickBehavior: RaindropTagClickBehavior; } -export const DEFAULT_SETTINGS: MyPluginSettings = { - mySetting: 'default' +export const DEFAULT_SETTINGS: RaindropViewSettings = { + accessToken: "", + defaultCollectionId: 0, + defaultLimit: 20, + defaultSort: "-created", + tagClickBehavior: "obsidian-search", +}; + +export function isRaindropTagClickBehavior(value: unknown): value is RaindropTagClickBehavior { + return typeof value === "string" && TAG_CLICK_BEHAVIORS.has(value as RaindropTagClickBehavior); } -export class SampleSettingTab extends PluginSettingTab { - plugin: MyPlugin; +function parseNumberSetting(value: string, fallback: number): number { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : fallback; +} - constructor(app: App, plugin: MyPlugin) { +export class RaindropSettingTab extends PluginSettingTab { + plugin: RaindropViewPlugin; + + constructor(app: App, plugin: RaindropViewPlugin) { super(app, plugin); this.plugin = plugin; } display(): void { - const {containerEl} = this; - + const { containerEl } = this; containerEl.empty(); + new Setting(containerEl).setName("Authentication").setHeading(); + new Setting(containerEl) - .setName('Settings #1') - .setDesc('It\'s a secret') - .addText(text => text - .setPlaceholder('Enter your secret') - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - })); + .setName("Access token") + .setDesc("Stored in plugin data and sent only to the bookmark service.") + .addText((text) => { + text.inputEl.type = "password"; + text + .setPlaceholder("Access token") + .setValue(this.plugin.settings.accessToken) + .onChange(async (value) => { + this.plugin.settings.accessToken = value.trim(); + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName("Default collection") + .setDesc("Used when a note block does not set `collection`. Use 0 for all collections.") + .addText((text) => + text + .setPlaceholder("0") + .setValue(String(this.plugin.settings.defaultCollectionId)) + .onChange(async (value) => { + this.plugin.settings.defaultCollectionId = parseNumberSetting(value, DEFAULT_SETTINGS.defaultCollectionId); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Default limit") + .setDesc("Maximum links to request when a note block does not set `limit`.") + .addText((text) => + text + .setPlaceholder("20") + .setValue(String(this.plugin.settings.defaultLimit)) + .onChange(async (value) => { + const parsed = parseNumberSetting(value, DEFAULT_SETTINGS.defaultLimit); + this.plugin.settings.defaultLimit = Math.max(1, Math.min(100, Math.floor(parsed))); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Default sort") + .setDesc("Passed through to Raindrop.io when a note block does not set `sort`.") + .addText((text) => + text + .setPlaceholder("-created") + .setValue(this.plugin.settings.defaultSort) + .onChange(async (value) => { + this.plugin.settings.defaultSort = value.trim(); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Tag click behavior") + .setDesc("Choose whether item tags search notes, filter the explorer, or do nothing.") + .addDropdown((dropdown) => + dropdown + .addOption("obsidian-search", "Search notes for the tag") + .addOption("raindrop-search", "Filter explorer by the tag") + .addOption("none", "Do nothing") + .setValue(this.plugin.settings.tagClickBehavior) + .onChange(async (value) => { + if (isRaindropTagClickBehavior(value)) { + this.plugin.settings.tagClickBehavior = value; + } + await this.plugin.saveSettings(); + }), + ); } } diff --git a/styles.css b/styles.css index 71cc60f..ded4faa 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,112 @@ -/* +.raindrop-results { + display: flex; + flex-direction: column; + gap: 0.75rem; +} -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. +.raindrop-results-title, +.raindrop-side-header h3 { + margin: 0; +} -If your plugin does not need CSS, delete this file. +.raindrop-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} -*/ +.raindrop-item { + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + padding: 0.75rem; + background: var(--background-primary); +} + +.raindrop-item-title { + display: inline-block; + font-weight: var(--font-semibold); + margin-bottom: 0.25rem; +} + +.raindrop-item-meta, +.raindrop-item-excerpt, +.raindrop-status, +.raindrop-warning { + color: var(--text-muted); + font-size: var(--font-ui-small); +} + +.raindrop-item-excerpt { + margin-top: 0.35rem; +} + +.raindrop-item-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.5rem; +} + +.raindrop-item-tag { + border-radius: 999px; + background: var(--background-secondary); + color: var(--text-muted); + font-size: var(--font-ui-smaller); + padding: 0.1rem 0.45rem; +} + +.raindrop-status, +.raindrop-warnings { + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + padding: 0.75rem; +} + +.raindrop-status-error, +.raindrop-warning { + color: var(--text-error); +} + +.raindrop-side-view { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; +} + +.raindrop-side-header { + align-items: center; + display: flex; + justify-content: space-between; + gap: 0.75rem; +} + +.raindrop-explorer-controls { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.raindrop-explorer-search, +.raindrop-explorer-actions { + display: flex; + gap: 0.5rem; +} + +.raindrop-explorer-search input { + flex: 1; + min-width: 0; +} + +.raindrop-explorer-context { + color: var(--text-muted); + font-size: var(--font-ui-small); +} + +.raindrop-side-content { + min-height: 4rem; +} + +.raindrop-load-more { + align-self: stretch; +} diff --git a/version-bump.mjs b/version-bump.mjs index 55d631f..fd3925d 100644 --- a/version-bump.mjs +++ b/version-bump.mjs @@ -11,7 +11,7 @@ writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); // update versions.json with target version and minAppVersion from manifest.json // but only if the target version is not already in versions.json const versions = JSON.parse(readFileSync('versions.json', 'utf8')); -if (!Object.values(versions).includes(minAppVersion)) { +if (!versions[targetVersion]) { versions[targetVersion] = minAppVersion; writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); }