mirror of
https://github.com/dotwee/obsidian-raindropio-plugin.git
synced 2026-07-22 06:50:29 +00:00
feat: introduce bookmark explorer
- Add a note-aware explorer for browsing saved bookmarks and filtering from note context. - Support inline blocks, configurable tag behavior, and theme-friendly icon styling. - Prepare release metadata, documentation, licensing, and automated release assets.
This commit is contained in:
parent
2323eddbb1
commit
4dc7207c41
20 changed files with 1262 additions and 176 deletions
14
.cursor/mcp.json
Normal file
14
.cursor/mcp.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
.github/workflows/release.yml
vendored
Normal file
35
.github/workflows/release.yml
vendored
Normal file
|
|
@ -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
|
||||
18
LICENSE
18
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 <lukas@wolfsteiner.media>
|
||||
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
181
README.md
181
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 <lukas@wolfsteiner.media>
|
||||
|
||||
Licensed under the _[DO WHAT THE FUCK YOU WANT TO BUT IT'S NOT MY FAULT PUBLIC LICENSE](LICENSE)_.
|
||||
|
|
|
|||
|
|
@ -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 <lukas@wolfsteiner-media.de>",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
|
|||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
22
package.json
22
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 <lukas@wolfsteiner.media> (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",
|
||||
|
|
|
|||
71
src/block-parser.ts
Normal file
71
src/block-parser.ts
Normal file
|
|
@ -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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
2
src/constants.ts
Normal file
2
src/constants.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const RAINDROP_CODE_BLOCK = "raindrop";
|
||||
export const RAINDROP_VIEW_TYPE = "raindrop-view";
|
||||
20
src/icons.ts
Normal file
20
src/icons.ts
Normal file
|
|
@ -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 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><g fill="currentColor" fill-rule="evenodd"><path d="M35.314 9.686c6.248 6.249 6.248 16.38 0 22.628q-.314.313-.64.605L24 43 13.326 32.92q-.326-.293-.64-.606c-6.248-6.249-6.248-16.38 0-22.628s16.38-6.248 22.628 0"/><path d="M12 19c6.627 0 12 5.373 12 12v12H12C5.373 43 0 37.627 0 31s5.373-12 12-12" opacity=".85"/><path d="M24 43V31l.004-.305C24.166 24.209 29.474 19 36 19c6.627 0 12 5.373 12 12s-5.373 12-12 12z" opacity=".7"/></g></svg>';
|
||||
|
||||
const RAINDROP_FAVICON_ICON_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><g fill="currentColor" fill-rule="evenodd"><path d="M35.314 9.686c6.248 6.249 6.248 16.38 0 22.628q-.314.313-.64.605L24 43 13.326 32.92q-.326-.293-.64-.606c-6.248-6.249-6.248-16.38 0-22.628s16.38-6.248 22.628 0"/><path d="M12 19c6.627 0 12 5.373 12 12v12H12C5.373 43 0 37.627 0 31s5.373-12 12-12" opacity=".8"/><path d="M24 43V31l.004-.305C24.166 24.209 29.474 19 36 19c6.627 0 12 5.373 12 12s-5.373 12-12 12z" opacity=".55"/></g></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);
|
||||
}
|
||||
272
src/main.ts
272
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<void> {
|
||||
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<MyPluginSettings>);
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, (await this.loadData()) as Partial<RaindropViewSettings>);
|
||||
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<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class SampleModal extends Modal {
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
async openRaindropView(searchQuery?: string, context?: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const normalized = tag.replace(/^#/, "").trim();
|
||||
if (!normalized) return;
|
||||
|
||||
const searchQuery = `tag:#${normalized}`;
|
||||
const appWithInternals = this.app as typeof this.app & {
|
||||
internalPlugins?: {
|
||||
plugins?: Record<string, { instance?: unknown }>;
|
||||
};
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
src/note-parser.ts
Normal file
43
src/note-parser.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
27
src/note-search.ts
Normal file
27
src/note-search.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
91
src/raindrop-api.ts
Normal file
91
src/raindrop-api.ts
Normal file
|
|
@ -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<RaindropItem[]> {
|
||||
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<RaindropItem[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
43
src/raindrop-search.ts
Normal file
43
src/raindrop-search.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
253
src/raindrop-view.ts
Normal file
253
src/raindrop-view.ts
Normal file
|
|
@ -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<void> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
92
src/renderer.ts
Normal file
92
src/renderer.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/settings.ts
122
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<RaindropTagClickBehavior>(["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();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
styles.css
114
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue