mirror of
https://github.com/l10n-dev/obsidian-plugin-ai-translator.git
synced 2026-07-22 06:56:27 +00:00
Implement AI translation (MVP functionality) (#1)
This commit is contained in:
parent
8f4771834e
commit
4958d6e151
16 changed files with 840 additions and 251 deletions
68
.github/workflows/publish.yaml
vendored
Normal file
68
.github/workflows/publish.yaml
vendored
Normal file
|
|
@ -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
|
||||
48
.gitignore
vendored
48
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
CHANGELOG.md
Normal file
22
CHANGELOG.md
Normal file
|
|
@ -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
|
||||
116
README.md
116
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 `<YourVault>/.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)
|
||||
|
|
|
|||
98
TESTING.md
Normal file
98
TESTING.md
Normal file
|
|
@ -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:
|
||||
```
|
||||
<YourVault>/.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 `<YourVault>/.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`.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
97
package-lock.json
generated
97
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
55
src/LanguageSuggestModal.ts
Normal file
55
src/LanguageSuggestModal.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { App, SuggestModal } from "obsidian";
|
||||
import { Language } from "./types";
|
||||
import { TranslationService } from "./TranslationService";
|
||||
|
||||
export class LanguageSuggestModal extends SuggestModal<Language> {
|
||||
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<Language[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
55
src/TranslationService.ts
Normal file
55
src/TranslationService.ts
Normal file
|
|
@ -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<BalanceResponse> {
|
||||
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<Language[]> {
|
||||
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<TranslateResponse> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
166
src/main.ts
166
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<MyPluginSettings>);
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
(await this.loadData()) as Partial<L10nSettings>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
123
src/settings.ts
123
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
161
src/translator.ts
Normal file
161
src/translator.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
39
src/types.ts
Normal file
39
src/types.ts
Normal file
|
|
@ -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<void>;
|
||||
}
|
||||
10
styles.css
10
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue