diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..a0d4911 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,68 @@ +name: Publish release + +on: + push: + branches: + - main + +jobs: + check-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.get-version.outputs.version }} + tag-exists: ${{ steps.check-tag.outputs.exists }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version from package.json + id: get-version + run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" + + - name: Check if tag already exists + id: check-tag + run: | + VERSION="${{ steps.get-version.outputs.version }}" + if git tag --list | grep -qx "$VERSION"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + publish: + needs: check-version + if: needs.check-version.outputs.tag-exists == 'false' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Tag release + run: | + VERSION="${{ needs.check-version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$VERSION" + git push origin "$VERSION" + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.check-version.outputs.version }} + name: ${{ needs.check-version.outputs.version }} + files: | + main.js + manifest.json + styles.css diff --git a/.gitignore b/.gitignore index e09a007..4fa59f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,26 @@ -# vscode -.vscode - -# Intellij -*.iml -.idea - -# npm -node_modules - -# Don't include the compiled main.js file in the repo. -# They should be uploaded to GitHub releases instead. -main.js - -# Exclude sourcemaps -*.map - -# obsidian -data.json - -# Exclude macOS Finder (System Explorer) View States -.DS_Store +# vscode +.vscode + +# Intellij +*.iml +.idea + +# npm +node_modules + +# Don't include the compiled main.js file in the repo. +# They should be uploaded to GitHub releases instead. +main.js + +# Exclude sourcemaps +*.map + +# obsidian +data.json + +# Exclude macOS Finder (System Explorer) View States +.DS_Store + +# Exclude obsidian developer docs +obsidian-developer-docs +apil10ndev*.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..24d28d3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] — 2026-04-28 + +### Added + +- Translate the active note via the command palette ("Translate current note"), ribbon icon, and right-click context menus (file explorer and editor) +- Dynamic language picker using `SuggestModal` — searches [l10n.dev](https://l10n.dev) in real time with a 300 ms debounce; no hardcoded language list +- Three output modes configurable in settings: + - **Create a new note** (default) — saves translation as `{filename} ({lang-code}).md` in the same folder + - **Replace current note content** — overwrites the note in place + - **Append to current note** — appends translation below a horizontal rule +- YAML frontmatter toggle — when disabled, frontmatter is preserved verbatim and only the note body is sent for translation +- Success notice shows characters used and remaining balance from the API response +- Secure API key input (password-masked) in settings +- Async balance display in settings tab — fetches current character balance when the tab is opened +- Typed error handling: 401 Unauthorized and 402 Payment Required surface actionable notices with direct links to [l10n.dev/ws/keys](https://l10n.dev/ws/keys) and [l10n.dev/#pricing](https://l10n.dev/#pricing) +- Mobile-compatible — all network calls use Obsidian's `requestUrl`; no Node.js or Electron APIs used diff --git a/README.md b/README.md index 8ffa20e..03491c0 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,74 @@ -# Obsidian Sample Plugin +# L10n.dev - AI Translator for Obsidian -This is a sample plugin for Obsidian (https://obsidian.md). +Translate your Obsidian notes using [l10n](https://l10n.dev).dev — an AI-powered localization API. Works on desktop and mobile. -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. +## Features -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. +- Context-aware translations using advanced AI. Translate to 165+ languages. +- Preserves Markdown formatting and struvture. +- Translate the active note via the command palette, ribbon icon, or right-click context menu. +- Dynamic language search — type a language name to find it instantly (no hardcoded list) +- Remembers your last used language — one keypress to repeat the same translation. +- Three output modes: create a new note, replace the current note, or append the translation. +- Optional YAML frontmatter preservation — translate only the note body if desired. +- Shows characters used and remaining balance after each translation. +- Mobile-compatible — uses Obsidian's native network layer, no Node.js APIs -## First time developing plugins? +## Requirements -Quick starting guide for new plugin devs: +A free [l10n.dev](https://l10n.dev) account. You receive **30,000 characters free per month** after signing up. Get your API key at [l10n.dev/ws/keys](https://l10n.dev/ws/keys). -- 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. +## Installation -## Releasing new releases +### Community plugin (recommended) -- 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. +1. Open **Settings → Community plugins** and select **Browse**. +2. Search for **L10n.dev - AI Translator**. +3. Select **Install**, then **Enable**. -> 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` +### Manual installation -## Adding your plugin to the community plugin list +1. Go to the [latest release](../../releases/latest) and download `manifest.json`, `main.js`, and `styles.css`. +2. In your vault, create the folder `/.obsidian/plugins/ai-translator/`. +3. Copy the three downloaded files into that folder. +4. Open Obsidian, go to **Settings → Community plugins**, and enable **L10n.dev - AI Translator**. -- 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. +## Setup -## How to use +1. Open **Settings → L10n.dev - AI Translator**. +2. Paste your l10n.dev API key into the **API key** field. +3. Choose your preferred **Output behavior** and toggle **Translate frontmatter** as needed. -- 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. +## Usage -## Manually installing the plugin +With a note open, trigger translation in any of these ways: -- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. +- **Command palette** — run `Translate current note` +- **Ribbon** — select the globe icon in the left sidebar +- **Context menu** — right-click a file in the file explorer or inside the editor and select **Translate…** -## 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/obsidianmd/eslint-plugin) for Obsidan specific code guidelines. -- A GitHub action is preconfigured to automatically lint every commit on all branches. +A language picker will open. Type a language name (e.g. "Spanish", "German", "Japanese") and select your target language. The translation will be saved according to your output behavior setting. -## Funding URL +### Repeat translation to the same language -You can include funding URLs where people who use your plugin can financially support it. +After your first translation, the last used language is saved automatically. The next time the language picker opens, it pre-selects that language — press Enter to confirm without typing anything. -The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: +For even faster repeat translations, use the **Translate to last used language** command from the command palette. It skips the language picker entirely and translates immediately. Assign a hotkey to it in **Settings → Hotkeys** for one-keystroke translation. -```json -{ - "fundingUrl": "https://buymeacoffee.com" -} -``` +## Output behavior -If you have multiple URLs, you can also do: +| Setting | Result | +|---|---| +| Create a new note (default) | Saves translation as `{filename} ({lang-code}).md` in the same folder | +| Replace current note content | Overwrites the current note with the translation | +| Append to current note | Appends the translation below a horizontal rule | -```json -{ - "fundingUrl": { - "Buy Me a Coffee": "https://buymeacoffee.com", - "GitHub Sponsor": "https://github.com/sponsors", - "Patreon": "https://www.patreon.com/" - } -} -``` +## Privacy -## API Documentation +Translation requests are sent to the [AI translation API](https://api.l10n.dev/doc/#tag/ai-translation) over HTTPS. l10n.dev does not store your content after translation. See the [l10n.dev terms of service](https://l10n.dev/terms-of-service) for details. -See https://docs.obsidian.md +No telemetry or analytics are collected by this plugin. + +## License + +[MIT](LICENSE) diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..2778ba8 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,98 @@ +# Testing the L10n.dev - AI Translator plugin + +## Prerequisites + +- [Node.js](https://nodejs.org/) 18 or later +- An Obsidian vault (desktop or mobile) +- A [l10n.dev](https://l10n.dev) account and API key — get one free at [l10n.dev/ws/keys](https://l10n.dev/ws/keys) + +--- + +## 1. Get the plugin files + +**For testing a release** — download `main.js`, `manifest.json`, and `styles.css` from the [latest release](../../releases/latest). + +**For local development** — build from source: + +```bash +npm install +npm run build +``` + +This produces `main.js` in the repository root. + +--- + +## 2. Install into your vault (desktop) + +1. Locate your vault folder. Inside it, open (or create) the directory: + ``` + /.obsidian/plugins/ai-translator/ + ``` +2. Copy the following files from the repository root into that folder: + - `main.js` + - `manifest.json` + - `styles.css` (if present) +3. Open Obsidian. +4. Go to **Settings → Community plugins**. +5. If prompted, select **Turn on community plugins**. +6. Find **L10n.dev - AI Translator** in the list and toggle it on. + +> **Tip:** Clone the repository directly into `/.obsidian/plugins/ai-translator/` and run `npm run dev` for live reloading during development. + +--- + +## 3. Configure the plugin + +1. Open **Settings → L10n.dev - AI Translator**. +2. Paste your l10n.dev API key into the **API key** field — the remaining balance should appear below it automatically. +3. Choose an **Output behavior** and set the **Translate frontmatter** toggle as desired. + +--- + +## 4. Run a translation + +1. Open any Markdown note. +2. Trigger translation using any of these entry points: + - **Command palette** — `Ctrl/Cmd + P` → `Translate current note` + - **Ribbon** — select the globe icon in the left sidebar + - **Context menu (editor)** — right-click inside the note body → **Translate…** + - **Context menu (file explorer)** — right-click a `.md` file → **Translate…** +3. Type a language name in the picker (e.g. "Spanish") and select a result. +4. The output is saved according to the output behavior setting. + +--- + +## 5. Manual test checklist + +| Scenario | Expected result | +|---|---| +| Valid API key, output = **create new note** | `{filename} ({lang-code}).md` created in the same folder | +| Valid API key, output = **replace** | Current note content replaced | +| Valid API key, output = **append** | Translation appended below `---` | +| **Translate frontmatter** OFF | YAML block at top of translated file is identical to original | +| **Translate frontmatter** ON | YAML block is translated along with the body | +| Wrong API key | Notice: "Invalid API key. Get your key at https://l10n.dev/ws/keys" | +| Insufficient balance | Notice: "Insufficient balance. Top up at https://l10n.dev/#pricing" | +| No note open, command triggered | Notice: "No active note to translate." | +| Language picker closed without selecting | Translation cancelled silently | +| Success | Notice shows characters used and remaining balance | + +--- + +## 6. Mobile testing + +1. Build `main.js` on desktop (`npm run build`). +2. Transfer `main.js`, `manifest.json`, and `styles.css` to your vault's `.obsidian/plugins/ai-translator/` folder using a sync solution (e.g. iCloud, Obsidian Sync, or manual file transfer). +3. Open Obsidian on the mobile device and enable the plugin as described in step 2. +4. Repeat the manual test checklist on mobile, paying attention to the language picker and context menu behaviour. + +--- + +## 7. Linting + +```bash +npm run lint +``` + +The project uses [eslint-plugin-obsidianmd](https://github.com/obsidianmd/eslint-plugin) for Obsidian-specific code guidelines alongside `typescript-eslint`. diff --git a/manifest.json b/manifest.json index dfa940e..d011f76 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,10 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", + "id": "ai-translator", + "name": "L10n.dev - AI Translator", "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": "Translate notes into any of 165 languages with AI. Preserves Markdown formatting and structure. Powered by L10n.dev's AI translation API.", + "author": "l10n.dev", + "authorUrl": "https://l10n.dev", "isDesktopOnly": false } diff --git a/package-lock.json b/package-lock.json index d0dac39..74db538 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { - "name": "obsidian-sample-plugin", + "name": "obsidian-plugin-ai-translator", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "obsidian-sample-plugin", + "name": "obsidian-plugin-ai-translator", "version": "1.0.0", - "license": "0-BSD", + "license": "MIT", "dependencies": { "obsidian": "latest" }, @@ -1044,9 +1044,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -1054,13 +1054,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1148,9 +1148,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1381,9 +1381,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2188,9 +2188,9 @@ } }, "node_modules/eslint-plugin-json-schema-validator/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -2205,9 +2205,9 @@ } }, "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -2222,9 +2222,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-json-schema-validator/node_modules/minimatch": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", - "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", "dev": true, "license": "ISC", "dependencies": { @@ -2264,9 +2264,9 @@ } }, "node_modules/eslint-plugin-n/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -2287,13 +2287,13 @@ } }, "node_modules/eslint-plugin-n/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2691,9 +2691,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3522,9 +3522,9 @@ } }, "node_modules/json-schema-migrate/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -3746,9 +3746,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4044,9 +4044,9 @@ "license": "MIT" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5101,9 +5101,9 @@ } }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -5111,6 +5111,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yaml-eslint-parser": { diff --git a/package.json b/package.json index 17268d7..ccd7614 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "obsidian-sample-plugin", + "name": "obsidian-plugin-ai-translator", "version": "1.0.0", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "description": "Obsidian plugin to translate notes with AI using l10n.dev", "main": "main.js", "type": "module", "scripts": { @@ -11,7 +11,7 @@ "lint": "eslint ." }, "keywords": [], - "license": "0-BSD", + "license": "MIT", "devDependencies": { "@types/node": "^16.11.6", "esbuild": "0.25.5", diff --git a/src/LanguageSuggestModal.ts b/src/LanguageSuggestModal.ts new file mode 100644 index 0000000..4358909 --- /dev/null +++ b/src/LanguageSuggestModal.ts @@ -0,0 +1,55 @@ +import { App, SuggestModal } from "obsidian"; +import { Language } from "./types"; +import { TranslationService } from "./TranslationService"; + +export class LanguageSuggestModal extends SuggestModal { + private onSelect: (lang: Language) => void; + private lastLanguage: Language | undefined; + private debounceTimer: number | null = null; + + constructor( + app: App, + lastLanguage: Language | undefined, + onSelect: (lang: Language) => void, + ) { + super(app); + this.lastLanguage = lastLanguage; + this.onSelect = onSelect; + this.setPlaceholder( + lastLanguage + ? `Last used: ${lastLanguage.name} — type to change…` + : "Type a language name (e.g. Spanish, German)…", + ); + } + + async getSuggestions(query: string): Promise { + if (!query.trim()) { + return this.lastLanguage ? [this.lastLanguage] : []; + } + return new Promise((resolve) => { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.debounceTimer = null; + TranslationService.predictLanguages(query) + .then(resolve) + .catch(() => resolve([])); + }, 300); + }); + } + + renderSuggestion(lang: Language, el: HTMLElement): void { + el.createEl("span", { text: `${lang.name} — ${lang.code}` }); + if (this.lastLanguage?.code === lang.code) { + el.createEl("span", { + text: "Last used", + cls: "ai-translator-last-used-badge", + }); + } + } + + onChooseSuggestion(lang: Language): void { + this.onSelect(lang); + } +} diff --git a/src/TranslationService.ts b/src/TranslationService.ts new file mode 100644 index 0000000..07a5df6 --- /dev/null +++ b/src/TranslationService.ts @@ -0,0 +1,55 @@ +import { requestUrl } from "obsidian"; +import { + BalanceResponse, + Language, + TranslateRequest, + TranslateResponse, +} from "./types"; + +const API_BASE = "https://api.l10n.dev"; + +export class TranslationService { + static async getBalance(apiKey: string): Promise { + const response = await requestUrl({ + url: `${API_BASE}/v2/balance`, + method: "GET", + headers: { "X-API-Key": apiKey }, + throw: false, + }); + if (response.status === 401) throw new Error("unauthorized"); + if (response.status !== 200) throw new Error("network_error"); + return response.json as BalanceResponse; + } + + static async predictLanguages(input: string): Promise { + const response = await requestUrl({ + url: `${API_BASE}/v2/languages/predict?input=${encodeURIComponent(input)}&limit=10`, + method: "GET", + throw: false, + }); + if (response.status !== 200) return []; + const body = response.json as { languages?: Language[] }; + return body.languages ?? []; + } + + static async translate( + apiKey: string, + req: TranslateRequest, + ): Promise { + console.debug("[AI Translator] Sending translation request:", req); + const response = await requestUrl({ + url: `${API_BASE}/v2/translate`, + method: "POST", + headers: { + "X-API-Key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(req), + throw: false, + }); + if (response.status === 401) throw new Error("unauthorized"); + if (response.status === 402) throw new Error("quota_exceeded"); + if (response.status !== 200) throw new Error("network_error"); + return response.json as TranslateResponse; + } +} diff --git a/src/main.ts b/src/main.ts index 6fe0c83..89ade9a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,99 +1,119 @@ -import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian'; -import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings"; +import { Menu, Plugin, TAbstractFile, TFile } from "obsidian"; +import { L10nSettings } from "./types"; +import { DEFAULT_SETTINGS, L10nSettingsTab } from "./settings"; +import { translateActiveNote, translateToLastLanguage } from "./translator"; -// Remember to rename these classes and interfaces! - -export default class MyPlugin extends Plugin { - settings: MyPluginSettings; +export default class L10nPlugin extends Plugin { + settings: L10nSettings = { ...DEFAULT_SETTINGS }; async onload() { await this.loadSettings(); - // 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.addRibbonIcon("languages", "Translate note", () => { + console.debug("[AI Translator] Translating active note"); + translateActiveNote(this); }); - // 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 adds a simple command that can be triggered anywhere this.addCommand({ - id: 'open-modal-simple', - name: 'Open modal (simple)', + id: "translate-current-note", + name: "Translate current note", callback: () => { - new SampleModal(this.app).open(); - } + translateActiveNote(this); + }, }); - // 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; - } - return false; - } + id: "translate-to-last-language", + name: "Translate to last used language", + callback: () => { + translateToLastLanguage(this); + }, }); - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new SampleSettingTab(this.app, this)); + this.registerEvent( + this.app.workspace.on( + "file-menu", + (menu: Menu, abstractFile: TAbstractFile) => { + if (!(abstractFile instanceof TFile)) return; + menu.addItem((item) => { + item.setTitle("Translate…") + .setIcon("languages") + .onClick(() => { + console.debug( + "[AI Translator] Translating file:", + abstractFile.path, + ); + translateActiveNote(this, abstractFile); + }); + }); + }, + ), + ); - // 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"); - }); + this.registerEvent( + this.app.workspace.on( + "file-menu", + (menu: Menu, abstractFile: TAbstractFile) => { + if (!(abstractFile instanceof TFile)) return; + menu.addItem((item) => { + item.setTitle("Translate to last used language") + .setIcon("languages") + .onClick(() => { + console.debug( + "[AI Translator] Translating file to last used language:", + abstractFile.path, + ); + translateToLastLanguage(this, abstractFile); + }); + }); + }, + ), + ); - // 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.workspace.on("editor-menu", (menu: Menu) => { + menu.addItem((item) => { + item.setTitle("Translate to last used language") + .setIcon("languages") + .onClick(() => { + console.debug( + "[AI Translator] Translating current note to last used language", + ); + translateToLastLanguage(this); + }); + }); + }), + ); + this.registerEvent( + this.app.workspace.on("editor-menu", (menu: Menu) => { + menu.addItem((item) => { + item.setTitle("Translate…") + .setIcon("languages") + .onClick(() => { + console.debug( + "[AI Translator] Translating current note", + ); + translateActiveNote(this); + }); + }); + }), + ); + + this.addSettingTab(new L10nSettingsTab(this.app, this)); } - onunload() { - } + onunload() {} async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); + this.settings = Object.assign( + {}, + DEFAULT_SETTINGS, + (await this.loadData()) as Partial, + ); } async saveSettings() { await this.saveData(this.settings); } } - -class SampleModal extends Modal { - constructor(app: App) { - super(app); - } - - onOpen() { - let {contentEl} = this; - contentEl.setText('Woah!'); - } - - onClose() { - const {contentEl} = this; - contentEl.empty(); - } -} diff --git a/src/settings.ts b/src/settings.ts index 352121e..f67b7b9 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,36 +1,115 @@ -import {App, PluginSettingTab, Setting} from "obsidian"; -import MyPlugin from "./main"; +import { App, Plugin, PluginSettingTab, Setting } from "obsidian"; +import { L10nSettings, OutputBehavior, PluginWithSettings } from "./types"; +import { TranslationService } from "./TranslationService"; -export interface MyPluginSettings { - mySetting: string; -} +export type { L10nSettings }; -export const DEFAULT_SETTINGS: MyPluginSettings = { - mySetting: 'default' -} +export const DEFAULT_SETTINGS: L10nSettings = { + apiKey: "", + outputBehavior: "new-note", + translateFrontmatter: false, +}; -export class SampleSettingTab extends PluginSettingTab { - plugin: MyPlugin; +export class L10nSettingsTab extends PluginSettingTab { + plugin: PluginWithSettings; - constructor(app: App, plugin: MyPlugin) { + constructor(app: App, plugin: Plugin & PluginWithSettings) { super(app, plugin); this.plugin = plugin; } display(): void { - const {containerEl} = this; - + const { containerEl } = this; containerEl.empty(); + new Setting(containerEl).setName("AI translator").setHeading(); + + // Balance refresh helper (defined before API key so onChange can call it) + const refreshBalance = (apiKey: string) => { + if (!apiKey) { + quotaEl.setText( + ` ${(30000).toLocaleString()} characters free monthly.`, + ); + return; + } + quotaEl.setText(" Loading…"); + TranslationService.getBalance(apiKey) + .then((res) => { + quotaEl.setText( + ` ${res.currentBalance.toLocaleString()} characters`, + ); + }) + .catch(() => { + quotaEl.setText( + ` ${(30000).toLocaleString()} characters free monthly.`, + ); + }); + }; + + // API Key + const apiKeySetting = new Setting(containerEl) + .setName("API key") + .addText((text) => { + text.inputEl.type = "password"; + text.setPlaceholder("Paste your API key here") + .setValue(this.plugin.settings.apiKey) + .onChange(async (value) => { + this.plugin.settings.apiKey = value.trim(); + await this.plugin.saveSettings(); + refreshBalance(this.plugin.settings.apiKey); + }); + }); + apiKeySetting.descEl.appendText("Your l10n.dev API key. Get one at "); + apiKeySetting.descEl.createEl("a", { + text: "l10n.dev/ws/keys", + href: "https://l10n.dev/ws/keys", + attr: { target: "_blank", rel: "noopener noreferrer" }, + }); + + // Quota display + const quotaSetting = new Setting(containerEl) + .setName("Remaining balance") + .setDesc("Characters remaining for translation.") + .addButton((btn) => { + btn.setButtonText("Buy characters").onClick(() => { + window.open("https://l10n.dev/#pricing", "_blank"); + }); + }); + const quotaEl = quotaSetting.descEl.createEl("span", { text: "" }); + + // Initial balance fetch + refreshBalance(this.plugin.settings.apiKey); + + // Output behavior 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("Output behavior") + .setDesc("How translated content is saved.") + .addDropdown((dropdown) => { + dropdown + .addOption("new-note", "Create a new note") + .addOption("replace", "Replace current note content") + .addOption("append", "Append to current note") + .setValue(this.plugin.settings.outputBehavior) + .onChange(async (value) => { + this.plugin.settings.outputBehavior = + value as OutputBehavior; + await this.plugin.saveSettings(); + }); + }); + + // Translate frontmatter + new Setting(containerEl) + .setName("Translate frontmatter") + .setDesc( + "When enabled, YAML frontmatter will be included in the translation.", + ) + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.translateFrontmatter) + .onChange(async (value) => { + this.plugin.settings.translateFrontmatter = value; + await this.plugin.saveSettings(); + }); + }); } } diff --git a/src/translator.ts b/src/translator.ts new file mode 100644 index 0000000..955132e --- /dev/null +++ b/src/translator.ts @@ -0,0 +1,161 @@ +import { Notice, TFile } from "obsidian"; +import { LanguageSuggestModal } from "./LanguageSuggestModal"; +import { TranslationService } from "./TranslationService"; +import { Language, PluginWithSettings } from "./types"; + +const FRONTMATTER_REGEX = /^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/; + +function splitFrontmatter( + content: string, +): { frontmatter: string; body: string } | null { + const match = FRONTMATTER_REGEX.exec(content); + if (!match) return null; + return { frontmatter: match[1] ?? "", body: match[2] ?? "" }; +} + +async function doTranslate( + plugin: PluginWithSettings, + file: TFile, + lang: Language, +): Promise { + let content: string; + try { + content = await plugin.app.vault.read(file); + } catch { + new Notice("Failed to read the note."); + return; + } + + let sourceText = content; + let frontmatter = ""; + if (!plugin.settings.translateFrontmatter) { + const parsed = splitFrontmatter(content); + if (parsed) { + frontmatter = parsed.frontmatter; + sourceText = parsed.body; + } + } + + let translated: string; + let charsUsed = 0; + let remainingBalance: number | null | undefined; + const progressNotice = new Notice("Translating…", 0); + try { + const result = await TranslationService.translate( + plugin.settings.apiKey, + { + sourceStrings: sourceText, + targetLanguageCode: lang.code, + format: "txt", + useContractions: true, + }, + ); + translated = result.translations; + charsUsed = result.usage.charsUsed; + remainingBalance = result.remainingBalance; + } catch (err) { + progressNotice.hide(); + const msg = err instanceof Error ? err.message : ""; + if (msg === "unauthorized") { + new Notice( + "Invalid API key. Get your key at https://l10n.dev/ws/keys", + ); + } else if (msg === "quota_exceeded") { + new Notice( + "Insufficient balance. Top up at https://l10n.dev/#pricing", + ); + } else { + console.error("[AI Translator]", err); + new Notice( + "Translation failed. Check the developer console for details.", + ); + } + return; + } + progressNotice.hide(); + + const outputContent = frontmatter + translated; + const { outputBehavior } = plugin.settings; + + try { + if (outputBehavior === "replace") { + await plugin.app.vault.modify(file, outputContent); + } else if (outputBehavior === "append") { + await plugin.app.vault.modify( + file, + content + "\n\n---\n\n" + translated, + ); + } else { + // new-note (default) + const folder = file.parent ? file.parent.path : ""; + const newPath = + (folder && folder !== "/" ? folder + "/" : "") + + `${file.basename} (${lang.code}).md`; + const existing = plugin.app.vault.getAbstractFileByPath(newPath); + if (existing instanceof TFile) { + await plugin.app.vault.modify(existing, outputContent); + } else { + await plugin.app.vault.create(newPath, outputContent); + } + } + const balanceText = + remainingBalance != null + ? ` ${remainingBalance.toLocaleString()} characters remaining.` + : ""; + new Notice( + `Translation complete. Used ${charsUsed.toLocaleString()} characters.${balanceText}`, + ); + } catch (err) { + console.error("[AI Translator]", err); + new Notice("Failed to save the translated note."); + } +} + +export function translateActiveNote( + plugin: PluginWithSettings, + targetFile?: TFile, +): void { + if (!plugin.settings.apiKey) { + new Notice("Please set your l10n.dev API key in settings."); + return; + } + + const file = targetFile ?? plugin.app.workspace.getActiveFile(); + if (!file) { + new Notice("No active note to translate."); + return; + } + + new LanguageSuggestModal( + plugin.app, + plugin.settings.lastLanguage, + (lang) => { + plugin.settings.lastLanguage = lang; + void plugin.saveSettings(); + void doTranslate(plugin, file, lang); + }, + ).open(); +} + +export function translateToLastLanguage( + plugin: PluginWithSettings, + targetFile?: TFile, +): void { + const lang = plugin.settings.lastLanguage; + if (!lang) { + return translateActiveNote(plugin, targetFile); + } + + if (!plugin.settings.apiKey) { + new Notice("Please set your l10n.dev API key in settings."); + return; + } + + const file = targetFile ?? plugin.app.workspace.getActiveFile(); + if (!file) { + new Notice("No active note to translate."); + return; + } + + void doTranslate(plugin, file, lang); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..854c10f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,39 @@ +export interface Language { + code: string; + name: string; +} + +export type OutputBehavior = "new-note" | "replace" | "append"; + +export interface L10nSettings { + apiKey: string; + outputBehavior: OutputBehavior; + translateFrontmatter: boolean; + lastLanguage?: Language; +} + +export interface TranslateRequest { + sourceStrings: string; + targetLanguageCode: string; + format?: string; + useContractions?: boolean; +} + +export interface TranslateResponse { + translations: string; + finishReason: string; + usage: { charsUsed: number }; + remainingBalance?: number | null; +} + +export interface BalanceResponse { + currentBalance: number; +} + +import type { App } from "obsidian"; + +export interface PluginWithSettings { + app: App; + settings: L10nSettings; + saveSettings(): Promise; +} diff --git a/styles.css b/styles.css index 71cc60f..8202cbd 100644 --- a/styles.css +++ b/styles.css @@ -6,3 +6,13 @@ available in the app when your plugin is enabled. If your plugin does not need CSS, delete this file. */ + +.ai-translator-last-used-badge { + margin-left: 6px; + padding: 1px 6px; + border-radius: 3px; + font-size: 0.75em; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + vertical-align: middle; +} diff --git a/tsconfig.json b/tsconfig.json index 222535d..a4e09c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ -{ +{ "compilerOptions": { - "baseUrl": "src", "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", @@ -9,7 +8,7 @@ "noImplicitAny": true, "noImplicitThis": true, "noImplicitReturns": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "importHelpers": true, "noUncheckedIndexedAccess": true, "isolatedModules": true, @@ -17,14 +16,7 @@ "strictBindCallApply": true, "allowSyntheticDefaultImports": true, "useUnknownInCatchVariables": true, - "lib": [ - "DOM", - "ES5", - "ES6", - "ES7" - ] + "lib": ["DOM", "ES5", "ES6", "ES7"] }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] }